Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae9f348663 | |||
| 360b83b957 | |||
| a88fa5a26c | |||
| e6e024a42c | |||
| 9cdd0bd7f9 | |||
| 66869c2758 | |||
| 057539e2b7 | |||
| 48ebb5a85c | |||
| ef9411d5e9 | |||
| 4456e8b1d6 | |||
| 2f4cd67af0 | |||
| 922d05d7e9 | |||
| cf835100b0 | |||
| 74b7afaee2 | |||
| 5b0e2e37e4 | |||
| a5e22fddf9 | |||
| d84a9cb24a | |||
| 39ef6d10ff | |||
| 961ae3c39e | |||
| 726b50d6b5 | |||
| 814eb1f1a1 | |||
| 648528499c | |||
| 474e5ac0c0 | |||
| a799d77643 | |||
| 2e04b63d2d | |||
| eec419bdba | |||
| baa5630344 | |||
| e7b551caa4 | |||
| 4dfc1584bd | |||
| 09eddfc339 | |||
| 75f2e547e0 | |||
| d1cbdfd819 | |||
| 0b64417058 | |||
| 57a505a80c | |||
| 21ee38c218 | |||
| 09de014a43 | |||
| 8d42149304 | |||
| 2451a6a322 | |||
| d5c9eaca5a | |||
| 1491012969 | |||
| 9dcf478dd3 | |||
| 586825aafa | |||
| 247550fc13 | |||
| 781c85b66b | |||
| 64f967cd29 | |||
| 1eee24dc19 | |||
| ff9e13ca03 | |||
| 7758e64f40 | |||
| 4ab9edcd57 | |||
| 0892c05321 | |||
| 2375bc136c | |||
| e1c2053697 | |||
| 58f68d86e1 | |||
| 7c97719907 | |||
| d0c9de9d96 | |||
| 81f3997628 | |||
| 0cf8b9da1a | |||
| 7be761ce84 | |||
| 5181bba083 | |||
| f434d78b5d | |||
| e07f709dd4 | |||
| afbacb0a24 | |||
| 409e073192 | |||
| 886dcb75d5 | |||
| bb4d2a9fea | |||
| 5e5054282e | |||
| f497e75426 | |||
| 97ab13ded6 | |||
| 99d674c615 | |||
| 1cdb6b62c8 | |||
| 2bf53301d2 | |||
| ec84f31bc7 | |||
| 7813219b86 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 🙏
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://test.jest
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=false
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+43
@@ -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
@@ -1,2 +1,3 @@
|
||||
export * from './AccessibleImageBlock';
|
||||
export * from './CalloutBlock';
|
||||
export * from './DividerBlock';
|
||||
|
||||
+35
-36
@@ -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,
|
||||
|
||||
+4
-10
@@ -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
Reference in New Issue
Block a user