Compare commits

...

73 Commits

Author SHA1 Message Date
Cyril ae9f348663 fixup! (frontend) make components accessible to screen readers 2025-09-04 12:21:29 +02:00
Cyril 360b83b957 fixup! (frontend) make components accessible to screen readers 2025-09-04 12:21:28 +02:00
Cyril a88fa5a26c fixup! (frontend) add keyboard navigation for subdocs with focus activation 2025-09-04 12:21:20 +02:00
Cyril e6e024a42c fixup! ️(frontend) improve accessibility of selected document's sub-menu 2025-09-04 11:45:47 +02:00
Cyril 9cdd0bd7f9 fixup! (frontend) add keyboard navigation for subdocs with focus activation 2025-09-04 11:45:44 +02:00
Cyril 66869c2758 fixup! (frontend) add keyboard navigation for subdocs with focus activation 2025-09-04 11:44:38 +02:00
Cyril 057539e2b7 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:44:38 +02:00
Cyril 48ebb5a85c fixup! (frontend) make components accessible to screen readers 2025-09-04 11:44:37 +02:00
Cyril ef9411d5e9 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:44:37 +02:00
Cyril 4456e8b1d6 fixup! ️(frontend) improve accessibility of selected document's sub-menu 2025-09-04 11:44:33 +02:00
Cyril 2f4cd67af0 fixup! (frontend) add keyboard navigation for subdocs with focus activation 2025-09-04 11:42:50 +02:00
Cyril 922d05d7e9 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:42:49 +02:00
Cyril cf835100b0 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:42:48 +02:00
Cyril 74b7afaee2 fixup! (frontend) make components accessible to screen readers 2025-09-04 11:42:48 +02:00
Cyril 5b0e2e37e4 (frontend) make components accessible to screen readers
adds proper aria props and translation keys for accessibility support

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-04 11:42:45 +02:00
Cyril a5e22fddf9 (frontend) add keyboard navigation for subdocs with focus activation
enter/space now trigger only on real focus add useTreeItemKeyboardActivate hook

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-04 11:36:17 +02:00
Cyril d84a9cb24a ️(frontend) improve accessibility of selected document's sub-menu
adds focus style to make the sub-menu accessible unify dropdownmenu

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-04 11:31:43 +02:00
Anthony LC 39ef6d10ff 🐛(frontend) fix display bug on homepage
A section in the homepage was not displaying
correctly anymore. This commit fixes the issue.
2025-09-04 10:44:35 +02:00
Anthony LC 961ae3c39e 🐛(frontend) remove padding from ul in left panel favorites
We recently change from a div to a ul for better semantics.
ul include by default padding, we remove it to
align with the design.
2025-09-04 10:27:53 +02:00
Anthony LC 726b50d6b5 ️(frontend) improve fallback width calculation
Sometimes we do not have the width of some
columns in a table. In such cases, we need to
calculate a fallback width to ensure the table
is rendered correctly.
We were previously using 120 points as the
fallback width, but this has been improved
to better fit the content.
We now check the size left and distribute it
among the unknown columns.
2025-09-03 18:11:43 +02:00
Anthony LC 814eb1f1a1 🐛(frontend) fix modal backdrop z-index
Recent upgrade of ui-kit removed the z-index for
the modal backdrop, causing it to be hidden behind
other elements. This commit restores the z-index
 to ensure the modal backdrop is displayed correctly.
2025-09-03 10:21:27 +02:00
Anthony LC 648528499c 🐛(frontend) fix dnd conflict with tree and Blocknote
We have different DND system in the page, one on the
menu tree and one in the Blocknote editor.
The menu tree was adding a transparent layer
when user were dragging element on the Blocknote
editor, blocking the Blocknote DND.
We update the ui-kit to add the prop dndRootElement,
dndRootElement is now used to specify the root element
for DND, this transparent layer is now only applied
when a drag is made from the menu tree.

It stabilize as well the drop position, making it easier to drop element.
2025-09-03 10:21:27 +02:00
Anthony LC 474e5ac0c0 🐛(frontend) fix background color during dnd
When we were dragging an item in the doc tree,
the background color was opaque, making it difficult
to see the underlying content. The cause was
that we were overriding the transparent background
color.
2025-09-03 10:21:27 +02:00
Henry Hiles a799d77643 📝(doc) add Federated Nexus docs instance (#1320)
## Purpose

Adds a new Docs instance, as suggested here:
https://github.com/suitenumerique/docs/discussions/1316

## 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: Henry-Hiles <henry@henryhiles.com>
2025-09-02 14:38:52 +00:00
Manuel Raynaud 2e04b63d2d 🐛(backend) allow creator to delete subpages
An editor who created a subpages should be allowed to delete it.
We change the abilities to be coherent between the creation and the
deletion.
Fixes #1193
2025-09-01 22:43:44 +02:00
Anthony LC eec419bdba 🐛(renovate) fix renovate
Fix renovate configuration:
- schedule at 7am the monday
- avoid wiping manual fixes unless needed
- only create during schedule
2025-09-01 17:05:49 +02:00
Anthony LC baa5630344 🐛(CI) fix npm fallback on bundle-size-check
Adding install-script to ensure Yarn is used explicitly
during compressed-size-action to prevent npm peer conflict.
2025-09-01 17:05:49 +02:00
Anthony LC e7b551caa4 🍱(frontend) update material-icons font
We don't use the material-icons font from
ui-kit anymore, we need to import it directly
in order to use both the outlined and filled variants.
2025-09-01 17:05:48 +02:00
Anthony LC 4dfc1584bd ️(frontend) add back Marianne font
Marianne font was in base64 in the ui-kit,
it was not an optimize way to do it.
We do not have a CDN yet so the best
is to put them back in the project in
waiting for a CDN options.
2025-09-01 17:05:48 +02:00
renovate[bot] 09eddfc339 ⬆️(dependencies) update js dependencies 2025-09-01 17:05:48 +02:00
Olivier Laurendeau 75f2e547e0 🔧(frontend) increase test-e2e-other-browser action timeout
It was failing at 20min, increase the timeout to 30 min
2025-09-01 16:35:17 +02:00
Olivier Laurendeau d1cbdfd819 (frontend) use title first emoji as doc icon in tree
Implemented emoji detection system, new DocIcon component.
2025-09-01 16:35:17 +02:00
Manuel Raynaud 0b64417058 🔧(ci) always run all git-lint steps (#1323)
git-lint steps are independant and we would like to have all checks at
once. Using the `if: always()` instruction should ensure all steps
should be run event if the previous fails.
2025-09-01 12:29:47 +00:00
Anthony LC 57a505a80c ⬆️(dependencies) Bump next from 15.4.6 to 15.4.7
Bumps [next](https://github.com/vercel/next.js) from 15.4.6
to 15.4.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.4.6...v15.4.7)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.4.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 11:56:40 +02:00
Anthony LC 21ee38c218 🐛(frontend) fix export when quote block and inline code
When exporting documents, if a inline code was inside
a quote block, the PDF export was failing because the
inline code was searching the GeistMono font in
italics, which was not available.
We switch to the core "Courier" font for code marks,
which is available in italics.
2025-08-29 18:42:27 +02:00
Manuel Raynaud 09de014a43 🐛(back) allow ASCII characters in user sub field
All ASCII characters are allowed in a sub, we change the sub validator
to reflect this.
2025-08-29 13:59:06 +00:00
Anthony LC 8d42149304 👷(CI) trigger bundle-size-check only if necessary
We don't need to run the bundle-size-check job if
the app didn't change.
If the yarn.lock file or the app have changed, the
bundle-size-check job will be triggered.
2025-08-29 10:21:13 +02:00
Maximilian Bosch 2451a6a322 📦️(mail) make DIR_MAILS variable configurable
That allows downstream packages to e.g. split the builds of
frontend/backend/mail, as it's the case in nixpkgs:
https://github.com/NixOS/nixpkgs/commit/5597a4f8f9f82e6a4829f3527728016c07838f4f
2025-08-29 08:00:43 +00:00
Manuel Raynaud d5c9eaca5a ♻️(backend) fallback to email identifier when no name (#1298)
In the UserlightSerializer, if the user has no short_name or full_name,
we have no info about the user. We decided to use the email identifier
and slugify it to have a little bit information.
2025-08-29 07:39:55 +00:00
Loïc Tosser 1491012969 🔧(project) update OIDC_OP_LOGOUT_ENDPOINT to match keycloak
Update default value of OIDC_OP_LOGOUT_ENDPOINT
to match keycloak.
2025-08-28 17:06:07 +02:00
Karl Horky 9dcf478dd3 ✏️(frontend) fix GitHub capitalization
The capitalization of the product GitHub has a capital H

Signed-off-by: Karl Horky <karl.horky@gmail.com>
2025-08-28 16:44:28 +02:00
Manuel Raynaud 586825aafa ♻️(back) stop returning a 500 on cors_proxy on request failure
On the cors_proxy endpoint, if the fetched url fails we were returning
an error 500. Instead, we log the exception and return a 400 to not
give back information to the frontend application.
2025-08-28 16:06:35 +02:00
Manuel Raynaud 247550fc13 ♻️(back) validate url used in cors_proxy endpoint
The url used by the cors_proxy was not validated, other value than a
http url can be used. We use the built in URLValidator to validate it is
a valid url.
2025-08-28 16:06:35 +02:00
Manuel Raynaud 781c85b66b ️(ci) use setup-python cache option
The setup-python action is able to cache the dependencies and reuse this
cache while the pyproject file has not changed. It is easy to setup,
just the package manager used has to be declared in the cache settings.
2025-08-28 14:00:16 +00:00
Anthony LC 64f967cd29 (frontend) assert DocToolBox depends the licence
Thanks to Vitest we can now assert more complicated
parts of the code without too much mocking.
2025-08-28 11:20:12 +02:00
Anthony LC 1eee24dc19 ♻️(docs-app) Switch from Jest tests to Vitest
We have migrated the testing framework from Jest
to Vitest for the Docs application.
This change includes updates to test files,
configuration files, and the addition of new setup
files for Vitest.
2025-08-28 11:11:29 +02:00
Anthony LC ff9e13ca03 👷(CI) add bundle size check job
Job that will give a report on the bundle size
of the frontend application.
Good to know if the bundle size is increasing or
decreasing and if the changes are acceptable.
2025-08-28 10:37:54 +02:00
Anthony LC 7758e64f40 💄(makefile) add information messages during bootstrap
Add nicely formatted messages to the Makefile to
indicate the start and end of the bootstrap process.
This will help users understand when the bootstrap
process begins and ends, improving the
overall user experience.
2025-08-28 10:16:14 +02:00
virgile-dev 4ab9edcd57 📝(doc) update readme and add community installation methods (#1292)
## Purpose

To showcase the work of the community to make docs deployment easier


## Proposal

- [x] Added README.md in /docs/installation with methods contributed by
the community
- [x] Added a list of public instances on the main README.md
- [x] Updated the warnings around Blocknote XL packages from AGPL to GPL

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-dev <virgile.deville@beta.gouv.fr>
2025-08-26 16:03:39 +03:00
Luca Weiss 0892c05321 📝(compose) Increase attachment upload size to 10 MB
Mostly give this as an example how a person deploying this knows which
knob to turn.

Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:51 +02:00
Luca Weiss 2375bc136c 📝(compose) Whitespace cleanup in default.conf.template
Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:50 +02:00
Luca Weiss e1c2053697 📝(compose) Remove double colon from yaml
It doesn't look like this is supposed to be there.

Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:49 +02:00
Luca Weiss 58f68d86e1 📝(compose) Reindent compose.yaml files
Follow yamlllint's suggestions and use the correct indentation for
lists.

Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:48 +02:00
Luca Weiss 7c97719907 📝(compose) Update deprecated USER_OIDC_FIELD* variables in example
Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:40 +02:00
Cyril d0c9de9d96 (frontend) set empty alt for decorative images in blocknote editor
ensure decorative images have empty alt to comply with RGAA 1.2 accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-14 14:39:42 +02:00
Cyril 81f3997628 (frontend) improve accessibility of search modal for screen readers
added clearer sr-only translations and aria-hidden for non-essential content

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-08 08:58:22 +02:00
Anthony LC 0cf8b9da1a 🐛(minio) fix user permission error with Minio and Windows
With Minio Docker and Windows, the user ID needs
 to be set to `0:0` to avoid permission issues.
 This change ensures that the Minio container
 runs with root privileges on Windows, which
 is necessary for proper file access and management.
2025-08-07 12:37:00 +02:00
Anthony LC 7be761ce84 🐛(makefile) Windows compatibility fix for Docker volume mounting
On Windows systems, Docker volume paths starting
with a single / can be interpreted incorrectly
by the Docker daemon. The double slash (//) helps
Docker on Windows properly interpret the path as
an absolute path within the container, ensuring
that the working directory is correctly set
when running mail-related yarn commands.
2025-08-07 12:36:29 +02:00
Cyril 5181bba083 ️(a11y) improve keyboard access for language menu and action buttons
Enhances nav for language switch and makes DocsGridActions buttons accessible

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-07 11:58:47 +02:00
Anthony LC f434d78b5d ⬆️(dependencies) update js dependencies
- Update js dependencies
- Fix linters
2025-08-07 11:06:34 +02:00
Cyril e07f709dd4 (frontend) improve accessibility of global docs home link at top
moved aria-label and added aria-hidden for better accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-06 15:37:51 +02:00
Cyril G afbacb0a24 ️(frontend) improve left panel accessibility (#1262)
Improve overall accessibility of the left panel:
- ️(frontend) make LeftPanelTargetFilter accessible and use Box as nav
- ️(frontend) improve accessibility in left panel components
- (e2e) fix e2e test to expect aria-current instead of aria-selected
- (frontend) add semantic ul/li to LeftPanel
- (frontend) improve favorite item a11y and update e2e test accordingly
2025-08-06 14:20:53 +02:00
Anthony LC 409e073192 🤡(e2e) mock PATCH language switch
We add some flaky tests because the aria label
selectors were not everytime in english language.
It was because the language switch was not mocked
in the e2e tests, impacting the consistency of
other concurrent tests.
We mock the language switch in the e2e tests
to ensure that the other tests are not impacted
by the language switch.
2025-08-05 12:42:13 +02:00
Jan Conen 886dcb75d5 📝(self-hosted) commands copy-pastable
Make bash commands copy-pastable by prepending
the foldername to the commands.

Signed-off-by: Jan Conen <janconen@hotmail.com>
2025-08-05 11:47:44 +02:00
Jan Conen bb4d2a9fea 📝(self-hosted) default.conf.template when using nginx-proxy
Add step to copy default.conf.template
hen using nginx-proxy.

Signed-off-by: Jan Conen <janconen@hotmail.com>
2025-08-05 11:47:08 +02:00
Moritz Schlarb 5e5054282e 📝(doc) Fix commands in docker compose section
Corrected the commands in step 1

Signed-off-by: Moritz Schlarb <schlarbm@uni-mainz.de>
2025-08-05 11:14:40 +02:00
timo f497e75426 🔧(project) Add trailing slash to yprovider api path
The value in the production environment .env example was missing a
trailing slash in the path. This commit adjusts this to be in the same
format as in other places.
2025-08-05 10:02:31 +02:00
Cyril 97ab13ded6 (e2e) fix broken e2e tests by updating selectors
selectors were updated to stabilize and fix the failing e2e tests

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 16:00:17 +02:00
Cyril 99d674c615 ️(frontend) add correct attributes to decorative and interactive icons
Add aria-hidden and aria-label to improve screen reader accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 13:35:48 +02:00
Cyril 1cdb6b62c8 (e2e) ensure i18n.language is injected into generated PDF
Adds an end-to-end test to verify language injection in the generated PDF.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 09:35:13 +02:00
Cyril 2bf53301d2 ️(frontend) inject language attribute to pdf export
added language="fr-FR" to <Document /> in ModalExport.tsx via cloneElement()
to improve accessibility and ensure correct screen reader pronunciation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 09:35:13 +02:00
Cyril ec84f31bc7 ️(frontend) set html lang attribute dynamically based on current loc
ensures proper language tag is set for accessibility and SEO compliance

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 08:42:56 +02:00
rouja 7813219b86 ♻️(documentation) remove unused environment variables
Yesterday during a deployment, we discovered that these variables are
unused:
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
2025-08-01 12:42:02 +00:00
164 changed files with 6584 additions and 4653 deletions
+2 -1
View File
@@ -23,9 +23,10 @@ jobs:
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
+52 -1
View File
@@ -101,7 +101,7 @@ jobs:
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: test-e2e-chromium
timeout-minutes: 20
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -136,3 +136,54 @@ jobs:
name: playwright-other-report
path: src/frontend/apps/e2e/report/
retention-days: 7
bundle-size-check:
runs-on: ubuntu-latest
needs: install-dependencies
if: github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Detect relevant changes
id: changes
uses: dorny/paths-filter@v2
with:
filters: |
lock:
- 'src/frontend/**/yarn.lock'
app:
- 'src/frontend/apps/impress/**'
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Setup Node.js
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Check bundle size changes
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
build-script: "app:build"
pattern: "apps/impress/out/**/*.{css,js,html}"
exclude: "{**/*.map,**/node_modules/**}"
minimum-change-threshold: 500
compression: "gzip"
cwd: "./src/frontend"
show-total: true
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
omit-unchanged: true
install-script: "yarn install --frozen-lockfile"
+8 -2
View File
@@ -25,14 +25,18 @@ jobs:
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
@@ -89,9 +93,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
@@ -184,9 +189,10 @@ jobs:
mc version enable impress/impress-media-storage"
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.13.3"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
+32
View File
@@ -8,6 +8,38 @@ and this project adheres to
## [Unreleased]
### Added
- 👷(CI) add bundle size check job #1268
- ✨(frontend) use title first emoji as doc icon in tree
### Changed
- ♻️(docs-app) Switch from Jest tests to Vitest #1269
- ⚡️(frontend) improve accessibility:
- #1248
- #1235
- #1275
- #1255
- #1262
- #1244
- #1270
- #1282
- #1261
- ♻️(backend) fallback to email identifier when no name #1298
- 🐛(backend) allow ASCII characters in user sub field #1295
- ⚡️(frontend) improve fallback width calculation #1333
### Fixed
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1264
- 🐛(minio) fix user permission error with Minio and Windows #1264
- 🐛(frontend) fix export when quote block and inline code #1319
- 🐛(frontend) fix base64 font #1324
- 🐛(backend) allow editor to delete subpages #1296
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
- 🐛(frontend) fix display bug on homepage #1332
## [3.5.0] - 2025-07-31
### Added
+78 -6
View File
@@ -35,9 +35,13 @@ DB_PORT = 5432
# -- Docker
# Get the current user ID to use for docker run and docker exec commands
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
ifeq ($(OS),Windows_NT)
DOCKER_USER := 0:0 # run containers as root on Windows
else
DOCKER_UID := $(shell id -u)
DOCKER_GID := $(shell id -g)
DOCKER_USER := $(DOCKER_UID):$(DOCKER_GID)
endif
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
COMPOSE_EXEC = $(COMPOSE) exec
@@ -48,7 +52,7 @@ COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
MAIL_YARN = $(COMPOSE_RUN) -w //app/src/mail node yarn
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -89,13 +93,77 @@ post-bootstrap: \
mails-build
.PHONY: post-bootstrap
pre-beautiful-bootstrap: ## Display a welcome message before bootstrap
ifeq ($(OS),Windows_NT)
@echo ""
@echo "================================================================================"
@echo ""
@echo " Welcome to Docs - Collaborative Text Editing from La Suite!"
@echo ""
@echo " This will set up your development environment with:"
@echo " - Docker containers for all services"
@echo " - Database migrations and static files"
@echo " - Frontend dependencies and build"
@echo " - Environment configuration files"
@echo ""
@echo " Services will be available at:"
@echo " - Frontend: http://localhost:3000"
@echo " - API: http://localhost:8071"
@echo " - Admin: http://localhost:8071/admin"
@echo ""
@echo "================================================================================"
@echo ""
@echo "Starting bootstrap process..."
else
@echo "$(BOLD)"
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
@echo "║ ║"
@echo "║ 🚀 Welcome to Docs - Collaborative Text Editing from La Suite ! 🚀 ║"
@echo "║ ║"
@echo "║ This will set up your development environment with : ║"
@echo "║ • Docker containers for all services ║"
@echo "║ • Database migrations and static files ║"
@echo "║ • Frontend dependencies and build ║"
@echo "║ • Environment configuration files ║"
@echo "║ ║"
@echo "║ Services will be available at: ║"
@echo "║ • Frontend: http://localhost:3000 ║"
@echo "║ • API: http://localhost:8071 ║"
@echo "║ • Admin: http://localhost:8071/admin ║"
@echo "║ ║"
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
@echo "$(RESET)"
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
endif
@echo ""
.PHONY: pre-beautiful-bootstrap
bootstrap: ## Prepare Docker developmentimages for the project
post-beautiful-bootstrap: ## Display a success message after bootstrap
@echo ""
ifeq ($(OS),Windows_NT)
@echo "Bootstrap completed successfully!"
@echo ""
@echo "Next steps:"
@echo " - Visit http://localhost:3000 to access the application"
@echo " - Run 'make help' to see all available commands"
else
@echo "$(GREEN)🎉 Bootstrap completed successfully!$(RESET)"
@echo ""
@echo "$(BOLD)Next steps:$(RESET)"
@echo " • Visit http://localhost:3000 to access the application"
@echo " • Run 'make help' to see all available commands"
endif
@echo ""
.PHONY: post-beautiful-bootstrap
bootstrap: ## Prepare the project for local development
bootstrap: \
pre-beautiful-bootstrap \
pre-bootstrap \
build \
post-bootstrap \
run
run \
post-beautiful-bootstrap
.PHONY: bootstrap
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
@@ -338,6 +406,10 @@ run-frontend-development: ## Run the frontend in development mode
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
frontend-test: ## Run the frontend tests
cd $(PATH_FRONT_IMPRESS) && yarn test
.PHONY: frontend-test
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
.PHONY: frontend-i18n-extract
+21 -4
View File
@@ -49,13 +49,24 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
### Self-host
🚀 Docs is easy to install on your own servers
Available methods: Helm chart, Nix package
#### 🚀 Docs is easy to install on your own servers
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
In the works: Docker Compose, YunoHost
#### 🌍 Known instances
We hope to see many more, here is an incomplete list of public Docs instances (urls listed in alphabetical order). Feel free to make a PR to add ones that are not listed below🙏
| | | |
| --- | --- | ------- |
| Url | Org | Public |
| docs.numerique.gouv.fr | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| docs.suite.anct.gouv.fr | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| notes.demo.opendesk.eu | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| notes.liiib.re | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| docs.federated.nexus | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
#### ⚠️ Advanced features
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
## Getting started 🔧
@@ -130,6 +141,12 @@ To start all the services, except the frontend container, you can use the follow
$ make run-backend
```
To execute frontend tests & linting only
```shellscript
$ make frontend-test
$ make frontend-lint
```
**Adding content**
You can create a basic demo site by running this command:
+4
View File
@@ -38,6 +38,10 @@ function _set_user() {
# options: docker compose command options
# ARGS : docker compose command arguments
function _docker_compose() {
# Set DOCKER_USER for Windows compatibility with MinIO
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || -n "${WSL_DISTRO_NAME:-}" ]]; then
export DOCKER_USER="0:0"
fi
echo "🐳(compose) file: '${COMPOSE_FILE}'"
docker compose \
@@ -11,6 +11,9 @@ server {
server_name localhost;
charset utf-8;
# increase max upload size
client_max_body_size 10m;
# Disables server version feedback on pages and in headers
server_tokens off;
@@ -68,7 +71,7 @@ server {
proxy_set_header Host $host;
}
location /collaboration/api/ {
location /collaboration/api/ {
# Collaboration server
proxy_pass http://${YPROVIDER_HOST}:4444;
proxy_set_header Host $host;
@@ -95,7 +98,7 @@ server {
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https;
@@ -109,4 +112,4 @@ server {
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
}
}
+3 -3
View File
@@ -135,9 +135,9 @@ NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
+15 -15
View File
@@ -7,12 +7,12 @@ services:
timeout: 2s
retries: 300
env_file:
- env.d/postgresql
- env.d/common
- env.d/postgresql
- env.d/common
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:8
@@ -22,12 +22,12 @@ services:
user: ${DOCKER_USER:-1000}
restart: always
environment:
- DJANGO_CONFIGURATION=Production
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/common
- env.d/backend
- env.d/yprovider
- env.d/postgresql
- env.d/common
- env.d/backend
- env.d/yprovider
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
@@ -45,24 +45,24 @@ services:
image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000}
env_file:
- env.d/common
- env.d/yprovider
- env.d/common
- env.d/yprovider
frontend:
image: lasuite/impress-frontend:latest
user: "101"
entrypoint:
- /docker-entrypoint.sh
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- env.d/common
- env.d/common
# Uncomment and set your values if using our nginx proxy example
#environment:
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
volumes:
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
depends_on:
backend:
condition: service_healthy
+3 -3
View File
@@ -9,9 +9,9 @@
```bash
mkdir keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
curl -o keycloak/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o keycloak/env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o keycloak/env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
```
### Step 2:. Update `env.d/` files
+7 -7
View File
@@ -7,23 +7,23 @@ services:
timeout: 2s
retries: 300
env_file:
- env.d/kc_postgresql
- env.d/kc_postgresql
volumes:
- ./data/keycloak:/var/lib/postgresql/data/pgdata
- ./data/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:26.1.3
command: ["start"]
env_file:
- env.d/kc_postgresql
- env.d/keycloak
- env.d/kc_postgresql
- env.d/keycloak
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
depends_on:
kc_postgresql::
kc_postgresql:
condition: service_healthy
restart: true
# Uncomment if using our nginx proxy example
@@ -33,4 +33,4 @@ services:
#
#networks:
# proxy-tier:
# external: true
# external: true
+1 -1
View File
@@ -9,7 +9,7 @@
```bash
mkdir minio
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/minio/compose.yaml
curl -o minio/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/minio/compose.yaml
```
### Step 2:. Update compose file with your own values
+5 -5
View File
@@ -2,8 +2,8 @@ services:
minio:
image: minio/minio
environment:
- MINIO_ROOT_USER=<set minio root username>
- MINIO_ROOT_PASSWORD=<set minio root password>
- MINIO_ROOT_USER=<set minio root username>
- MINIO_ROOT_PASSWORD=<set minio root password>
# Uncomment and set your values if using our nginx proxy example
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=9000 # used by nginx proxy
@@ -16,12 +16,12 @@ services:
entrypoint: ""
command: minio server /data
volumes:
- ./data/minio:/data
- ./data/minio:/data
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - proxy-tier
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
# external: true
+1 -1
View File
@@ -13,7 +13,7 @@ Acme-companion is a lightweight companion container for nginx-proxy. It handles
```bash
mkdir nginx-proxy
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
curl -o nginx-proxy/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
```
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
+12 -12
View File
@@ -3,28 +3,28 @@ services:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
- "80:80"
- "443:443"
volumes:
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- proxy-tier
- proxy-tier
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
environment:
- DEFAULT_EMAIL=mail@yourdomain.tld
- DEFAULT_EMAIL=mail@yourdomain.tld
volumes_from:
- nginx-proxy
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy-tier
- proxy-tier
networks:
proxy-tier:
+1 -4
View File
@@ -27,7 +27,7 @@ backend:
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
@@ -46,9 +46,6 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: root
+32
View File
@@ -0,0 +1,32 @@
# Installation
If you want to install Docs you've come to the right place.
Here are a bunch of resources to help you install the project.
## Kubernetes
We (Docs maintainers) are only using the Kubernetes deployment method in production. We can only provide advanced support for this method.
Please follow the instructions laid out [here](/docs/installation/kubernetes.md).
## Docker Compose
We are aware that not everyone has Kubernetes Cluster laying around 😆.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=impress) that you can deploy using Compose.
Please follow the instructions [here](/docs/installation/compose.md).
⚠️ Please keep in mind that we do not use it ourselves in production. Let us know in the issues if you run into troubles, we'll try to help.
## Other ways to install Docs
Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are two many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance.
Here is the list of other methods in alphabetical order:
- Coop-Cloud: [code](https://git.coopcloud.tech/coop-cloud/lasuite-docs)
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&query=lasuite-docs), ⚠️ unstable
- Podman: [code][https://codeberg.org/philo/lasuite-docs-podman], ⚠️ experimental
- YunoHost: [code](https://github.com/YunoHost-Apps/lasuite-docs_ynh), [app store](https://apps.yunohost.org/app/lasuite-docs)
Feel free to make a PR to add ones that are not listed above 🙏
## Cloud providers
Some cloud providers are making it easy to deploy Docs on their infrastructure.
Here is the list in alphabetical order:
- Clever Cloud 🇫🇷 : [market place][https://www.clever-cloud.com/product/docs/], [technical doc](https://www.clever.cloud/developers/guides/docs/#deploy-docs)
Feel free to make a PR to add ones that are not listed above 🙏
+7 -1
View File
@@ -31,11 +31,17 @@ For older versions of Docker Engine that do not include Docker Compose:
```bash
mkdir -p docs/env.d
cd docs
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/compose.yaml
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/common
curl -o env.d/backend https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/backend
curl -o env.d/yprovider https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/yprovider
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/postgresql
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/postgresql
```
If you are using the sample nginx-proxy configuration:
```bash
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docker/files/production/etc/nginx/conf.d/default.conf.template
```
## Step 2: Configuration
+1 -4
View File
@@ -124,7 +124,7 @@ OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
@@ -168,9 +168,6 @@ DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
### Find s3 bucket connection values
-49
View File
@@ -83,55 +83,6 @@ If you already have CRLF line endings in your local repository, the **best appro
git commit -m "✏️(project) Fix line endings to LF"
```
## Minio Permission Issues on Windows
### Problem Description
On Windows, you may encounter permission-related errors when running Minio in development mode with Docker Compose. This typically happens because:
- **Windows file permissions** don't map well to Unix-style user IDs used in Docker containers
- **Docker Desktop** may have issues with user mapping when using the `DOCKER_USER` environment variable
- **Minio container** fails to start or access volumes due to permission conflicts
### Common Symptoms
- Minio container fails to start with permission denied errors
- Error messages related to file system permissions in Minio logs
- Unable to create or access buckets in the development environment
- Docker Compose showing Minio service as unhealthy or exited
### Solution for Windows Users
If you encounter Minio permission issues on Windows, you can temporarily disable user mapping for the Minio service:
1. **Open the `compose.yml` file**
2. **Comment out the user directive** in the `minio` service section:
```yaml
minio:
# user: ${DOCKER_USER:-1000} # Comment this line on Windows if permission issues occur
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
- MINIO_ROOT_PASSWORD=password
# ... rest of the configuration
```
3. **Restart the services**:
```bash
make run
```
### Why This Works
- Commenting out the `user` directive allows the Minio container to run with its default user
- This bypasses Windows-specific permission mapping issues
- The container will have the necessary permissions to access and manage the mounted volumes
### Note
This is a **development-only workaround**. In production environments, proper user mapping and security considerations should be maintained according to your deployment requirements.
## Frontend File Watching Issues on Windows
### Problem Description
+2 -2
View File
@@ -43,8 +43,8 @@ OIDC_RP_CLIENT_ID=<client_id>
OIDC_RP_CLIENT_SECRET=<client secret>
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
#USER_OIDC_FIELD_TO_SHORTNAME
#USER_OIDC_FIELDS_TO_FULLNAME
#OIDC_USERINFO_SHORTNAME_FIELD
#OIDC_USERINFO_FULLNAME_FIELDS
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}
+1 -1
View File
@@ -1,4 +1,4 @@
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api/
Y_PROVIDER_API_KEY=<generate a random key>
COLLABORATION_SERVER_SECRET=<generate a random key>
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
+4
View File
@@ -2,6 +2,10 @@
"extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog", "automated"],
"schedule": ["before 7am on monday"],
"prCreation": "not-pending",
"rebaseWhen": "conflicted",
"updateNotScheduled": false,
"packageRules": [
{
"enabled": false,
+22 -2
View File
@@ -7,12 +7,13 @@ from base64 import b64decode
from django.conf import settings
from django.db.models import Q
from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import serializers
from core import choices, enums, models, utils
from core import choices, enums, models, utils, validators
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
@@ -32,11 +33,30 @@ class UserSerializer(serializers.ModelSerializer):
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
full_name = serializers.SerializerMethodField(read_only=True)
short_name = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
def get_full_name(self, instance):
"""Return the full name of the user."""
if not instance.full_name:
email = instance.email.split("@")[0]
return slugify(email)
return instance.full_name
def get_short_name(self, instance):
"""Return the short name of the user."""
if not instance.short_name:
email = instance.email.split("@")[0]
return slugify(email)
return instance.short_name
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
@@ -402,7 +422,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
content = serializers.CharField(required=True)
# User
sub = serializers.CharField(
required=True, validators=[models.User.sub_validator], max_length=255
required=True, validators=[validators.sub_validator], max_length=255
)
email = serializers.EmailField(required=True)
language = serializers.ChoiceField(
+39 -11
View File
@@ -13,6 +13,7 @@ from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import URLValidator
from django.db import connection, transaction
from django.db import models as db
from django.db.models.expressions import RawSQL
@@ -359,7 +360,7 @@ class DocumentViewSet(
permission_classes = [
permissions.DocumentPermission,
]
queryset = models.Document.objects.all()
queryset = models.Document.objects.select_related("creator").all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
children_serializer_class = serializers.ListDocumentSerializer
@@ -786,7 +787,11 @@ class DocumentViewSet(
)
# GET: List children
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
queryset = (
document.get_children()
.select_related("creator")
.filter(ancestors_deleted_at__isnull=True)
)
queryset = self.filter_queryset(queryset)
filterset = DocumentFilter(request.GET, queryset=queryset)
@@ -840,19 +845,27 @@ class DocumentViewSet(
user = self.request.user
try:
current_document = self.queryset.only("depth", "path").get(pk=pk)
current_document = (
self.queryset.select_related(None).only("depth", "path").get(pk=pk)
)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
ancestors = (
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
(
current_document.get_ancestors()
| self.queryset.select_related(None).filter(pk=pk)
)
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
# Get the highest readable ancestor
highest_readable = (
ancestors.readable_per_se(request.user).only("depth", "path").first()
ancestors.select_related(None)
.readable_per_se(request.user)
.only("depth", "path")
.first()
)
if highest_readable is None:
raise (
@@ -880,7 +893,12 @@ class DocumentViewSet(
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
queryset = (
ancestors.select_related("creator").filter(
depth__gte=highest_readable.depth
)
| children
)
queryset = queryset.order_by("path")
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_is_favorite(user)
@@ -1282,7 +1300,8 @@ class DocumentViewSet(
)
attachments_documents = (
self.queryset.filter(attachments__contains=[key])
self.queryset.select_related(None)
.filter(attachments__contains=[key])
.only("path")
.order_by("path")
)
@@ -1441,6 +1460,15 @@ class DocumentViewSet(
url = unquote(url)
url_validator = URLValidator(schemes=["http", "https"])
try:
url_validator(url)
except drf.exceptions.ValidationError as e:
return drf.response.Response(
{"detail": str(e)},
status=drf.status.HTTP_400_BAD_REQUEST,
)
try:
response = requests.get(
url,
@@ -1471,10 +1499,10 @@ class DocumentViewSet(
return proxy_response
except requests.RequestException as e:
logger.error("Proxy request failed: %s", str(e))
return drf_response.Response(
{"error": f"Failed to fetch resource: {e!s}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
logger.exception(e)
return drf.response.Response(
{"error": f"Failed to fetch resource from {url}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -2,6 +2,8 @@
from django.db import migrations, models
import core.validators
class Migration(migrations.Migration):
dependencies = [
@@ -33,4 +35,17 @@ class Migration(migrations.Migration):
verbose_name="language",
),
),
migrations.AlterField(
model_name="user",
name="sub",
field=models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. ASCII characters only.",
max_length=255,
null=True,
unique=True,
validators=[core.validators.sub_validator],
verbose_name="sub",
),
),
]
+12 -15
View File
@@ -14,7 +14,7 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import mail, validators
from django.core import mail
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -39,6 +39,7 @@ from .choices import (
RoleChoices,
get_equivalent_link_definition,
)
from .validators import sub_validator
logger = getLogger(__name__)
@@ -136,22 +137,12 @@ class UserManager(auth_models.UserManager):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
help_text=_("Required. 255 characters or fewer. ASCII characters only."),
max_length=255,
unique=True,
validators=[sub_validator],
unique=True,
blank=True,
null=True,
)
@@ -762,6 +753,12 @@ class Document(MP_Node, BaseModel):
can_update = (
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
can_create_children = can_update and user.is_authenticated
can_destroy = (
is_owner
if self.is_root()
else (is_owner_or_admin or (user.is_authenticated and self.creator == user))
)
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
@@ -784,11 +781,11 @@ class Document(MP_Node, BaseModel):
"media_check": can_get,
"can_edit": can_update,
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"children_create": can_create_children,
"collaboration_auth": can_get,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": is_owner,
"destroy": can_destroy,
"duplicate": can_get and user.is_authenticated,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
@@ -2,6 +2,7 @@
import pytest
import responses
from requests.exceptions import RequestException
from rest_framework.test import APIClient
from core import factories
@@ -149,3 +150,41 @@ def test_api_docs_cors_proxy_unsupported_media_type():
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 415
@pytest.mark.parametrize(
"url_to_fetch",
[
"ftp://external-url.com/assets/index.html",
"ftps://external-url.com/assets/index.html",
"invalid-url.com",
"ssh://external-url.com/assets/index.html",
],
)
def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
"""Test the CORS proxy API for documents with an invalid URL."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == ["Enter a valid URL."]
@responses.activate
def test_api_docs_cors_proxy_request_failed():
"""Test the CORS proxy API for documents with a request failed."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=RequestException("Connection refused"))
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
}
@@ -148,7 +148,7 @@ def test_api_documents_create_for_owner_invalid_sub():
data = {
"title": "My Document",
"content": "Document content",
"sub": "123!!",
"sub": "invalid süb",
"email": "john.doe@example.com",
}
@@ -163,10 +163,7 @@ def test_api_documents_create_for_owner_invalid_sub():
assert not Document.objects.exists()
assert response.json() == {
"sub": [
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
]
"sub": ["Enter a valid sub. This value should be ASCII only."]
}
@@ -494,7 +494,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": access.role == "owner",
"destroy": access.role in ["administrator", "owner"],
"duplicate": True,
"favorite": True,
"invite_owner": access.role == "owner",
@@ -0,0 +1,44 @@
"""Test user light serializer."""
import pytest
from core import factories
from core.api.serializers import UserLightSerializer
pytestmark = pytest.mark.django_db
def test_user_light_serializer():
"""Test user light serializer."""
user = factories.UserFactory(
email="test@test.com",
full_name="John Doe",
short_name="John",
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "John Doe"
assert serializer.data["short_name"] == "John"
def test_user_light_serializer_no_full_name():
"""Test user light serializer without full name."""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name="John",
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "test_foo"
assert serializer.data["short_name"] == "John"
def test_user_light_serializer_no_short_name():
"""Test user light serializer without short name."""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name=None,
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "test_foo"
assert serializer.data["short_name"] == "test_foo"
@@ -593,6 +593,86 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
}
@pytest.mark.parametrize(
"is_authenticated, is_creator,role,link_reach,link_role,can_destroy",
[
(True, False, "owner", "restricted", "editor", True),
(True, True, "owner", "restricted", "editor", True),
(True, False, "owner", "restricted", "reader", True),
(True, True, "owner", "restricted", "reader", True),
(True, False, "owner", "authenticated", "editor", True),
(True, True, "owner", "authenticated", "editor", True),
(True, False, "owner", "authenticated", "reader", True),
(True, True, "owner", "authenticated", "reader", True),
(True, False, "owner", "public", "editor", True),
(True, True, "owner", "public", "editor", True),
(True, False, "owner", "public", "reader", True),
(True, True, "owner", "public", "reader", True),
(True, False, "administrator", "restricted", "editor", True),
(True, True, "administrator", "restricted", "editor", True),
(True, False, "administrator", "restricted", "reader", True),
(True, True, "administrator", "restricted", "reader", True),
(True, False, "administrator", "authenticated", "editor", True),
(True, True, "administrator", "authenticated", "editor", True),
(True, False, "administrator", "authenticated", "reader", True),
(True, True, "administrator", "authenticated", "reader", True),
(True, False, "administrator", "public", "editor", True),
(True, True, "administrator", "public", "editor", True),
(True, False, "administrator", "public", "reader", True),
(True, True, "administrator", "public", "reader", True),
(True, False, "editor", "restricted", "editor", False),
(True, True, "editor", "restricted", "editor", True),
(True, False, "editor", "restricted", "reader", False),
(True, True, "editor", "restricted", "reader", True),
(True, False, "editor", "authenticated", "editor", False),
(True, True, "editor", "authenticated", "editor", True),
(True, False, "editor", "authenticated", "reader", False),
(True, True, "editor", "authenticated", "reader", True),
(True, False, "editor", "public", "editor", False),
(True, True, "editor", "public", "editor", True),
(True, False, "editor", "public", "reader", False),
(True, True, "editor", "public", "reader", True),
(True, False, "reader", "restricted", "editor", False),
(True, False, "reader", "restricted", "reader", False),
(True, False, "reader", "authenticated", "editor", False),
(True, True, "reader", "authenticated", "editor", True),
(True, False, "reader", "authenticated", "reader", False),
(True, False, "reader", "public", "editor", False),
(True, True, "reader", "public", "editor", True),
(True, False, "reader", "public", "reader", False),
(False, False, None, "restricted", "editor", False),
(False, False, None, "restricted", "reader", False),
(False, False, None, "authenticated", "editor", False),
(False, False, None, "authenticated", "reader", False),
(False, False, None, "public", "editor", False),
(False, False, None, "public", "reader", False),
],
)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def test_models_documents_get_abilities_children_destroy( # noqa: PLR0913
is_authenticated,
is_creator,
role,
link_reach,
link_role,
can_destroy,
):
"""For a sub document, if a user can create children, he can destroy it."""
user = factories.UserFactory() if is_authenticated else AnonymousUser()
parent = factories.DocumentFactory(link_reach=link_reach, link_role=link_role)
document = factories.DocumentFactory(
link_reach=link_reach,
link_role=link_role,
parent=parent,
creator=user if is_creator else None,
)
if is_authenticated:
factories.UserDocumentAccessFactory(document=parent, user=user, role=role)
abilities = document.get_abilities(user)
assert abilities["destroy"] is can_destroy
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.parametrize(
"is_authenticated,reach",
@@ -44,3 +44,25 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
@pytest.mark.parametrize(
"sub,is_valid",
[
("valid_sub.@+-:=/", True),
("invalid süb", False),
(12345, True),
],
)
def test_models_users_sub_validator(sub, is_valid):
"""The "sub" field should be validated."""
user = factories.UserFactory()
user.sub = sub
if is_valid:
user.full_clean()
else:
with pytest.raises(
ValidationError,
match=("Enter a valid sub. This value should be ASCII only."),
):
user.full_clean()
+9
View File
@@ -0,0 +1,9 @@
"""Custom validators for the core app."""
from django.core.exceptions import ValidationError
def sub_validator(value):
"""Validate that the sub is ASCII only."""
if not value.isascii():
raise ValidationError("Enter a valid sub. This value should be ASCII only.")
@@ -9,7 +9,7 @@
},
"externalLinks": [
{
"label": "Github",
"label": "GitHub",
"href": "https://github.com/suitenumerique/docs/"
},
{
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await expect(
page.locator('header').first().locator('h2').getByText('Docs'),
page.locator('header').first().locator('h1').getByText('Docs'),
).toBeVisible();
await page.goto('unknown-page404');
});
@@ -23,7 +23,7 @@ const saveStorageState = async (
page.locator('header').first().getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
).toBeVisible({ timeout: 10000 });
await page.context().storageState({
path: storageState as string,
@@ -43,7 +43,9 @@ test.describe('Config', () => {
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
@@ -22,7 +22,7 @@ test.describe('Doc Create', () => {
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await header.locator('h1').getByText('Docs').click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
@@ -93,7 +93,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
// When the visibility is changed, the ws should close the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
@@ -272,7 +272,9 @@ test.describe('Doc Editor', () => {
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
@@ -284,6 +286,11 @@ test.describe('Doc Editor', () => {
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
await expect(image).toHaveAttribute('role', 'presentation');
await expect(image).toHaveAttribute('alt', '');
await expect(image).toHaveAttribute('tabindex', '-1');
await expect(image).toHaveAttribute('aria-hidden', 'true');
});
test('it checks the AI buttons', async ({ page, browserName }) => {
@@ -307,7 +314,7 @@ test.describe('Doc Editor', () => {
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'AI' }).click();
await page.locator('[data-test="ai-actions"]').click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
@@ -393,11 +400,11 @@ test.describe('Doc Editor', () => {
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
await expect(page.locator('[data-test="ai-actions"]')).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI' }).click();
await page.locator('[data-test="ai-actions"]').click();
if (ai_transform) {
await expect(
@@ -561,7 +568,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility', { exact: true }).click();
await page.getByTestId('doc-visibility').click();
await page
.getByRole('menuitem', {
@@ -573,7 +580,7 @@ test.describe('Doc Editor', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
// Close the modal
@@ -655,7 +662,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility mode').click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Reading' }).click();
// Close the modal
@@ -4,7 +4,13 @@ import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { createDoc, verifyDocName } from './utils-common';
import {
TestLanguage,
createDoc,
randomName,
verifyDocName,
waitForLanguageSwitch,
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -19,7 +25,7 @@ test.describe('Doc Export', () => {
await createDoc(page, 'doc-editor', browserName, 1);
await page
.getByRole('button', {
name: 'download',
name: 'Export the document',
})
.click();
@@ -72,8 +78,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'download',
exact: true,
name: 'Export the document',
})
.click();
@@ -116,14 +121,15 @@ test.describe('Doc Export', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page.getByRole('img', { name: 'test.svg' });
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
await page
.getByRole('button', {
name: 'download',
exact: true,
name: 'Export the document',
})
.click();
@@ -176,7 +182,9 @@ test.describe('Doc Export', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page.getByRole('img', { name: 'test.svg' });
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
@@ -190,7 +198,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'download',
name: 'Export the document',
})
.click();
@@ -267,7 +275,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'download',
name: 'Export the document',
})
.click();
@@ -317,8 +325,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'download',
exact: true,
name: 'Export the document',
})
.click();
@@ -380,8 +387,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'download',
exact: true,
name: 'Export the document',
})
.click();
@@ -413,6 +419,72 @@ test.describe('Doc Export', () => {
expect(pdfData.text).toContain('Column 3');
});
test('it injects the correct language attribute into PDF export', async ({
page,
browserName,
}) => {
await waitForLanguageSwitch(page, TestLanguage.French);
// Wait for the page to be ready after language switch
await page.waitForLoadState('domcontentloaded');
const header = page.locator('header').first();
await header.locator('h1').getByText('Docs').click();
const randomDocFrench = randomName(
'doc-language-export-french',
browserName,
1,
)[0];
await page
.getByRole('button', {
name: 'Nouveau doc',
})
.click();
await page.waitForURL('**/docs/**', {
timeout: 10000,
waitUntil: 'domcontentloaded',
});
const input = page.getByLabel('doc title input');
await expect(input).toBeVisible();
await expect(input).toHaveText('');
await input.click();
await input.fill(randomDocFrench);
await input.blur();
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.fill('Contenu de test pour export en français');
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDocFrench}.pdf`);
});
void page
.getByRole('button', {
name: 'Télécharger',
exact: true,
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDocFrench}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfString = pdfBuffer.toString('latin1');
expect(pdfString).toContain('/Lang (fr)');
});
test('it exports the doc with interlinking', async ({
page,
browserName,
@@ -460,8 +532,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'download',
exact: true,
name: 'Export the document',
})
.click();
@@ -8,9 +8,9 @@ test.describe('Doc grid dnd', () => {
await page.goto('/');
const header = page.locator('header').first();
await createDoc(page, 'Draggable doc', browserName, 1);
await header.locator('h2').getByText('Docs').click();
await header.locator('h1').getByText('Docs').click();
await createDoc(page, 'Droppable doc', browserName, 1);
await header.locator('h2').getByText('Docs').click();
await header.locator('h1').getByText('Docs').click();
const response = await page.waitForResponse(
(response) =>
@@ -119,7 +119,7 @@ test.describe('Document grid item options', () => {
await page.getByText('push_pin').click();
// Check is pinned
await expect(row.getByLabel('Pin document icon')).toBeVisible();
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
@@ -128,7 +128,7 @@ test.describe('Document grid item options', () => {
await page.getByText('Unpin').click();
// Check is unpinned
await expect(row.getByLabel('Pin document icon')).toBeHidden();
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
@@ -227,18 +227,18 @@ test.describe('Documents filters', () => {
// Initial state
await expect(allDocs).toBeVisible();
await expect(allDocs).toHaveAttribute('aria-selected', 'true');
await expect(allDocs).toHaveAttribute('aria-current', 'page');
await expect(myDocs).toBeVisible();
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(myDocs).toHaveAttribute('aria-selected', 'false');
await expect(myDocs).not.toHaveAttribute('aria-current');
await expect(sharedWithMe).toBeVisible();
await expect(sharedWithMe).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await expect(sharedWithMe).toHaveAttribute('aria-selected', 'false');
await expect(sharedWithMe).not.toHaveAttribute('aria-current');
await allDocs.click();
@@ -30,7 +30,7 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility', { exact: true }).click();
await page.getByTestId('doc-visibility').click();
await page
.getByRole('menuitem', {
@@ -44,7 +44,9 @@ test.describe('Doc Header', () => {
await expect(card.getByText('Owner ·')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Open the document options' }),
).toBeVisible();
@@ -59,6 +61,31 @@ test.describe('Doc Header', () => {
await verifyDocName(page, 'Hello World');
});
test('it updates the title doc adding a leading emoji', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await docTitle.fill('👍 Hello Emoji World');
await docTitle.blur();
await verifyDocName(page, '👍 Hello Emoji World');
// 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 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();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
@@ -115,7 +142,9 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
@@ -185,7 +214,9 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).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();
@@ -245,7 +276,9 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).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();
@@ -409,7 +442,7 @@ test.describe('Doc Header', () => {
const row = await getGridRow(page, docTitle);
// Check is pinned
await expect(row.getByLabel('Pin document icon')).toBeVisible();
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
@@ -424,7 +457,7 @@ test.describe('Doc Header', () => {
await page.goto('/');
// Check is unpinned
await expect(row.getByLabel('Pin document icon')).toBeHidden();
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
@@ -244,9 +244,7 @@ test.describe('Document create member: Multiple login', () => {
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.goto(urlDoc);
@@ -271,9 +269,7 @@ test.describe('Document create member: Multiple login', () => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible({
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
@@ -334,9 +330,7 @@ test.describe('Document create member: Multiple login', () => {
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible({
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
@@ -26,10 +26,7 @@ test.describe('Document search', () => {
);
await verifyDocName(page, doc2Title);
await page.goto('/');
await page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' })
.click();
await page.getByTestId('search-docs-button').click();
await expect(
page.getByRole('img', { name: 'No active search' }),
@@ -104,9 +101,7 @@ test.describe('Document search', () => {
browserName,
}) => {
// Doc grid filters are not visible
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search', exact: true });
const searchButton = page.getByTestId('search-docs-button');
const filters = page.getByTestId('doc-search-filters');
@@ -170,9 +165,7 @@ test.describe('Document search', () => {
1,
);
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' });
const searchButton = page.getByTestId('search-docs-button');
await searchButton.click();
await page.getByRole('combobox', { name: 'Quick search input' }).click();
@@ -182,10 +175,10 @@ test.describe('Document search', () => {
// Expect to find the first doc
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
page.getByRole('presentation').getByText(firstDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
page.getByRole('presentation').getByText(secondDocTitle),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -203,13 +196,13 @@ test.describe('Document search', () => {
// Now there is a sub page - expect to have the focus on the current doc
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
page.getByRole('presentation').getByText(secondDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondChildDocTitle),
page.getByRole('presentation').getByText(secondChildDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
page.getByRole('presentation').getByText(firstDocTitle),
).toBeHidden();
});
});
@@ -25,7 +25,7 @@ test.describe('Doc Tree', () => {
1,
);
await verifyDocName(page, titleParent);
const addButton = page.getByRole('button', { name: 'New doc' });
const addButton = page.getByTestId('new-doc-button');
const docTree = page.getByTestId('doc-tree');
await expect(addButton).toBeVisible();
@@ -63,7 +63,7 @@ test.describe('Doc Tree', () => {
test('check the reorder of sub pages', async ({ page, browserName }) => {
await createDoc(page, 'doc-tree-content', browserName, 1);
const addButton = page.getByRole('button', { name: 'New doc' });
const addButton = page.getByTestId('new-doc-button');
await expect(addButton).toBeVisible();
const docTree = page.getByTestId('doc-tree');
@@ -201,7 +201,7 @@ test.describe('Doc Tree', () => {
).not.toHaveText(docChild);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await header.locator('h1').getByText('Docs').click();
await expect(page.getByText(docChild)).toBeVisible();
});
@@ -259,6 +259,10 @@ test.describe('Doc Tree: Inheritance', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A child inherit from the parent', async ({ page, browserName }) => {
// test.slow() to extend timeout since this scenario chains Keycloak login + redirects,
// doc creation/navigation and async doc-tree loading (/documents/:id/tree), which can exceed 30s (especially in CI).
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
@@ -271,7 +275,7 @@ test.describe('Doc Tree: Inheritance', () => {
await verifyDocName(page, docParent);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
@@ -307,6 +311,85 @@ test.describe('Doc Tree: Inheritance', () => {
await expect(page.locator('h2').getByText(docChild)).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible({ timeout: 10000 });
await expect(docTree.getByText(docParent)).toBeVisible();
});
});
test.describe('Doc tree keyboard interactions (subdocs)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('navigates in the tree and actions with keyboard and toggles menu (options and create childDoc)', async ({
page,
browserName,
}) => {
const [rootDocTitle] = await createDoc(
page,
'doc-tree-keyboard',
browserName,
1,
);
await verifyDocName(page, rootDocTitle);
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'subdoc-tree-actions',
);
await verifyDocName(page, childTitle);
const docTree = page.getByTestId('doc-tree');
const actionsGroup = page.getByRole('toolbar', {
name: `Actions for ${childTitle}`,
});
await expect(actionsGroup).toBeVisible();
const moreOptions = actionsGroup.getByRole('button', {
name: `More options for ${childTitle}`,
});
await expect(moreOptions).toBeVisible();
await moreOptions.focus();
await expect(moreOptions).toBeFocused();
await page.keyboard.press('ArrowRight');
const addChild = actionsGroup.getByTestId('add-child-doc');
await expect(addChild).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(moreOptions).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByText('Copy link')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByText('Copy link')).toBeHidden();
await page.keyboard.press('ArrowRight');
await expect(addChild).toBeFocused();
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await page.keyboard.press('Enter');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const newChildDoc = (await response.json()) as { id: string };
const childButton = page.getByTestId(`doc-sub-page-item-${newChildDoc.id}`);
const childTreeItem = docTree
.locator('.c__tree-view--row')
.filter({ has: childButton })
.first();
await childTreeItem.focus();
});
});
@@ -41,7 +41,7 @@ test.describe('Doc Visibility', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await expect(selectVisibility.getByText('Private')).toBeVisible();
@@ -51,13 +51,13 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page.getByLabel('Connected').click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await page.getByLabel('Public', { exact: true }).click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
});
@@ -122,9 +122,7 @@ test.describe('Doc Visibility: Restricted', () => {
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible({
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
@@ -178,9 +176,7 @@ test.describe('Doc Visibility: Restricted', () => {
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.goto(urlDoc);
@@ -209,7 +205,7 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
@@ -222,8 +218,8 @@ test.describe('Doc Visibility: Public', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await page.getByLabel('Visibility mode').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page
.getByRole('menuitem', {
name: 'Reading',
@@ -246,8 +242,8 @@ test.describe('Doc Visibility: Public', () => {
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'search' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
await expect(page.getByTestId('search-docs-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
const urlDoc = page.url();
@@ -262,8 +258,8 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'search' })).toBeHidden();
await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden();
await expect(page.getByTestId('search-docs-button')).toBeHidden();
await expect(page.getByTestId('new-doc-button')).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
@@ -293,7 +289,7 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
@@ -306,7 +302,7 @@ test.describe('Doc Visibility: Public', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await expect(
@@ -362,7 +358,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
@@ -414,7 +410,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
@@ -455,9 +451,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible({
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
@@ -501,6 +495,7 @@ test.describe('Doc Visibility: Authenticated', () => {
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
@@ -514,7 +509,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
@@ -527,7 +522,7 @@ test.describe('Doc Visibility: Authenticated', () => {
).toBeVisible();
const urlDoc = page.url();
await page.getByLabel('Visibility mode').click();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await expect(
@@ -545,15 +540,17 @@ test.describe('Doc Visibility: Authenticated', () => {
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
await expect(page.getByText('Link Copied !')).toBeVisible({
timeout: 10000,
});
});
});
@@ -21,7 +21,7 @@ test.describe('Footer', () => {
await expect(footer.getByAltText('Docs Logo')).toBeVisible();
await expect(footer.getByRole('heading', { name: 'Docs' })).toBeVisible();
await expect(footer.getByRole('link', { name: 'Github' })).toBeVisible();
await expect(footer.getByRole('link', { name: 'GitHub' })).toBeVisible();
await expect(footer.getByRole('link', { name: 'DINUM' })).toBeVisible();
await expect(footer.getByRole('link', { name: 'ZenDiS' })).toBeVisible();
@@ -12,8 +12,8 @@ test.describe('Header', () => {
const header = page.locator('header').first();
await expect(header.getByLabel('Docs Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
await expect(header.getByTestId('header-logo-link')).toBeVisible();
await expect(header.locator('h1').getByText('Docs')).toHaveCSS(
'font-family',
/Roboto/i,
);
@@ -37,8 +37,8 @@ test.describe('Header', () => {
const header = page.locator('header').first();
await expect(header.getByLabel('Docs Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(header.locator('h1').getByText('Docs')).toHaveCSS(
'font-family',
/Marianne/i,
);
@@ -106,7 +106,7 @@ test.describe('Header mobile', () => {
const header = page.locator('header').first();
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(header.getByRole('link', { name: 'Docs Logo' })).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
@@ -15,10 +15,13 @@ test.describe('Home page', () => {
const header = page.locator('header').first();
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(
header.getByRole('button', { name: /Language/ }),
).toBeVisible();
await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible();
const languageButton = page.getByRole('button', {
name: /Language|Select language/,
});
await expect(languageButton).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
// Check the titles
@@ -65,20 +68,31 @@ test.describe('Home page', () => {
await page.goto('/docs/');
// Wait for the page to be fully loaded and responsive store to be initialized
await page.waitForLoadState('domcontentloaded');
// Wait a bit more for the responsive store to be initialized
await page.waitForTimeout(500);
// Check header content
const header = page.locator('header').first();
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(
header.getByRole('button', { name: /Language/ }),
).toBeVisible();
// Check for language picker - it should be visible on desktop
// Use a more flexible selector that works with both Header and HomeHeader
const languageButton = page.getByRole('button', {
name: /Language|Select language/,
});
await expect(languageButton).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
).toBeVisible();
await expect(
header.getByRole('img', { name: 'Gouvernement Logo' }),
).toBeVisible();
await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
// Check the titles
@@ -1,30 +1,17 @@
import { Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { createDoc } from './utils-common';
test.describe.serial('Language', () => {
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.afterAll(async () => {
await page.close();
});
import { TestLanguage, createDoc, waitForLanguageSwitch } from './utils-common';
test.describe('Language', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForLanguageSwitch(page, TestLanguage.English);
});
test.afterEach(async ({ page }) => {
// Switch back to English - important for other tests to run as expected
await waitForLanguageSwitch(page, TestLanguage.English);
});
test('checks language switching', async ({ page }) => {
const header = page.locator('header').first();
const languagePicker = header.locator('.--docs--language-picker-text');
await expect(page.locator('html')).toHaveAttribute('lang', 'en-us');
// initial language should be english
await expect(
@@ -36,17 +23,57 @@ test.describe.serial('Language', () => {
// switch to french
await waitForLanguageSwitch(page, TestLanguage.French);
await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
await expect(
header.getByRole('button').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('button').getByText('Français').click();
await page.getByLabel('Deutsch').click();
// Switch to German using the utility function for consistency
await waitForLanguageSwitch(page, TestLanguage.German);
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
await languagePicker.click();
await expect(page.locator('[role="menu"]')).toBeVisible();
const menuItems = page.getByRole('menuitem');
await expect(menuItems.first()).toBeVisible();
await menuItems.first().click();
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
await expect(languagePicker).toContainText('English');
});
test('can switch language using only keyboard', async ({ page }) => {
await page.goto('/');
await waitForLanguageSwitch(page, TestLanguage.English);
const languagePicker = page.getByRole('button', {
name: /select language/i,
});
await expect(languagePicker).toBeVisible();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page.locator('html')).not.toHaveAttribute('lang', 'en-us');
});
test('checks that backend uses the same language as the frontend', async ({
@@ -94,48 +121,3 @@ test.describe.serial('Language', () => {
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
});
});
// language helper
export const TestLanguage = {
English: {
label: 'English',
expectedLocale: ['en-us'],
},
French: {
label: 'Français',
expectedLocale: ['fr-fr'],
},
German: {
label: 'Deutsch',
expectedLocale: ['de-de'],
},
} as const;
type TestLanguageKey = keyof typeof TestLanguage;
type TestLanguageValue = (typeof TestLanguage)[TestLanguageKey];
export async function waitForLanguageSwitch(
page: Page,
lang: TestLanguageValue,
) {
const header = page.locator('header').first();
const languagePicker = header.locator('.--docs--language-picker-text');
const isAlreadyTargetLanguage = await languagePicker
.innerText()
.then((text) => text.toLowerCase().includes(lang.label.toLowerCase()));
if (isAlreadyTargetLanguage) {
return;
}
await languagePicker.click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes('/user') && resp.request().method() === 'PATCH',
);
await page.getByLabel(lang.label).click();
const resolvedResponsePromise = await responsePromise;
const responseData = await resolvedResponsePromise.json();
expect(lang.expectedLocale).toContain(responseData.language);
}
@@ -8,8 +8,8 @@ test.describe('Left panel desktop', () => {
test('checks all the elements are visible', async ({ page }) => {
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
await expect(page.getByRole('button', { name: 'house' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
await expect(page.getByTestId('home-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
});
});
@@ -27,9 +27,11 @@ test.describe('Left panel mobile', () => {
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
const header = page.locator('header').first();
const homeButton = page.getByRole('button', { name: 'house' });
const newDocButton = page.getByRole('button', { name: 'New doc' });
const languageButton = page.getByRole('button', { name: /Language/ });
const homeButton = page.getByTestId('home-button');
const newDocButton = page.getByTestId('new-doc-button');
const languageButton = page.getByRole('button', {
name: 'Select language',
});
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
@@ -136,9 +136,11 @@ export const getGridRow = async (page: Page, title: string) => {
const rows = docsGrid.getByRole('row');
const row = rows.filter({
hasText: title,
});
const row = rows
.filter({
hasText: title,
})
.first();
await expect(row).toBeVisible();
@@ -154,7 +156,7 @@ export const goToGridDoc = async (
{ nthRow = 1, title }: GoToGridDocOptions = {},
) => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await header.locator('h1').getByText('Docs').click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
@@ -273,3 +275,52 @@ export const expectLoginPage = async (page: Page) =>
).toBeVisible({
timeout: 10000,
});
// language helper
export const TestLanguage = {
English: {
label: 'English',
expectedLocale: ['en-us'],
},
French: {
label: 'Français',
expectedLocale: ['fr-fr'],
},
German: {
label: 'Deutsch',
expectedLocale: ['de-de'],
},
} as const;
type TestLanguageKey = keyof typeof TestLanguage;
type TestLanguageValue = (typeof TestLanguage)[TestLanguageKey];
export async function waitForLanguageSwitch(
page: Page,
lang: TestLanguageValue,
) {
await page.route('**/api/v1.0/users/**', async (route, request) => {
if (request.method().includes('PATCH')) {
await route.fulfill({
json: {
language: lang.expectedLocale[0],
},
});
} else {
await route.continue();
}
});
const header = page.locator('header').first();
const languagePicker = header.locator('.--docs--language-picker-text');
const isAlreadyTargetLanguage = await languagePicker
.innerText()
.then((text) => text.toLowerCase().includes(lang.label.toLowerCase()));
if (isAlreadyTargetLanguage) {
return;
}
await languagePicker.click();
await page.getByLabel(lang.label).click();
}
@@ -45,7 +45,7 @@ export const updateShareLink = async (
linkReach: LinkReach,
linkRole?: LinkRole | null,
) => {
await page.getByRole('button', { name: 'Visibility', exact: true }).click();
await page.getByTestId('doc-visibility').click();
await page.getByRole('menuitem', { name: linkReach }).click();
const visibilityUpdatedText = page
@@ -55,9 +55,7 @@ export const updateShareLink = async (
await expect(visibilityUpdatedText).toBeVisible();
if (linkRole) {
await page
.getByRole('button', { name: 'Visibility mode', exact: true })
.click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: linkRole }).click();
await expect(visibilityUpdatedText).toBeVisible();
}
@@ -63,5 +63,6 @@ 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('add-child-doc').click();
};
+1 -1
View File
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.54.1",
"@playwright/test": "1.55.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-config-impress": "*",
+1
View File
@@ -1 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_PUBLISH_AS_MIT=false
+13
View File
@@ -50,6 +50,13 @@ tokens.themes.default.theme = {
...tokens.themes.default.theme.colors,
...customColors,
},
font: {
...tokens.themes.default.theme.font,
families: {
base: 'sans-serif',
accent: 'sans-serif',
},
},
},
};
@@ -89,6 +96,12 @@ const dsfrTheme = {
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
font: {
families: {
base: 'Marianne',
accent: 'Marianne',
},
},
},
components: {
'la-gaufre': true,
-33
View File
@@ -1,33 +0,0 @@
import type { Config } from 'jest';
import nextJest from 'next/jest.js';
const createJestConfig = nextJest({
dir: './',
});
// Add any custom config to be passed to Jest
const config: Config = {
coverageProvider: 'v8',
moduleNameMapper: {
'^@/docs/(.*)$': '<rootDir>/src/features/docs/$1',
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jsdom',
};
const jestConfig = async () => {
const nextJestConfig = await createJestConfig(config)();
return {
...nextJestConfig,
moduleNameMapper: {
'\\.svg$': '<rootDir>/jest/mocks/svg.js',
'^.+\\.svg\\?url$': `<rootDir>/jest/mocks/fileMock.js`,
BlockNoteEditor: `<rootDir>/jest/mocks/ComponentMock.js`,
'custom-blocks': `<rootDir>/jest/mocks/ComponentMock.js`,
...nextJestConfig.moduleNameMapper,
},
};
};
export default jestConfig;
-4
View File
@@ -1,4 +0,0 @@
import '@testing-library/jest-dom';
import * as dotenv from 'dotenv';
dotenv.config({ path: './.env.test' });
@@ -1,5 +0,0 @@
import React from 'react';
export const ComponentMock = () => {
return <div>My component mocked</div>;
};
@@ -1,16 +0,0 @@
module.exports = {
src: '/img.jpg',
height: 40,
width: 40,
blurDataURL: 'data:image/png;base64,imagedata',
};
if (
(typeof exports.default === 'function' ||
(typeof exports.default === 'object' && exports.default !== null)) &&
typeof exports.default.__esModule === 'undefined'
) {
Object.defineProperty(exports.default, '__esModule', { value: true });
Object.assign(exports.default, exports);
module.exports = exports.default;
}
@@ -1,3 +0,0 @@
const nameMock = 'svg';
export default nameMock;
export const ReactComponent = 'svg';
+32 -30
View File
@@ -11,81 +11,83 @@
"lint": "tsc --noEmit && next lint",
"prettier": "prettier --write .",
"stylelint": "stylelint \"**/*.css\"",
"test": "jest",
"test:watch": "jest --watch"
"test": "vitest",
"test:watch": "vitest --watch"
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.35.0",
"@blocknote/core": "0.35.0",
"@blocknote/mantine": "0.35.0",
"@blocknote/react": "0.35.0",
"@blocknote/xl-docx-exporter": "0.35.0",
"@blocknote/xl-multi-column": "0.35.0",
"@blocknote/xl-pdf-exporter": "0.35.0",
"@blocknote/code-block": "0.37.0",
"@blocknote/core": "0.37.0",
"@blocknote/mantine": "0.37.0",
"@blocknote/react": "0.37.0",
"@blocknote/xl-docx-exporter": "0.37.0",
"@blocknote/xl-multi-column": "0.37.0",
"@blocknote/xl-pdf-exporter": "0.37.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.10.0",
"@gouvfr-lasuite/ui-kit": "0.16.1",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.2.0",
"@openfun/cunningham-react": "3.2.3",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.42.0",
"@tanstack/react-query": "5.83.0",
"@sentry/nextjs": "10.8.0",
"@tanstack/react-query": "5.85.6",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"i18next": "25.3.2",
"emoji-regex": "10.4.0",
"i18next": "25.4.2",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.1",
"next": "15.4.4",
"posthog-js": "1.258.2",
"next": "15.4.7",
"posthog-js": "1.261.0",
"react": "*",
"react-aria-components": "1.11.0",
"react-aria-components": "1.12.1",
"react-dom": "*",
"react-i18next": "15.6.1",
"react-i18next": "15.7.3",
"react-intersection-observer": "9.16.0",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.5",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.6"
"zustand": "5.0.8"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.83.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@tanstack/react-query-devtools": "5.85.6",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.8.0",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "30.0.0",
"@types/lodash": "4.17.20",
"@types/luxon": "3.6.2",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"cross-env": "7.0.3",
"@vitejs/plugin-react": "5.0.2",
"cross-env": "10.0.0",
"dotenv": "17.2.1",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",
"jest": "30.0.5",
"jest-environment-jsdom": "30.0.5",
"jsdom": "26.1.0",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.22.0",
"stylelint-config-standard": "38.0.0",
"stylelint": "16.23.1",
"stylelint-config-standard": "39.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"webpack": "5.100.2",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.101.3",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -0,0 +1,101 @@
@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;
}
@@ -1,3 +1,5 @@
import { describe, expect, it } from 'vitest';
import { APIError, isAPIError } from '@/api';
describe('APIError', () => {
@@ -1,3 +1,5 @@
import { describe, expect, it } from 'vitest';
import { baseApiUrl } from '@/api';
describe('config', () => {
@@ -1,4 +1,5 @@
import fetchMock from 'fetch-mock';
import { beforeEach, describe, expect, it } from 'vitest';
import { fetchAPI } from '@/api';
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useAPIInfiniteQuery } from '@/api';
@@ -21,8 +22,8 @@ const createWrapper = () => {
describe('helpers', () => {
it('fetches and paginates correctly', async () => {
const mockAPI = jest
.fn<Promise<DummyResponse>, [{ page: number; query: string }]>()
const mockAPI = vi
.fn<(params: { page: number; query: string }) => Promise<DummyResponse>>()
.mockResolvedValueOnce({
results: [{ id: 1 }],
next: 'url?page=2',
@@ -1,3 +1,5 @@
import { describe, expect, it } from 'vitest';
import { errorCauses, getCSRFToken } from '@/api';
describe('utils', () => {
@@ -51,7 +51,7 @@ export const Box = styled('div')<BoxProps>`
${({ $cursor }) => $cursor && `cursor: ${$cursor};`}
${({ $direction }) => `flex-direction: ${$direction || 'column'};`}
${({ $display, as }) =>
`display: ${$display || as?.match('span|input') ? 'inline-flex' : 'flex'};`}
`display: ${$display || (as?.match('span|input') ? 'inline-flex' : 'flex')};`}
${({ $flex }) => $flex && `flex: ${$flex};`}
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
@@ -33,6 +33,13 @@ const StyledButton = styled(Button)<StyledButtonProps>`
font-size: 0.938rem;
padding: 0;
${({ $css }) => $css};
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
border-radius: 4px;
transition: none;
}
`;
export interface DropButtonProps {
@@ -41,6 +48,7 @@ export interface DropButtonProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
testId?: string;
}
export const DropButton = ({
@@ -50,6 +58,7 @@ export const DropButton = ({
onOpenChange,
children,
label,
testId,
}: PropsWithChildren<DropButtonProps>) => {
const { themeTokens } = useCunninghamTheme();
const font = themeTokens['font']?.['families']['base'];
@@ -72,6 +81,7 @@ export const DropButton = ({
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
data-testid={testId}
$css={css`
font-family: ${font};
${buttonCss};
@@ -1,12 +1,21 @@
import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
import { Fragment, PropsWithChildren, useRef, useState } from 'react';
import {
Fragment,
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDropdownKeyboardNav } from '@/hook/useDropdownKeyboardNav';
export type DropdownMenuOption = {
icon?: string;
icon?: string | ReactNode;
label: string;
testId?: string;
value?: string;
@@ -29,6 +38,7 @@ export type DropdownMenuProps = {
topMessage?: string;
selectedValues?: string[];
afterOpenChange?: (isOpen: boolean) => void;
testId?: string;
};
export const DropdownMenu = ({
@@ -43,15 +53,58 @@ export const DropdownMenu = ({
topMessage,
afterOpenChange,
selectedValues,
testId,
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null);
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
afterOpenChange?.(isOpen);
};
const onOpenChange = useCallback(
(isOpen: boolean) => {
setIsOpen(isOpen);
setFocusedIndex(-1);
afterOpenChange?.(isOpen);
},
[afterOpenChange],
);
useDropdownKeyboardNav({
isOpen,
focusedIndex,
options,
menuItemRefs,
setFocusedIndex,
onOpenChange,
});
// Focus selected menu item when menu opens
useEffect(() => {
if (!isOpen || menuItemRefs.current.length === 0) {
return;
}
const selectedIndex = options.findIndex((option) => option.isSelected);
if (selectedIndex !== -1) {
setFocusedIndex(selectedIndex);
setTimeout(() => {
menuItemRefs.current[selectedIndex]?.focus();
}, 0);
return;
}
// Fallback: focus first enabled/visible option
const firstEnabledIndex = options.findIndex(
(opt) => opt.show !== false && !opt.disabled,
);
if (firstEnabledIndex !== -1) {
setFocusedIndex(firstEnabledIndex);
setTimeout(() => {
menuItemRefs.current[firstEnabledIndex]?.focus();
}, 0);
}
}, [isOpen, options]);
if (disabled) {
return children;
@@ -63,6 +116,7 @@ export const DropdownMenu = ({
onOpenChange={onOpenChange}
label={label}
buttonCss={buttonCss}
testId={testId}
button={
showArrow ? (
<Box
@@ -95,6 +149,7 @@ export const DropdownMenu = ({
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
role="menu"
aria-label={label}
>
{topMessage && (
<Text
@@ -115,14 +170,19 @@ export const DropdownMenu = ({
return;
}
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<Fragment key={option.label}>
<BoxButton
ref={(el) => {
menuItemRefs.current[index] = el;
}}
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
$hasTransition={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -158,6 +218,10 @@ export const DropdownMenu = ({
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
&:focus-visible {
background-color: var(--c--theme--colors--greyscale-050);
}
`}
>
<Box
@@ -165,14 +229,17 @@ export const DropdownMenu = ({
$align="center"
$gap={spacingsTokens['base']}
>
{option.icon && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
)}
{option.icon &&
(typeof option.icon === 'string' ? (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
) : (
option.icon
))}
<Text $variation={isDisabled ? '400' : '1000'}>
{option.label}
</Text>
@@ -1,3 +1,4 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { Box } from '../Box';
@@ -46,7 +46,9 @@ export const QuickSearchInput = ({
$gap={spacingsTokens['2xs']}
$padding={{ horizontal: 'base', vertical: 'sm' }}
>
{!loading && <Icon iconName="search" $variation="600" />}
{!loading && (
<Icon iconName="search" $variation="600" aria-hidden="true" />
)}
{loading && (
<div>
<Loader size="small" />
@@ -1,5 +1,6 @@
@import url('@gouvfr-lasuite/ui-kit/style');
@import url('./cunningham-tokens.css');
@import url('/assets/fonts/Marianne/Marianne-font.css');
:root {
/**
@@ -37,6 +38,13 @@
);
}
/**
* Modal
*/
.c__modal__backdrop {
z-index: 1000;
}
/**
* Tooltip
*/
@@ -143,8 +143,8 @@
--c--theme--font--weights--bold: 600;
--c--theme--font--weights--extrabold: 800;
--c--theme--font--weights--black: 900;
--c--theme--font--families--base: marianne;
--c--theme--font--families--accent: marianne;
--c--theme--font--families--base: sans-serif;
--c--theme--font--families--accent: sans-serif;
--c--theme--font--letterspacings--h1: normal;
--c--theme--font--letterspacings--h2: normal;
--c--theme--font--letterspacings--h3: normal;
@@ -298,6 +298,9 @@
--c--components--button--tertiary-text--color: var(
--c--theme--colors--primary-600
);
--c--components--button--tertiary-text--disabled: var(
--c--theme--colors--primary-300
);
--c--components--button--danger--color-hover: white;
--c--components--button--danger--background--color: var(
--c--theme--colors--danger-600
@@ -553,6 +556,8 @@
--c--theme--logo--widthHeader: 110px;
--c--theme--logo--widthFooter: 220px;
--c--theme--logo--alt: gouvernement logo;
--c--theme--font--families--base: marianne;
--c--theme--font--families--accent: marianne;
--c--components--la-gaufre: true;
--c--components--home-proconnect: true;
--c--components--favicon--ico: /assets/favicon-dsfr.ico;
@@ -153,7 +153,7 @@ export const tokens = {
extrabold: 800,
black: 900,
},
families: { base: 'Marianne', accent: 'Marianne' },
families: { base: 'sans-serif', accent: 'sans-serif' },
letterSpacings: {
h1: 'normal',
h2: 'normal',
@@ -266,6 +266,7 @@ export const tokens = {
'background--color-hover': '#eee',
'color-hover': '#000091',
color: '#313178',
disabled: '#CACAFB',
},
danger: {
'color-hover': 'white',
@@ -435,6 +436,7 @@ export const tokens = {
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
font: { families: { base: 'Marianne', accent: 'Marianne' } },
},
components: {
'la-gaufre': true,
@@ -1,42 +1,28 @@
import { Crisp } from 'crisp-sdk-web';
import fetchMock from 'fetch-mock';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { gotoLogout } from '../utils';
jest.mock('crisp-sdk-web', () => ({
...jest.requireActual('crisp-sdk-web'),
Crisp: {
isCrispInjected: jest.fn().mockReturnValue(true),
setTokenId: jest.fn(),
user: {
setEmail: jest.fn(),
},
session: {
reset: jest.fn(),
},
},
// Mock the Crisp service
vi.mock('@/services/Crisp', () => ({
terminateCrispSession: vi.fn(),
}));
describe('utils', () => {
afterEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
fetchMock.restore();
});
it('checks support session is terminated when logout', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
it('checks support session is terminated when logout', async () => {
const { terminateCrispSession } = await import('@/services/Crisp');
window.$crisp = true;
const propertyDescriptors = Object.getOwnPropertyDescriptors(window);
for (const key in propertyDescriptors) {
propertyDescriptors[key].configurable = true;
}
const clonedWindow = Object.defineProperties({}, propertyDescriptors);
Object.defineProperty(clonedWindow, 'location', {
// Mock window.location.replace
const mockReplace = vi.fn();
Object.defineProperty(window, 'location', {
value: {
...window.location,
replace: jest.fn(),
replace: mockReplace,
},
writable: true,
configurable: true,
@@ -44,6 +30,9 @@ describe('utils', () => {
gotoLogout();
expect(Crisp.session.reset).toHaveBeenCalled();
expect(terminateCrispSession).toHaveBeenCalled();
expect(mockReplace).toHaveBeenCalledWith(
'http://test.jest/api/v1.0/logout/',
);
});
});
@@ -1,13 +1,14 @@
import { renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { Fragment } from 'react';
import { beforeEach, describe, expect, vi } from 'vitest';
import { AbstractAnalytic } from '@/libs';
import { AppWrapper } from '@/tests/utils';
import { useAuth } from '../useAuth';
const trackEventMock = jest.fn();
const trackEventMock = vi.fn();
const flag = true;
class TestAnalytic extends AbstractAnalytic {
public constructor() {
@@ -31,11 +32,11 @@ class TestAnalytic extends AbstractAnalytic {
}
}
jest.mock('next/router', () => ({
...jest.requireActual('next/router'),
vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
useRouter: () => ({
pathname: '/dashboard',
replace: jest.fn(),
replace: vi.fn(),
}),
}));
@@ -43,7 +44,7 @@ const dummyUser = { id: '123', email: 'test@example.com' };
describe('useAuth hook - trackEvent effect', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
fetchMock.restore();
});
@@ -33,7 +33,11 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { CalloutBlock, DividerBlock } from './custom-blocks';
import {
AccessibleImageBlock,
CalloutBlock,
DividerBlock,
} from './custom-blocks';
import {
InterlinkingLinkInlineContent,
InterlinkingSearchInlineContent,
@@ -50,6 +54,7 @@ const baseBlockNoteSchema = withPageBreak(
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
image: AccessibleImageBlock,
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
@@ -119,7 +119,11 @@ export const DocVersionEditor = ({
causes={error.cause}
icon={
error.status === 502 ? (
<Text className="material-icons" $theme="danger">
<Text
className="material-icons"
$theme="danger"
aria-hidden={true}
>
wifi_off
</Text>
) : undefined
@@ -0,0 +1,43 @@
import {
BlockFromConfig,
BlockNoteEditor,
BlockSchemaWithBlock,
InlineContentSchema,
StyleSchema,
createBlockSpec,
imageBlockConfig,
imageParse,
imageRender,
imageToExternalHTML,
} from '@blocknote/core';
type ImageBlockConfig = typeof imageBlockConfig;
export const accessibleImageRender = (
block: BlockFromConfig<ImageBlockConfig, InlineContentSchema, StyleSchema>,
editor: BlockNoteEditor<
BlockSchemaWithBlock<ImageBlockConfig['type'], ImageBlockConfig>,
InlineContentSchema,
StyleSchema
>,
) => {
const imageRenderComputed = imageRender(block, editor);
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');
return {
...imageRenderComputed,
dom,
};
};
export const AccessibleImageBlock = createBlockSpec(imageBlockConfig, {
render: accessibleImageRender,
parse: imageParse,
toExternalHTML: imageToExternalHTML,
});
@@ -1,2 +1,3 @@
export * from './AccessibleImageBlock';
export * from './CalloutBlock';
export * from './DividerBlock';
@@ -1,36 +1,38 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { useRouter } from 'next/router';
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { AppWrapper } from '@/tests/utils';
import { useSaveDoc } from '../useSaveDoc';
jest.mock('next/router', () => ({
useRouter: jest.fn(),
vi.mock('next/router', () => ({
useRouter: vi.fn(),
}));
jest.mock('@/docs/doc-versioning', () => ({
vi.mock('@/docs/doc-versioning', () => ({
KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions',
}));
jest.mock('@/docs/doc-management', () => ({
useUpdateDoc: jest.requireActual('@/docs/doc-management/api/useUpdateDoc')
.useUpdateDoc,
vi.mock('@/docs/doc-management', async () => ({
useUpdateDoc: (
await vi.importActual('@/docs/doc-management/api/useUpdateDoc')
).useUpdateDoc,
}));
describe('useSaveDoc', () => {
const mockRouterEvents = {
on: jest.fn(),
off: jest.fn(),
on: vi.fn(),
off: vi.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
fetchMock.restore();
(useRouter as jest.Mock).mockReturnValue({
(useRouter as Mock).mockReturnValue({
events: mockRouterEvents,
});
});
@@ -39,7 +41,7 @@ describe('useSaveDoc', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
@@ -60,8 +62,8 @@ describe('useSaveDoc', () => {
addEventListenerSpy.mockRestore();
});
it('should not save when canSave is false', async () => {
jest.useFakeTimers();
it('should not save when canSave is false', () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
@@ -80,22 +82,19 @@ describe('useSaveDoc', () => {
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
// Advance timers to trigger the save interval
vi.advanceTimersByTime(61000);
});
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
});
// Since canSave is false, no API call should be made
expect(fetchMock.calls().length).toBe(0);
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
jest.useRealTimers();
vi.useRealTimers();
});
it('should save when there are local changes', async () => {
jest.useFakeTimers();
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
@@ -117,21 +116,22 @@ describe('useSaveDoc', () => {
});
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
// Advance timers to trigger the save interval
vi.advanceTimersByTime(61000);
});
// Switch to real timers to allow the mutation promise to resolve
vi.useRealTimers();
await waitFor(() => {
expect(fetchMock.lastCall()?.[0]).toBe(
'http://test.jest/api/v1.0/documents/test-doc-id/',
);
});
jest.useRealTimers();
});
it('should not save when there are no local changes', async () => {
jest.useFakeTimers();
it('should not save when there are no local changes', () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
@@ -148,21 +148,20 @@ describe('useSaveDoc', () => {
});
act(() => {
// Now advance timers after state has updated
jest.advanceTimersByTime(61000);
// Advance timers without triggering any local updates
vi.advanceTimersByTime(61000);
});
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
// Since there are no local changes, no API call should be made
expect(fetchMock.calls().length).toBe(0);
jest.useRealTimers();
vi.useRealTimers();
});
it('should cleanup event listeners on unmount', () => {
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
@@ -1,20 +1,14 @@
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
jest.mock('@/features/docs/doc-export/utils', () => ({
anything: true,
}));
jest.mock('@/features/docs/doc-export/components/ModalExport', () => ({
ModalExport: () => <span>ModalExport</span>,
}));
describe('useModuleExport', () => {
afterAll(() => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv;
});
afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
vi.clearAllMocks();
vi.resetModules();
});
it('should return undefined when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => {
@@ -22,7 +16,7 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toBeUndefined();
});
}, 10000);
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';

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