Compare commits

..

32 Commits

Author SHA1 Message Date
Your name a4d968eff3 remise à niveau 2025-02-13 16:29:33 +01:00
Your name 12f7c8b371 after pull 2025-02-12 10:36:03 +01:00
MarineM1 70e3e9e7d4 Merge branch 'main' into accessibility 2025-02-12 10:02:25 +01:00
MarineM1 e0426ca803 Merge branch 'main' into accessibility 2025-02-10 14:17:20 +01:00
MarineM1 a01790dc0b Merge branch 'main' into accessibility 2025-02-10 08:35:03 +01:00
Your name e14622ff66 ️ (frontend) modal options modified
The modal for document options has been modified to allow navigation
using "tab" and to be closed.
2025-02-06 14:47:04 +01:00
Your name 75fd994a5b (frontend) Focus
The elements focused with "Tab" are visible and consistent with the
defined style.
2025-02-04 16:27:46 +01:00
Your name 239933aef3 Merge branch 'accessibility' of https://github.com/suitenumerique/docs into accessibility 2025-02-03 16:38:13 +01:00
Your name 95c06d68cb ️ (frontend) navigation with tab and other modifications
- In DocGrid, when navigating with tab, we can see where we are
- In Doc, don't read "doc title input" but "rename"
- In docSearch, don't read "search" icon and read "aucun document
  trouvé" with screen reader
2025-02-03 16:27:24 +01:00
MarineM1 0d12278474 Merge branch 'main' into accessibility 2025-02-03 10:58:02 +01:00
Your name 8ce13075c1 (frontend) leftpanel and grid modified
- Read the Tooltip with the number of participants, not the icon
- In LeftPanel, when navigating with tab, we can see where we are
2025-01-31 16:47:37 +01:00
Your name 225f5a8abb erreur du commit précedent
je ne commite que les modifications annoncées dans le commit précédent
2025-01-31 13:23:05 +01:00
Your name 71a8765770 ️ (frontend) update after comments
-modifications after Sylvain's commentary
-modifications after Anto's commentaries
2025-01-31 11:42:32 +01:00
MarineM1 9186a101ec Merge branch 'main' into accessibility 2025-01-31 11:41:31 +01:00
MarineM1 74e816c479 Merge branch 'main' into accessibility 2025-01-30 16:14:09 +01:00
Your name 5d9eb2d694 mise en correspondance avec le main 2025-01-30 15:25:40 +01:00
Your name 33e168ba17 mise en correspondance avec le main 2025-01-30 14:49:13 +01:00
Your name f1c0f6bba0 ️ (frontend) pin icon translated and read
For good accessibility, the pinned icon will be read by the screen reader, but the simple icon will not
2025-01-30 14:26:29 +01:00
Your name 8f4fd15495 Merge branch 'accessibility' of https://github.com/suitenumerique/docs into accessibility 2025-01-30 09:36:00 +01:00
MarineM1 cc6ce4a945 Merge branch 'main' into accessibility 2025-01-30 09:02:51 +01:00
MarineM1 044c8f0bbd Merge branch 'main' into accessibility 2025-01-29 13:18:04 +01:00
Your name 7e62dcf1fc Merge branch 'main' of https://github.com/suitenumerique/docs into accessibility 2025-01-29 13:10:44 +01:00
MarineM1 e7742d914c Merge branch 'main' into accessibility 2025-01-29 10:54:54 +01:00
Your name b1a5c17d75 ️(frontend) update some code html
To improve accessibility, certain parts of the code are modified
 with the help of a screen reader
2025-01-28 15:32:55 +01:00
Your name bd68396e52 commit suite pull du 28 01 2025 2025-01-28 10:32:11 +01:00
Your name 9f1ae58ead commit avant récup du code 2025-01-28 09:15:29 +01:00
Your name f597549e96 Accessibilité - modifications des boutons corporate, group et public pour le lecteur d'écran 2025-01-28 09:14:26 +01:00
Your name 847e120d67 commit après récupération des modif du main 2025-01-27 09:18:37 +01:00
Your name 6682ddafff accessibilité - icons apps,lock et group plus plues 2025-01-27 09:16:26 +01:00
Your name 2f23404003 Accessibilité - modif des boutons home et search 2025-01-22 11:18:24 +01:00
Your name 6d4210d34b essai de modificaation de la couleur des arrows + pull du 20/01/2025 2025-01-20 11:36:24 +01:00
Your name 85f7598be8 Accessibilité - Lecture du Logo supprimée car double 2025-01-16 15:21:38 +01:00
99 changed files with 6650 additions and 1712 deletions
+22
View File
@@ -88,6 +88,28 @@ jobs:
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for services to be ready
run: |
printf "Minio check...\n"
dockerize -wait tcp://localhost:9000 -timeout 20s
printf "Keyclock check...\n"
dockerize -wait tcp://localhost:8080 -timeout 20s
printf "Server collaboration check...\n"
dockerize -wait tcp://localhost:4444 -timeout 20s
printf "Ngnix check...\n"
dockerize -wait tcp://localhost:8083 -timeout 20s
printf "DRF check...\n"
dockerize -wait tcp://localhost:8071 -timeout 20s
printf "Postgres Keyclock check...\n"
dockerize -wait tcp://localhost:5433 -timeout 20s
printf "Postgres back check...\n"
dockerize -wait tcp://localhost:15432 -timeout 20s
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
+22 -20
View File
@@ -6,23 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## Added
- 💄(frontend) add error pages #643
## Changed
- 🛂(frontend) Restore version visibility #629
- 📝(doc) minor README.md formatting and wording enhancements
- ♻️Stop setting a default title on doc creation #634
- ♻️(frontend) misc ui improvements #644
## Fixed
- ♻️(frontend) improve table pdf rendering
## [2.2.0] - 2025-02-10
## Added
@@ -42,6 +28,7 @@ and this project adheres to
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
## [2.1.0] - 2025-01-29
## Added
@@ -57,7 +44,7 @@ and this project adheres to
## Changed
- 💄(frontend) add abilities on doc row #581
- 💄(frontend) improve DocsGridItem responsive padding #582
- 💄(frontend) improve DocsGridItem responsive padding #582
- 🔧(backend) Bump maximum page size to 200 #516
- 📝(doc) Improve Read me #558
@@ -69,6 +56,7 @@ and this project adheres to
- 🔥(backend) remove "content" field from list serializer # 516
## [2.0.1] - 2025-01-17
## Fixed
@@ -123,11 +111,12 @@ and this project adheres to
- ⚡️(e2e) reduce flakiness on e2e tests #511
## Fixed
## Fixed
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [1.9.0] - 2024-12-11
## Added
@@ -149,18 +138,21 @@ and this project adheres to
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
## [1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
@@ -189,6 +181,7 @@ and this project adheres to
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
## Added
@@ -216,6 +209,7 @@ and this project adheres to
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
## Added
@@ -238,6 +232,7 @@ and this project adheres to
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [1.5.1] - 2024-10-10
## Fixed
@@ -272,6 +267,7 @@ and this project adheres to
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## Added
@@ -292,6 +288,7 @@ and this project adheres to
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
## Added
@@ -316,6 +313,7 @@ and this project adheres to
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## Changed
@@ -323,6 +321,7 @@ and this project adheres to
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
@@ -346,14 +345,14 @@ and this project adheres to
- ⚡️(CI) only e2e chrome mandatory #177
## Removed
- 🔥(helm) remove htaccess #181
## [1.1.0] - 2024-07-15
## Added
- 🤡(demo) generate dummy documents on dev users #120
- 🤡(demo) generate dummy documents on dev users #120
- ✨(frontend) create side modal component #134
- ✨(frontend) Doc grid actions (update / delete) #136
- ✨(frontend) Doc editor header information #137
@@ -364,11 +363,12 @@ and this project adheres to
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## Added
- 🛂(frontend) Manage the document's right (#75)
- 🛂(frontend) Manage the document's right (#75)
- ✨(frontend) Update document (#68)
- ✨(frontend) Remove document (#68)
- 🐳(docker) dockerize dev frontend (#63)
@@ -402,6 +402,7 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## Added
@@ -409,6 +410,7 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
+17 -13
View File
@@ -44,6 +44,7 @@ COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -80,12 +81,12 @@ bootstrap: \
data/static \
create-env-files \
build \
run-with-frontend \
migrate \
demo \
back-i18n-compile \
mails-install \
mails-build \
run
mails-build
.PHONY: bootstrap
# -- Docker/compose
@@ -108,7 +109,7 @@ build-yjs-provider: ## build the y-provider container
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend $(cache)
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@@ -119,18 +120,19 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
run: ## start the wsgi (production) and development server
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -186,12 +188,14 @@ test-back-parallel: ## run all back-end tests in parallel
makemigrations: ## run django makemigrations for the impress project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the impress project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate
.PHONY: migrate
@@ -306,16 +310,16 @@ help:
.PHONY: help
# Front
frontend-development-install: ## install the frontend locally
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-development-install
.PHONY: frontend-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && yarn lint
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
+12 -28
View File
@@ -23,7 +23,6 @@ Welcome to Docs! The open source document editor where your notes can become kno
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
### Write
@@ -34,31 +33,23 @@ Docs is a collaborative text editor designed to address common challenges in kno
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
### Collaborate
* 🤝 Collaborate with your team in real time
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
* 🤝 Collaborate in realtime with your team mates
* 🔒 Granular access control to keep your information secure and shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
## Getting started 🔧
### Test it
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
```
email: test.docs@yopmail.com
password: I'd<3ToTestDocs
```
### Run it locally
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
@@ -66,22 +57,23 @@ $ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose version
$ docker compose -v
Docker Compose version v2.32.4
docker compose version 1.27.4, build 40524192
```
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
**Project bootstrap**
The easiest way to start working on the project is to use GNU Make:
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
@@ -97,7 +89,7 @@ password: impress
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
$ make run
$ make run-with-frontend
```
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
@@ -105,7 +97,7 @@ $ make run
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
@@ -117,7 +109,7 @@ $ make run-frontend-development
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
$ make run
```
**Adding content**
@@ -134,7 +126,6 @@ $ make help
```
**Django admin**
You can access the Django admin site at
<http://localhost:8071/admin>.
@@ -146,21 +137,17 @@ $ make superuser
```
## Feedback 🙋‍♂️🙋‍♀️
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
## Roadmap
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
@@ -182,13 +169,10 @@ docs
```
## Credits ❤️
### Stack
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/)
### Gov ❤️ open source
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
+3 -1
View File
@@ -7,6 +7,7 @@ UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="docs"
# _set_user: set (or unset) default user id used to run docker commands
@@ -39,8 +40,9 @@ function _set_user() {
# ARGS : docker compose command arguments
function _docker_compose() {
echo "🐳(compose) file: '${COMPOSE_FILE}'"
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"
+17 -55
View File
@@ -1,13 +1,6 @@
name: docs
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/development/postgresql
ports:
@@ -22,7 +15,7 @@ services:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
# user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
@@ -30,11 +23,6 @@ services:
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
@@ -43,9 +31,7 @@ services:
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
- minio
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
@@ -73,15 +59,10 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
- postgresql
- mailcatcher
- redis
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -112,13 +93,9 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
- postgresql
- redis
- minio
celery:
user: ${DOCKER_USER:-1000}
@@ -139,15 +116,11 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
app-dev:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_healthy
restart: true
- keycloak
- app-dev
- y-provider
frontend:
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
@@ -162,6 +135,9 @@ services:
ports:
- "3000:3000"
dockerize:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:3.16.0
volumes:
@@ -193,11 +169,6 @@ services:
kc_postgresql:
image: postgres:14.3
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
ports:
- "5433:5432"
env_file:
@@ -216,13 +187,6 @@ services:
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
healthcheck:
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
interval: 1s
timeout: 2s
retries: 300
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@@ -236,6 +200,4 @@ services:
ports:
- "8080:8080"
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
- kc_postgresql
@@ -68,8 +68,6 @@ server {
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {
@@ -90,11 +88,5 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Increase proxy buffer size to allow keycloak to send large
# header responses when a user is created.
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}
+3853
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
{
"dependencies": {
"@blocknote/core": "^0.23.4",
"next": "^15.1.7"
}
}
-2
View File
@@ -418,7 +418,6 @@ class FileUploadSerializer(serializers.Serializer):
self.context["expected_extension"] = extension
self.context["content_type"] = magic_mime_type
self.context["file_name"] = file.name
return file
@@ -427,7 +426,6 @@ class FileUploadSerializer(serializers.Serializer):
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
attrs["content_type"] = self.context["content_type"]
attrs["file_name"] = self.context["file_name"]
return attrs
-13
View File
@@ -925,19 +925,6 @@ class DocumentViewSet(
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
file_name = serializer.validated_data["file_name"]
if (
not serializer.validated_data["content_type"].startswith("image/")
or serializer.validated_data["is_unsafe"]
):
extra_args.update(
{"ContentDisposition": f'attachment; filename="{file_name:s}"'}
)
else:
extra_args.update(
{"ContentDisposition": f'inline; filename="{file_name:s}"'}
)
file = serializer.validated_data["file"]
default_storage.connection.meta.client.upload_fileobj(
file, default_storage.bucket_name, key, ExtraArgs=extra_args
+97 -483
View File
@@ -1,552 +1,166 @@
# Generated by Django 5.0.3 on 2024-05-28 20:29
import uuid
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
import uuid
from django.conf import settings
from django.db import migrations, models
import timezone_field.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name="Document",
name='Document',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
(
"is_public",
models.BooleanField(
default=False,
help_text="Whether this document is public for anyone to use.",
verbose_name="public",
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('title', models.CharField(max_length=255, verbose_name='title')),
('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')),
],
options={
"verbose_name": "Document",
"verbose_name_plural": "Documents",
"db_table": "impress_document",
"ordering": ("title",),
'verbose_name': 'Document',
'verbose_name_plural': 'Documents',
'db_table': 'impress_document',
'ordering': ('title',),
},
),
migrations.CreateModel(
name="Template",
name='Template',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
(
"description",
models.TextField(blank=True, verbose_name="description"),
),
("code", models.TextField(blank=True, verbose_name="code")),
("css", models.TextField(blank=True, verbose_name="css")),
(
"is_public",
models.BooleanField(
default=False,
help_text="Whether this template is public for anyone to use.",
verbose_name="public",
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('title', models.CharField(max_length=255, verbose_name='title')),
('description', models.TextField(blank=True, verbose_name='description')),
('code', models.TextField(blank=True, verbose_name='code')),
('css', models.TextField(blank=True, verbose_name='css')),
('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')),
],
options={
"verbose_name": "Template",
"verbose_name_plural": "Templates",
"db_table": "impress_template",
"ordering": ("title",),
'verbose_name': 'Template',
'verbose_name_plural': 'Templates',
'db_table': 'impress_template',
'ordering': ('title',),
},
),
migrations.CreateModel(
name="User",
name='User',
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"sub",
models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.",
max_length=255,
null=True,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.",
regex="^[\\w.@+-]+\\Z",
)
],
verbose_name="sub",
),
),
(
"email",
models.EmailField(
blank=True,
max_length=254,
null=True,
verbose_name="identity email address",
),
),
(
"admin_email",
models.EmailField(
blank=True,
max_length=254,
null=True,
unique=True,
verbose_name="admin email address",
),
),
(
"language",
models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
),
(
"timezone",
timezone_field.fields.TimeZoneField(
choices_display="WITH_GMT_OFFSET",
default="UTC",
help_text="The timezone in which the user wants to see times.",
use_pytz=False,
),
),
(
"is_device",
models.BooleanField(
default=False,
help_text="Whether the user is a device or a real user.",
verbose_name="device",
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"db_table": "impress_user",
'verbose_name': 'user',
'verbose_name_plural': 'users',
'db_table': 'impress_user',
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="DocumentAccess",
name='DocumentAccess',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("team", models.CharField(blank=True, max_length=100)),
(
"role",
models.CharField(
choices=[
("reader", "Reader"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="accesses",
to="core.document",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document/user relation",
"verbose_name_plural": "Document/user relations",
"db_table": "impress_document_access",
"ordering": ("-created_at",),
'verbose_name': 'Document/user relation',
'verbose_name_plural': 'Document/user relations',
'db_table': 'impress_document_access',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name="Invitation",
name='Invitation',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"email",
models.EmailField(max_length=254, verbose_name="email address"),
),
(
"role",
models.CharField(
choices=[
("reader", "Reader"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invitations",
to="core.document",
),
),
(
"issuer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invitations",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document invitation",
"verbose_name_plural": "Document invitations",
"db_table": "impress_invitation",
'verbose_name': 'Document invitation',
'verbose_name_plural': 'Document invitations',
'db_table': 'impress_invitation',
},
),
migrations.CreateModel(
name="TemplateAccess",
name='TemplateAccess',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("team", models.CharField(blank=True, max_length=100)),
(
"role",
models.CharField(
choices=[
("reader", "Reader"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
(
"template",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="accesses",
to="core.template",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Template/user relation",
"verbose_name_plural": "Template/user relations",
"db_table": "impress_template_access",
"ordering": ("-created_at",),
'verbose_name': 'Template/user relation',
'verbose_name_plural': 'Template/user relations',
'db_table': 'impress_template_access',
'ordering': ('-created_at',),
},
),
migrations.AddConstraint(
model_name="documentaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("user__isnull", False)),
fields=("user", "document"),
name="unique_document_user",
violation_error_message="This user is already in this document.",
),
model_name='documentaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'),
),
migrations.AddConstraint(
model_name="documentaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("team__gt", "")),
fields=("team", "document"),
name="unique_document_team",
violation_error_message="This team is already in this document.",
),
model_name='documentaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'),
),
migrations.AddConstraint(
model_name="documentaccess",
constraint=models.CheckConstraint(
check=models.Q(
models.Q(("team", ""), ("user__isnull", False)),
models.Q(("team__gt", ""), ("user__isnull", True)),
_connector="OR",
),
name="check_document_access_either_user_or_team",
violation_error_message="Either user or team must be set, not both.",
),
model_name='documentaccess',
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
migrations.AddConstraint(
model_name="invitation",
constraint=models.UniqueConstraint(
fields=("email", "document"), name="email_and_document_unique_together"
),
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
),
migrations.AddConstraint(
model_name="templateaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("user__isnull", False)),
fields=("user", "template"),
name="unique_template_user",
violation_error_message="This user is already in this template.",
),
model_name='templateaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),
),
migrations.AddConstraint(
model_name="templateaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("team__gt", "")),
fields=("team", "template"),
name="unique_template_team",
violation_error_message="This team is already in this template.",
),
model_name='templateaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'),
),
migrations.AddConstraint(
model_name="templateaccess",
constraint=models.CheckConstraint(
check=models.Q(
models.Q(("team", ""), ("user__isnull", False)),
models.Q(("team__gt", ""), ("user__isnull", True)),
_connector="OR",
),
name="check_template_access_either_user_or_team",
violation_error_message="Either user or team must be set, not both.",
),
model_name='templateaccess',
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]
@@ -1,9 +1,9 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
('core', '0001_initial'),
]
operations = [
@@ -1,114 +1,52 @@
# Generated by Django 5.1 on 2024-09-08 16:55
import uuid
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_create_pg_trgm_extension"),
('core', '0002_create_pg_trgm_extension'),
]
operations = [
migrations.AddField(
model_name="document",
name="link_reach",
field=models.CharField(
choices=[
("restricted", "Restricted"),
("authenticated", "Authenticated"),
("public", "Public"),
],
default="authenticated",
max_length=20,
),
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
),
migrations.AddField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[("reader", "Reader"), ("editor", "Editor")],
default="reader",
max_length=20,
),
model_name='document',
name='link_role',
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
),
migrations.AlterField(
model_name="document",
name="is_public",
model_name='document',
name='is_public',
field=models.BooleanField(null=True),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name="LinkTrace",
name='LinkTrace',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="link_traces",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="link_traces",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document/user link trace",
"verbose_name_plural": "Document/user link traces",
"db_table": "impress_link_trace",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_link_trace_document_user",
violation_error_message="A link trace already exists for this document/user.",
)
],
'verbose_name': 'Document/user link trace',
'verbose_name_plural': 'Document/user link traces',
'db_table': 'impress_link_trace',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
},
),
]
@@ -1,14 +1,13 @@
# Generated by Django 5.1 on 2024-09-08 17:04
from django.db import migrations
def migrate_is_public_to_link_reach(apps, schema_editor):
"""
Forward migration: Migrate 'is_public' to 'link_reach'.
If is_public == True, set link_reach to 'public'
"""
Document = apps.get_model("core", "Document")
Document.objects.filter(is_public=True).update(link_reach="public")
Document = apps.get_model('core', 'Document')
Document.objects.filter(is_public=True).update(link_reach='public')
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
@@ -17,20 +16,20 @@ def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
- If link_reach == 'public', set is_public to True
- Else set is_public to False
"""
Document = apps.get_model("core", "Document")
Document.objects.filter(link_reach="public").update(is_public=True)
Document.objects.filter(link_reach__in=["restricted", "authenticated"]).update(
is_public=False
)
Document = apps.get_model('core', 'Document')
Document.objects.filter(link_reach='public').update(is_public=True)
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
class Migration(migrations.Migration):
dependencies = [
("core", "0003_document_link_reach_document_link_role_and_more"),
('core', '0003_document_link_reach_document_link_role_and_more'),
]
operations = [
migrations.RunPython(
migrate_is_public_to_link_reach, reverse_migrate_link_reach_to_is_public
migrate_is_public_to_link_reach,
reverse_migrate_link_reach_to_is_public
),
]
@@ -4,16 +4,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0004_migrate_is_public_to_link_reach"),
('core', '0004_migrate_is_public_to_link_reach'),
]
operations = [
migrations.AlterField(
model_name="document",
name="title",
field=models.CharField(
blank=True, max_length=255, null=True, verbose_name="title"
),
model_name='document',
name='title',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
),
]
@@ -4,34 +4,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0005_remove_document_is_public_alter_document_link_reach_and_more"),
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name="user",
name="full_name",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="full name"
),
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name="user",
name="short_name",
field=models.CharField(
blank=True, max_length=20, null=True, verbose_name="short name"
),
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -117,10 +117,10 @@ BEGIN
END $$;
"""
class Migration(migrations.Migration):
dependencies = [
("core", "0006_add_user_full_name_and_short_name"),
('core', '0006_add_user_full_name_and_short_name'),
]
operations = [
@@ -4,22 +4,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0007_fix_users_duplicate"),
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_reach",
field=models.CharField(
choices=[
("restricted", "Restricted"),
("authenticated", "Authenticated"),
("public", "Public"),
],
default="restricted",
max_length=20,
),
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]
@@ -1,87 +1,37 @@
# Generated by Django 5.1.2 on 2024-11-08 07:59
import uuid
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0008_alter_document_link_reach"),
('core', '0008_alter_document_link_reach'),
]
operations = [
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name="DocumentFavorite",
name='DocumentFavorite',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="favorited_by_users",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="favorite_documents",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document favorite",
"verbose_name_plural": "Document favorites",
"db_table": "impress_document_favorite",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_document_favorite_user",
violation_error_message="This document is already targeted by a favorite relation instance for the same user.",
)
],
'verbose_name': 'Document favorite',
'verbose_name_plural': 'Document favorites',
'db_table': 'impress_document_favorite',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
},
),
]
@@ -7,48 +7,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0009_add_document_favorite"),
('core', '0009_add_document_favorite'),
]
operations = [
migrations.AddField(
model_name="document",
name="creator",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="documents_created",
to=settings.AUTH_USER_MODEL,
),
model_name='document',
name='creator',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.AlterField(
model_name="user",
name="sub",
field=models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.",
max_length=255,
null=True,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.",
regex="^[\\w.@+-:]+\\Z",
)
],
verbose_name="sub",
),
model_name='user',
name='sub',
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
),
]
@@ -3,7 +3,7 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db.models import F, ForeignKey, OuterRef, Q, Subquery
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
def set_creator_from_document_access(apps, schema_editor):
@@ -25,37 +25,28 @@ def set_creator_from_document_access(apps, schema_editor):
DocumentAccess = apps.get_model("core", "DocumentAccess")
# Update `creator` using the "owner" role
owner_subquery = (
DocumentAccess.objects.filter(
document=OuterRef("pk"),
user__isnull=False,
role="owner",
)
.order_by("created_at")
.values("user_id")[:1]
)
owner_subquery = DocumentAccess.objects.filter(
document=OuterRef('pk'),
user__isnull=False,
role='owner',
).order_by('created_at').values('user_id')[:1]
Document.objects.filter(creator__isnull=True).update(
creator=Subquery(owner_subquery)
)
Document.objects.filter(
creator__isnull=True
).update(creator=Subquery(owner_subquery))
class Migration(migrations.Migration):
dependencies = [
("core", "0010_add_field_creator_to_document"),
('core', '0010_add_field_creator_to_document'),
]
operations = [
migrations.RunPython(
set_creator_from_document_access, reverse_code=migrations.RunPython.noop
),
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="document",
name="creator",
field=ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="documents_created",
to=settings.AUTH_USER_MODEL,
),
model_name='document',
name='creator',
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
]
@@ -6,42 +6,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0011_populate_creator_field_and_make_it_required"),
('core', '0011_populate_creator_field_and_make_it_required'),
]
operations = [
migrations.AlterField(
model_name="document",
name="creator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="documents_created",
to=settings.AUTH_USER_MODEL,
),
model_name='document',
name='creator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name="invitation",
name="issuer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="invitations",
to=settings.AUTH_USER_MODEL,
),
model_name='invitation',
name='issuer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -2,10 +2,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0012_make_document_creator_and_invitation_issuer_optional"),
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
]
operations = [
@@ -4,29 +4,28 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0013_activate_fuzzystrmatch_extension"),
('core', '0013_activate_fuzzystrmatch_extension'),
]
operations = [
migrations.AddField(
model_name="document",
name="depth",
model_name='document',
name='depth',
field=models.PositiveIntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name="document",
name="numchild",
model_name='document',
name='numchild',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name="document",
name="path",
model_name='document',
name='path',
# Allow null values pending the next datamigration to populate the field
field=models.CharField(
db_collation="C", max_length=252, null=True, unique=True
),
field=models.CharField(db_collation='C', max_length=252, null=True, unique=True),
preserve_default=False,
),
]
@@ -7,10 +7,9 @@ from treebeard.numconv import NumConv
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
STEPLEN = 7
def set_path_on_existing_documents(apps, schema_editor):
"""
Updates the `path` and `depth` fields for all existing Document records
Updates the `path` and `depth` fields for all existing Document records
to ensure valid materialized paths.
This function assigns a unique `path` to each Document as a root node
@@ -27,25 +26,27 @@ def set_path_on_existing_documents(apps, schema_editor):
updates = []
for i, pk in enumerate(documents):
key = numconv.int2str(i)
path = "{0}{1}".format(ALPHABET[0] * (STEPLEN - len(key)), key)
path = "{0}{1}".format(
ALPHABET[0] * (STEPLEN - len(key)),
key
)
updates.append(Document(pk=pk, path=path, depth=1))
# Bulk update using the prepared updates list
Document.objects.bulk_update(updates, ["depth", "path"])
Document.objects.bulk_update(updates, ['depth', 'path'])
class Migration(migrations.Migration):
dependencies = [
("core", "0014_add_tree_structure_to_documents"),
('core', '0014_add_tree_structure_to_documents'),
]
operations = [
migrations.RunPython(
set_path_on_existing_documents, reverse_code=migrations.RunPython.noop
),
migrations.RunPython(set_path_on_existing_documents, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="document",
name="path",
field=models.CharField(db_collation="C", max_length=252, unique=True),
model_name='document',
name='path',
field=models.CharField(db_collation='C', max_length=252, unique=True),
),
]
@@ -4,27 +4,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0015_set_path_on_existing_documents"),
('core', '0015_set_path_on_existing_documents'),
]
operations = [
migrations.AddField(
model_name="document",
name="excerpt",
field=models.TextField(
blank=True, max_length=300, null=True, verbose_name="excerpt"
),
model_name='document',
name='excerpt',
field=models.TextField(blank=True, max_length=300, null=True, verbose_name='excerpt'),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -4,49 +4,33 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0016_add_document_excerpt"),
('core', '0016_add_document_excerpt'),
]
operations = [
migrations.AlterModelOptions(
name="document",
options={
"ordering": ("path",),
"verbose_name": "Document",
"verbose_name_plural": "Documents",
},
name='document',
options={'ordering': ('path',), 'verbose_name': 'Document', 'verbose_name_plural': 'Documents'},
),
migrations.AddField(
model_name="document",
name="ancestors_deleted_at",
model_name='document',
name='ancestors_deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="document",
name="deleted_at",
model_name='document',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.AddConstraint(
model_name="document",
constraint=models.CheckConstraint(
condition=models.Q(
("deleted_at__isnull", True),
("deleted_at", models.F("ancestors_deleted_at")),
_connector="OR",
),
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
),
model_name='document',
constraint=models.CheckConstraint(condition=models.Q(('deleted_at__isnull', True), ('deleted_at', models.F('ancestors_deleted_at')), _connector='OR'), name='check_deleted_at_matches_ancestors_deleted_at_when_set'),
),
]
@@ -1,24 +0,0 @@
from django.db import migrations
def update_titles_to_null(apps, schema_editor):
"""
If the titles are "Untitled document" or "Unbenanntes Dokument" or "Document sans titre"
we set them to Null
"""
Document = apps.get_model("core", "Document")
Document.objects.filter(
title__in=["Untitled document", "Unbenanntes Dokument", "Document sans titre"]
).update(title=None)
class Migration(migrations.Migration):
dependencies = [
("core", "0017_add_fields_for_soft_delete"),
]
operations = [
migrations.RunPython(
update_titles_to_null, reverse_code=migrations.RunPython.noop
),
]
@@ -79,7 +79,6 @@ def test_api_documents_attachment_upload_anonymous_success():
assert file_head["Metadata"] == {"owner": "None"}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
@pytest.mark.parametrize(
@@ -218,7 +217,6 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
)
assert file_head["Metadata"] == {"owner": str(user.id)}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
def test_api_documents_attachment_upload_invalid(client):
@@ -303,7 +301,6 @@ def test_api_documents_attachment_upload_fix_extension(
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == content_type
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
def test_api_documents_attachment_upload_empty_file():
@@ -353,4 +350,3 @@ def test_api_documents_attachment_upload_unsafe():
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == "application/octet-stream"
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
@@ -1,35 +0,0 @@
import pytest
from core import factories
@pytest.mark.django_db
def test_update_blank_title_migration(migrator):
"""
Test that the migration fixes the titles of documents that are
"Untitled document", "Unbenanntes Dokument" or "Document sans titre"
"""
migrator.apply_initial_migration(("core", "0017_add_fields_for_soft_delete"))
english_doc = factories.DocumentFactory(title="Untitled document")
german_doc = factories.DocumentFactory(title="Unbenanntes Dokument")
french_doc = factories.DocumentFactory(title="Document sans titre")
other_doc = factories.DocumentFactory(title="My document")
assert english_doc.title == "Untitled document"
assert german_doc.title == "Unbenanntes Dokument"
assert french_doc.title == "Document sans titre"
assert other_doc.title == "My document"
# Apply the migration
migrator.apply_tested_migration(("core", "0018_update_blank_title"))
english_doc.refresh_from_db()
german_doc.refresh_from_db()
french_doc.refresh_from_db()
other_doc.refresh_from_db()
assert english_doc.title == None
assert german_doc.title == None
assert french_doc.title == None
assert other_doc.title == "My document"
+1 -1
View File
@@ -68,7 +68,6 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"django-test-migrations==1.4.0",
"drf-spectacular-sidecar==2024.12.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
@@ -100,6 +99,7 @@ exclude = [
"build",
"venv",
"__pycache__",
"*/migrations/*",
]
line-length = 88
@@ -17,13 +17,13 @@ test.describe('404', () => {
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible();
await expect(page.getByText('Home')).toBeVisible();
await expect(page.getByText('Back to home page')).toBeVisible();
});
test('checks go back to home page redirects to home page', async ({
page,
}) => {
await page.getByText('Home').click();
await page.getByText('Back to home page').click();
await expect(page).toHaveURL('/');
});
});
@@ -1,46 +1,27 @@
import { FullConfig, FullProject, chromium, expect } from '@playwright/test';
import { test as setup } from '@playwright/test';
import { keyCloakSignIn } from './common';
const saveStorageState = async (
browserConfig: FullProject<unknown, unknown>,
) => {
const browserName = browserConfig?.name || 'chromium';
setup('authenticate-chromium', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'chromium');
await page
.context()
.storageState({ path: `playwright/.auth/user-chromium.json` });
});
const { storageState, ...useConfig } = browserConfig?.use;
const browser = await chromium.launch();
const context = await browser.newContext(useConfig);
const page = await context.newPage();
setup('authenticate-webkit', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'webkit');
await page
.context()
.storageState({ path: `playwright/.auth/user-webkit.json` });
});
await page.goto('/', { waitUntil: 'networkidle' });
await page.content();
await expect(page.getByText('Docs').first()).toBeVisible();
await keyCloakSignIn(page, browserName);
await expect(
page.locator('header').first().getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await page.context().storageState({
path: storageState as string,
});
await browser.close();
};
async function globalSetup(config: FullConfig) {
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const chromeConfig = config.projects.find((p) => p.name === 'chromium')!;
const firefoxConfig = config.projects.find((p) => p.name === 'firefox')!;
const webkitConfig = config.projects.find((p) => p.name === 'webkit')!;
/* eslint-enable @typescript-eslint/no-non-null-assertion */
await saveStorageState(chromeConfig);
await saveStorageState(webkitConfig);
await saveStorageState(firefoxConfig);
}
export default globalSetup;
setup('authenticate-firefox', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'firefox');
await page
.context()
.storageState({ path: `playwright/.auth/user-firefox.json` });
});
@@ -97,7 +97,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -37,10 +37,10 @@ test.describe('Doc Editor', () => {
// Change language to French
await header.click();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('button').getByText('Français'),
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
@@ -130,7 +130,7 @@ test.describe('Doc Editor', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -7,9 +7,17 @@ type SmallDoc = {
title: string;
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the grid when mobile', async ({ page }) => {
await page.route('**/documents/**', async (route) => {
const request = route.request();
@@ -86,10 +94,6 @@ test.describe('Documents Grid mobile', () => {
});
test.describe('Document grid item options', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
@@ -208,8 +212,6 @@ test.describe('Document grid item options', () => {
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
await page.goto('/');
// All Docs
const response = await page.waitForResponse(
(response) =>
@@ -280,9 +282,11 @@ test.describe('Documents filters', () => {
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
@@ -310,12 +314,11 @@ test.describe('Documents Grid', () => {
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse((response) => {
return (
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().endsWith(`/documents/?page=1`) &&
response.status() === 200
);
});
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
@@ -323,8 +326,6 @@ test.describe('Documents Grid', () => {
response.status() === 200,
);
await page.goto('/');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
let result = await responsePage1.json();
@@ -89,7 +89,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Delete document',
})
.click();
@@ -153,7 +153,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -177,7 +177,7 @@ test.describe('Doc Header', () => {
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -195,7 +195,7 @@ test.describe('Doc Header', () => {
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -233,7 +233,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -295,7 +295,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -352,7 +352,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await page.getByRole('button', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -387,7 +387,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await page.getByRole('button', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -460,7 +460,7 @@ test.describe('Documents Header mobile', () => {
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
@@ -494,7 +494,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -65,15 +65,15 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
page.getByRole('button', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -121,7 +121,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await page.getByRole('button', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -139,7 +139,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await page.getByRole('button', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -162,8 +162,8 @@ test.describe('Document create member', () => {
await createDoc(page, 'user-invitation', browserName, 1);
const header = page.locator('header').first();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'translate Français' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
@@ -178,7 +178,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrateur' }).click();
await page.getByRole('button', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -212,7 +212,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -232,14 +232,14 @@ test.describe('Document create member', () => {
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByRole('button', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
});
await moreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
});
@@ -161,12 +161,12 @@ test.describe('Document list members', () => {
await list.click();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByRole('button', { name: 'Reader' }).click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -215,13 +215,13 @@ test.describe('Document list members', () => {
await expect(mySelfMoreActions).toBeVisible();
await userReaderMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
});
@@ -19,7 +19,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -59,7 +59,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -91,7 +91,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
page.getByRole('button', { name: 'Version history' }),
).toBeDisabled();
});
@@ -120,7 +120,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -50,7 +50,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -60,7 +60,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -100,9 +100,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
@@ -135,7 +133,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
@@ -162,7 +160,7 @@ test.describe('Doc Visibility: Restricted', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
@@ -215,7 +213,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -227,7 +225,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Reading',
})
.click();
@@ -289,7 +287,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -357,7 +355,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -381,10 +379,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
@@ -407,7 +402,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -416,14 +411,6 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Document accessible to any connected person', {
exact: true,
}),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const urlDoc = page.url();
@@ -469,7 +456,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -88,7 +88,6 @@ test.describe('Header mobile', () => {
test.describe('Header: Log out', () => {
test.use({ storageState: { cookies: [], origins: [] } });
// eslint-disable-next-line playwright/expect-expect
test('checks logout button', async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
@@ -12,7 +12,7 @@ test.describe('Home page', () => {
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(
header.getByRole('button', { name: /Language/ }),
header.getByRole('combobox', { name: 'Language' }),
).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
@@ -9,20 +9,19 @@ test.describe('Language', () => {
await expect(page.getByLabel('Logout')).toBeVisible();
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('button').getByText('Français'),
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('button').getByText('Français').click();
await page.getByRole('menuitem', { name: 'Deutsch' }).click();
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
});
@@ -54,11 +53,8 @@ test.describe('Language', () => {
// Switch language to French
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
// Check for French 404 response
await check404Response('Pas trouvé.');
@@ -29,7 +29,7 @@ test.describe('Left panel mobile', () => {
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 languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
+5 -1
View File
@@ -38,9 +38,10 @@ export default defineConfig({
timeout: 120 * 1000,
reuseExistingServer: true,
},
globalSetup: require.resolve('./__tests__/app-impress/auth.setup'),
/* Configure projects for major browsers */
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
@@ -52,6 +53,7 @@ export default defineConfig({
permissions: ['clipboard-read', 'clipboard-write'],
},
},
dependencies: ['setup'],
},
{
name: 'webkit',
@@ -61,6 +63,7 @@ export default defineConfig({
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-webkit.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
@@ -76,6 +79,7 @@ export default defineConfig({
},
},
},
dependencies: ['setup'],
},
],
});
-1
View File
@@ -381,7 +381,6 @@ const config = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: {
-1
View File
@@ -15,7 +15,6 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.1",
"@blocknote/core": "0.23.2",
"@blocknote/mantine": "0.23.2",
"@blocknote/react": "0.23.2",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

@@ -1,4 +1,10 @@
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="32"
height="33"
viewBox="0 0 32 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
fill="#C9191E"

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,3 +1,4 @@
import { FocusScope } from '@react-aria/focus';
import {
PropsWithChildren,
ReactNode,
@@ -6,22 +7,19 @@ import {
useState,
} from 'react';
import { Button, Popover } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { BoxProps } from './Box';
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border: 1px solid #dddddd;
transition: opacity 0.2s ease-in-out;
padding: 1rem;
`;
interface StyledButtonProps {
$css?: BoxProps['$css'];
}
const StyledButton = styled(Button)<StyledButtonProps>`
const StyledButton = styled(Button)`
cursor: pointer;
border: none;
background: none;
@@ -32,12 +30,14 @@ const StyledButton = styled(Button)<StyledButtonProps>`
font-size: 0.938rem;
padding: 0;
text-wrap: nowrap;
${({ $css }) => $css};
&:focus-within {
outline: 2px solid #007bff;
}
`;
export interface DropButtonProps {
button: ReactNode;
buttonCss?: BoxProps['$css'];
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
@@ -45,19 +45,20 @@ export interface DropButtonProps {
export const DropButton = ({
button,
buttonCss,
isOpen = false,
onOpenChange,
children,
label,
}: PropsWithChildren<DropButtonProps>) => {
const { t } = useTranslation();
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
const triggerRef = useRef(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const firstFocusableRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
setIsLocalOpen(isOpen);
}, [isOpen]);
if (isLocalOpen && firstFocusableRef.current) {
firstFocusableRef.current.focus();
}
}, [isLocalOpen]);
const onOpenChangeHandler = (isOpen: boolean) => {
setIsLocalOpen(isOpen);
@@ -69,19 +70,30 @@ export const DropButton = ({
<StyledButton
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
$css={buttonCss}
aria-haspopup="true"
aria-expanded={isLocalOpen}
aria-label={t('Open the document options')}
>
{button}
<span aria-hidden="true">{button}</span>
</StyledButton>
<StyledPopover
triggerRef={triggerRef}
isOpen={isLocalOpen}
onOpenChange={onOpenChangeHandler}
>
{children}
</StyledPopover>
{isLocalOpen && (
<StyledPopover
triggerRef={triggerRef}
isOpen={isLocalOpen}
onOpenChange={onOpenChangeHandler}
>
<FocusScope contain restoreFocus>
{children}
<button
ref={firstFocusableRef}
onClick={() => setIsLocalOpen(false)}
>
{t('Close the modal')}
</button>
</FocusScope>
</StyledPopover>
)}
</>
);
};
@@ -1,4 +1,5 @@
import { PropsWithChildren, useRef, useState } from 'react';
//import { t } from 'i18next';
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -20,7 +21,6 @@ export type DropdownMenuProps = {
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
buttonCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
};
@@ -31,7 +31,6 @@ export const DropdownMenu = ({
disabled = false,
showArrow = false,
arrowCss,
buttonCss,
label,
topMessage,
}: PropsWithChildren<DropdownMenuProps>) => {
@@ -39,7 +38,6 @@ export const DropdownMenu = ({
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const blockButtonRef = useRef<HTMLDivElement>(null);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
@@ -54,17 +52,10 @@ export const DropdownMenu = ({
isOpen={isOpen}
onOpenChange={onOpenChange}
label={label}
buttonCss={buttonCss}
button={
showArrow ? (
<Box
ref={blockButtonRef}
$direction="row"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Box $direction="row" $align="center">
<div>{children}</div>
<Icon
$variation="600"
$css={
@@ -77,17 +68,11 @@ export const DropdownMenu = ({
/>
</Box>
) : (
<Box ref={blockButtonRef} aria-controls="menu">
{children}
</Box>
children
)
}
>
<Box
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
role="menu"
>
<Box $maxWidth="320px">
{topMessage && (
<Text
$variation="700"
@@ -106,7 +91,6 @@ export const DropdownMenu = ({
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
@@ -25,9 +25,7 @@ export interface TextProps extends BoxProps {
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'info'
| 'success'
| 'warning'
@@ -47,7 +47,11 @@ export const QuickSearchInput = ({
$gap={spacing['2xs']}
$padding={{ all: 'base' }}
>
{!loading && <Icon iconName="search" $variation="600" />}
{!loading && (
<span aria-hidden="true">
<Icon iconName="search" $variation="600" />
</span>
)}
{loading && (
<div>
<Loader size="small" />
@@ -203,6 +203,7 @@ input:-webkit-autofill:focus {
.c__select__wrapper .c__select__inner__actions__open:focus {
outline: none;
}
.c__select__wrapper .labelled-box__label.c__offscreen {
@@ -406,10 +407,6 @@ input:-webkit-autofill:focus {
);
}
.c__button--primary-text {
color: var(--c--components--button--primary-text--color);
}
.c__button--primary-text:hover,
.c__button--primary-text:focus-visible {
background-color: var(
@@ -609,3 +606,31 @@ input:-webkit-autofill:focus {
.c__tooltip {
padding: 4px 6px;
}
/**
* lecture ou non des icons
*/
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
[data-icon]:before {
font-family: 'Material Icons';
content: attr(data-icon);
}
button:focus {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
border-radius: var(--c--components--button--border-radius--focus);
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400)
}
@@ -505,9 +505,6 @@
--c--components--button--primary-text--color-hover: var(
--c--theme--colors--primary-text
);
--c--components--button--primary-text--color: var(
--c--theme--colors--primary-800
);
--c--components--button--secondary--background--color-hover: #f6f6f6;
--c--components--button--secondary--background--color-active: #ededed;
--c--components--button--secondary--border--color: var(
@@ -505,7 +505,6 @@ export const tokens = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: { 'color-hover': '#F6F6F6', 'color-active': '#EDEDED' },
@@ -14,11 +14,7 @@ export const ButtonLogin = () => {
if (!authenticated) {
return (
<Button
onClick={() => gotoLogin()}
color="primary-text"
aria-label={t('Login')}
>
<Button onClick={gotoLogin} color="primary-text" aria-label={t('Login')}>
{t('Login')}
</Button>
);
@@ -36,7 +32,7 @@ export const ProConnectButton = () => {
return (
<BoxButton
onClick={() => gotoLogin()}
onClick={gotoLogin}
aria-label={t('Proconnect Login')}
$css={css`
background-color: var(--c--theme--colors--primary-text);
@@ -16,11 +16,8 @@ export const setAuthUrl = () => {
}
};
export const gotoLogin = (withRedirect = true) => {
if (withRedirect) {
setAuthUrl();
}
export const gotoLogin = () => {
setAuthUrl();
window.location.replace(LOGIN_URL);
};
@@ -25,7 +25,6 @@ export const cssEditor = (readonly: boolean) => css`
user-select: none;
position: absolute;
top: -17px;
left: 0px;
padding: 0px 6px;
border-radius: 0px;
white-space: nowrap;
@@ -76,13 +75,13 @@ export const cssEditor = (readonly: boolean) => css`
.bn-block-outer:not(:first-child) {
&:has(h1) {
margin-top: 32px;
padding-top: 32px;
}
&:has(h2) {
margin-top: 24px;
padding-top: 24px;
}
&:has(h3) {
margin-top: 16px;
padding-top: 16px;
}
}
@@ -92,16 +91,9 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: 4px;
}
@media screen and (width <= 768px) {
& .bn-editor {
padding-right: 36px;
}
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
padding-right: 10px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
@@ -27,7 +27,6 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
const { t } = useTranslation();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const { transRole } = useTrans();
@@ -39,7 +38,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$gap={spacings['base']}
aria-label={t('It is the card information about the document.')}
>
{(docIsPublic || docIsAuth) && (
{docIsPublic && (
<Box
aria-label={t('Public document')}
$color={colors['primary-800']}
@@ -58,12 +57,10 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$theme="primary"
$variation="800"
data-testid="public-icon"
iconName={docIsPublic ? 'public' : 'vpn_lock'}
iconName="public"
/>
<Text $theme="primary" $variation="800">
{docIsPublic
? t('Public document')
: t('Document accessible to any connected person')}
{t('Public document')}
</Text>
</Box>
)}
@@ -79,9 +76,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
<Box $gap={spacings['3xs']} $overflow="auto">
<Box $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">
@@ -1,5 +1,3 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import {
Tooltip,
VariantType,
@@ -57,13 +55,16 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const { toast } = useToastProvider();
const { untitledDocument } = useTrans();
const isUntitled = titleDisplay === untitledDocument;
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(data) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
}
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${data.id}`);
@@ -77,7 +78,8 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
// When blank we set to untitled
if (!sanitizedTitle) {
setTitleDisplay('');
sanitizedTitle = untitledDocument;
setTitleDisplay(sanitizedTitle);
}
// If mutation we update
@@ -86,7 +88,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
updateDoc({ id: doc.id, title: sanitizedTitle });
}
},
[doc.id, doc.title, updateDoc],
[doc.id, doc.title, untitledDocument, updateDoc],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -101,35 +103,38 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}, [doc]);
return (
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
$color={colorsTokens()['greyscale-1000']}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
<>
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={isUntitled ? undefined : titleDisplay}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
$color={colorsTokens()['greyscale-1000']}
$margin={{ left: '-2px', right: '10px' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{isUntitled ? '' : titleDisplay}
</Box>
</Tooltip>
</>
);
};
@@ -18,11 +18,7 @@ import {
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
useCopyDocLink,
} from '@/features/docs/doc-management';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -38,7 +34,7 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1 && doc.abilities.accesses_view;
const hasAccesses = doc.nb_accesses > 1;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -54,7 +50,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const options: DropdownMenuOption[] = [
...(isSmallMobile
@@ -71,11 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
@@ -15,7 +15,7 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { Text as PDFText, pdf } from '@react-pdf/renderer';
import { pdf } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -27,8 +27,6 @@ import { Doc } from '@/features/docs/doc-management';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { downloadFile, exportResolveFileUrl } from '../utils';
import { Table } from './blocks/Table';
enum DocDownloadFormat {
PDF = 'pdf',
DOCX = 'docx',
@@ -115,8 +113,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
? 1.5
: 1.17;
return (
<PDFText
key={block.id}
<Text
style={{
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
fontWeight: 700,
@@ -125,7 +122,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
}}
>
{exporter.transformInlineContent(block.content)}
</PDFText>
</Text>
);
},
paragraph: (block, exporter) => {
@@ -149,14 +146,11 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
}
}
return (
<PDFText key={block.id}>
<Text key={block.id}>
{exporter.transformInlineContent(block.content)}
</PDFText>
</Text>
);
},
table: (block, transformer) => {
return <Table data={block.content} transformer={transformer} />;
},
},
},
{
@@ -1,76 +0,0 @@
import { TD, TH, TR, Table as TablePDF } from '@ag-media/react-pdf-table';
import {
DefaultBlockSchema,
Exporter,
InlineContentSchema,
StyleSchema,
TableContent,
} from '@blocknote/core';
import { View } from '@react-pdf/renderer';
import { ReactNode } from 'react';
export const Table = (props: {
data: TableContent<InlineContentSchema>;
transformer: Exporter<
DefaultBlockSchema,
InlineContentSchema,
StyleSchema,
unknown,
unknown,
unknown,
unknown
>;
}) => {
return (
<TablePDF>
{props.data.rows.map((row, index) => {
if (index === 0) {
return (
<TH key={index}>
{row.cells.map((cell, index) => {
// Make empty cells are rendered.
if (cell.length === 0) {
cell.push({
styles: {},
text: ' ',
type: 'text',
});
}
return (
<TD key={index}>
{props.transformer.transformInlineContent(cell)}
</TD>
);
})}
</TH>
);
}
return (
<TR key={index}>
{row.cells.map((cell, index) => {
// Make empty cells are rendered.
if (cell.length === 0) {
cell.push({
styles: {},
text: ' ',
type: 'text',
});
}
return (
<TD key={index}>
<View>
{
props.transformer.transformInlineContent(
cell,
) as ReactNode
}
</View>
</TD>
);
})}
</TR>
);
})}
</TablePDF>
);
};
@@ -6,9 +6,14 @@ import { Doc } from '../types';
import { KEY_LIST_DOC } from './useDocs';
export const createDoc = async (): Promise<Doc> => {
export type CreateDocParam = Pick<Doc, 'title'>;
export const createDoc = async ({ title }: CreateDocParam): Promise<Doc> => {
const response = await fetchAPI(`documents/`, {
method: 'POST',
body: JSON.stringify({
title,
}),
});
if (!response.ok) {
@@ -24,7 +29,7 @@ interface CreateDocProps {
export function useCreateDoc({ onSuccess }: CreateDocProps) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError>({
return useMutation<Doc, APIError, CreateDocParam>({
mutationFn: createDoc,
onSuccess: (data) => {
void queryClient.resetQueries({
@@ -71,15 +71,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
</Button>
</>
}
size={ModalSize.SMALL}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
<Text $size="h6" as="h6" $margin={{ all: '0' }} $align="flex-start">
{t('Delete a doc')}
</Text>
}
@@ -51,7 +51,7 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
return {
groupName: docs.length > 0 ? t('Select a document') : '',
elements: search ? docs : [],
emptyString: t('No document found'),
//emptyString: t('No document found'),
endActions: hasNextPage
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
: [],
@@ -96,6 +96,20 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
renderElement={(doc) => <DocSearchItem doc={doc} />}
/>
)}
{/* Message accessible pour les résultats vides */}
{search && docsData.elements.length === 0 && (
<p
role="alert"
aria-live="polite"
style={{
textAlign: 'center',
marginTop: '1rem',
color: '#666',
}}
>
{t('No document found')}
</p>
)}
</Box>
</QuickSearch>
</Box>
@@ -33,7 +33,9 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
>
<Button
fullWidth={false}
onClick={copyDocLink}
onClick={() => {
copyDocLink();
}}
color="tertiary"
icon={<span className="material-icons">add_link</span>}
>
@@ -100,21 +100,17 @@ export const ModalConfirmationVersion = ({
</Button>
</>
}
size={ModalSize.SMALL}
size={ModalSize.MEDIUM}
title={
<Text $size="h6" $align="flex-start" $variation="1000">
<Text $size="h6" $align="flex-start">
{t('Warning')}
</Text>
}
>
<Box aria-label={t('Modal confirmation to restore the version')}>
<Box>
<Text $variation="600">
{t('Your current document will revert to this version.')}
</Text>
<Text $variation="600">
{t('If a member is editing, his works can be lost.')}
</Text>
<Text>{t('Your current document will revert to this version.')}</Text>
<Text>{t('If a member is editing, his works can be lost.')}</Text>
</Box>
</Box>
</Modal>
@@ -36,9 +36,8 @@ export const ModalSelectVersion = ({
const { t } = useTranslation();
const [selectedVersionId, setSelectedVersionId] =
useState<Versions['version_id']>();
const canRestore = doc.abilities.partial_update;
const restoreModal = useModal();
const restoreModal = useModal();
return (
<>
<Modal
@@ -128,23 +127,21 @@ export const ModalSelectVersion = ({
selectedVersionId={selectedVersionId}
/>
</Box>
{canRestore && (
<Box
$padding="xs"
$css={css`
border-top: 1px solid var(--c--theme--colors--greyscale-200);
`}
<Box
$padding="xs"
$css={css`
border-top: 1px solid var(--c--theme--colors--greyscale-200);
`}
>
<Button
fullWidth
disabled={!selectedVersionId}
onClick={restoreModal.open}
color="primary"
>
<Button
fullWidth
disabled={!selectedVersionId}
onClick={restoreModal.open}
color="primary"
>
{t('Restore')}
</Button>
</Box>
)}
{t('Restore')}
</Button>
</Box>
</Box>
</Box>
</Modal>
@@ -61,8 +61,12 @@ export const DocsGrid = ({
$position="relative"
$width="100%"
$maxWidth="960px"
$maxHeight="calc(100vh - 52px - 2rem)"
$maxHeight="calc(100vh - 52px - 1rem)"
$align="center"
$css={css`
overflow-x: hidden;
overflow-y: auto;
`}
>
<DocsGridLoader isLoading={isRefetching || loading} />
<Card
@@ -71,7 +75,8 @@ export const DocsGrid = ({
$height="100%"
$width="100%"
$css={css`
${!isDesktop ? 'border: none;' : ''}
overflow-x: hidden;
overflow-y: auto;
`}
$padding={{
top: 'base',
@@ -96,7 +101,7 @@ export const DocsGrid = ({
</Box>
)}
{hasDocs && (
<Box $gap="6px" $overflow="auto">
<Box $gap="6px">
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
@@ -117,30 +122,28 @@ export const DocsGrid = ({
)}
</Box>
{/* Body */}
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button
onClick={() => void fetchNextPage()}
color="primary-text"
>
{t('More docs')}
</Button>
)}
</InView>
)}
</Box>
)}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button onClick={() => void fetchNextPage()} color="primary-text">
{t('More docs')}
</Button>
)}
</InView>
)}
</Card>
</Box>
);
@@ -49,7 +49,6 @@ export const DocsGridActions = ({
callback: () => {
openShareModal?.();
},
testId: `docs-grid-actions-share-${doc.id}`,
},
@@ -70,6 +69,7 @@ export const DocsGridActions = ({
iconName="more_horiz"
$theme="primary"
$variation="600"
aria-hidden="true"
/>
</DropdownMenu>
@@ -54,7 +54,9 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$css={css`
flex: ${flexLeft};
align-items: center;
min-width: 0;
&:focus {
outline: 2px solidrgb(33, 34, 82);
}
`}
href={`/docs/${doc.id}`}
>
@@ -65,7 +67,6 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$gap={spacings.xs}
$flex={flexLeft}
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{showAccesses && (
@@ -81,7 +82,11 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
>
<Tooltip
content={
<Text $textAlign="center" $variation="000">
<Text
id={`tooltip-access-${doc.id}`}
$textAlign="center"
$variation="000"
>
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
@@ -89,12 +94,17 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
}
placement="top"
>
<div>
<div
role="button"
tabIndex={0}
aria-labelledby={`tooltip-access-${doc.id}`}
>
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
aria-hidden="true"
/>
</div>
</Tooltip>
@@ -9,6 +9,7 @@ type Props = {
doc: Doc;
handleClick: () => void;
};
export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
const { t } = useTranslation();
const sharedCount = doc.nb_accesses;
@@ -18,11 +19,15 @@ export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
return <Box $minWidth="50px">&nbsp;</Box>;
}
const tooltipContent = t('Shared with {{count}} users', {
count: sharedCount,
});
return (
<Tooltip
content={
<Text $textAlign="center" $variation="000">
{t('Shared with {{count}} users', { count: sharedCount })}
<Text $variation="000" $textAlign="center">
{tooltipContent}
</Text>
}
placement="top"
@@ -36,8 +41,14 @@ export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
}}
color="tertiary"
size="nano"
icon={<Icon $variation="800" $theme="primary" iconName="group" />}
aria-label={tooltipContent} // Lecture directe pour les lecteurs d'écran
>
<Icon
$variation="800"
$theme="primary"
iconName="group"
aria-hidden="true" // Empêche la lecture de l'icône
/>
{sharedCount}
</Button>
</Tooltip>
@@ -1,7 +1,8 @@
import { Loader } from '@openfun/cunningham-react';
import { createGlobalStyle } from 'styled-components';
import { createGlobalStyle, css } from 'styled-components';
import { Box } from '@/components';
import { HEADER_HEIGHT } from '@/features/header/conf';
const DocsGridLoaderStyle = createGlobalStyle`
body, main {
@@ -26,11 +27,15 @@ export const DocsGridLoader = ({ isLoading }: DocsGridLoaderProps) => {
data-testid="grid-loader"
$align="center"
$justify="center"
$height="100%"
$height="calc(100vh - 50px)"
$width="100%"
$background="rgba(255, 255, 255, 0.5)"
$maxWidth="960px"
$background="rgba(255, 255, 255, 0.3)"
$zIndex={998}
$position="absolute"
$position="fixed"
$css={css`
top: ${HEADER_HEIGHT}px;
`}
>
<Loader />
</Box>
@@ -4,7 +4,7 @@ import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, useTrans } from '@/features/docs/doc-management';
import { Doc } from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import PinnedDocumentIcon from '../assets/pinned-document.svg';
@@ -35,10 +35,9 @@ export const SimpleDocItem = ({
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const spacings = spacingsTokens();
const { untitledDocument } = useTrans();
return (
<Box $direction="row" $gap={spacings.sm} $overflow="auto">
<Box $direction="row" $gap={spacings.sm}>
<Box
$direction="row"
$align="center"
@@ -48,12 +47,12 @@ export const SimpleDocItem = ({
`}
>
{isPinned ? (
<PinnedDocumentIcon aria-label={t('Pin document icon')} />
<PinnedDocumentIcon aria-label={t('Pinned document.')} />
) : (
<SimpleFileIcon aria-label={t('Simple document icon')} />
<SimpleFileIcon aria-label="" />
)}
</Box>
<Box $justify="center" $overflow="auto">
<Box $justify="center">
<Text
aria-describedby="doc-title"
aria-label={doc.title}
@@ -62,7 +61,7 @@ export const SimpleDocItem = ({
$weight="500"
$css={ItemTextCss}
>
{doc.title || untitledDocument}
{doc.title}
</Text>
{(!isDesktop || showAccesses) && (
<Box
@@ -50,7 +50,7 @@ export const Header = () => {
$height="fit-content"
$margin={{ top: 'auto' }}
>
<IconDocs aria-label={t('Docs Logo')} width={32} />
<IconDocs aria-label={t('Docs Logo')} width={25} />
<Title />
</Box>
</StyledLink>
@@ -55,7 +55,7 @@ export default function HomeBanner() {
$textAlign="center"
$margin="none"
$css={css`
line-height: ${!isMobile ? '56px' : '45px'};
line-height: 56px;
`}
>
{t('Collaborative writing, Simplified.')}
@@ -74,7 +74,7 @@ export default function HomeBanner() {
<ProConnectButton />
) : (
<Button
onClick={() => gotoLogin()}
onClick={gotoLogin}
icon={
<Text $isMaterialIcon $color="white">
bolt
@@ -116,8 +116,8 @@ export const HomeSection = ({
`}
$variation="1000"
$weight="bold"
$size={!isSmallDevice ? 'xs-alt' : isSmallMobile ? 'h6' : 'h4'}
$textAlign="left"
$size={!isSmallDevice ? 'xs-alt' : 'h4'}
$textAlign={isSmallMobile ? 'center' : 'left'}
$margin="none"
>
{title}
@@ -1,61 +1,85 @@
import { Select } from '@openfun/cunningham-react';
import { Settings } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import styled from 'styled-components';
import { DropdownMenu, Text } from '@/components/';
import { Box, Text } from '@/components/';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
flex-shrink: 0;
width: auto;
.c__select__wrapper {
min-height: 2rem;
height: auto;
border-color: transparent;
padding: 0 0.15rem 0 0.45rem;
border-radius: 1px;
.labelled-box .labelled-box__children {
padding-right: 2rem;
.c_select__render .typo-text {
${({ $isSmall }) => $isSmall && `display: none;`}
}
}
&:hover {
box-shadow: none !important;
}
}
`;
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { preload: languages } = i18n.options;
const language = i18n.language;
Settings.defaultLocale = language;
Settings.defaultLocale = i18n.language;
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
label: LANGUAGES_ALLOWED[lang],
isSelected: language === lang,
callback: () => {
i18n.changeLanguage(lang).catch((err) => {
console.error('Error changing language', err);
});
},
value: lang,
label: lang,
render: () => (
<Box
className="c_select__render"
$direction="row"
$gap="0.7rem"
$align="center"
>
<Text
$isMaterialIcon
$size="1rem"
$theme="primary"
$weight="bold"
$variation="800"
aria-hidden="true"
>
translate
</Text>
<Text $theme="primary" $weight="500" $variation="800">
{LANGUAGES_ALLOWED[lang]}
</Text>
</Box>
),
}));
}, [i18n, language, languages]);
}, [languages]);
return (
<DropdownMenu
<SelectStyled
label={t('Language')}
showLabelWhenSelected={false}
clearable={false}
hideLabel
defaultValue={i18n.language}
className="c_select__no_bg"
options={optionsPicker}
showArrow
buttonCss={css`
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
}
border-radius: 4px;
padding: 0.5rem 0.6rem;
& > div {
gap: 0.2rem;
display: flex;
}
& .material-icons {
color: var(--c--components--button--primary-text--color) !important;
}
`}
>
<Text
$theme="primary"
aria-label={t('Language')}
$direction="row"
$gap="0.5rem"
>
<Text $isMaterialIcon $color="inherit" $size="xl">
translate
</Text>
{LANGUAGES_ALLOWED[language]}
</Text>
</DropdownMenu>
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
console.error('Error changing language', err);
});
}}
/>
);
};
@@ -52,6 +52,7 @@ export const LeftPanelTargetFilters = () => {
return (
<Box
role="tablist"
$justify="center"
$padding={{ horizontal: 'sm' }}
$gap={spacing['2xs']}
@@ -61,7 +62,7 @@ export const LeftPanelTargetFilters = () => {
return (
<BoxButton
aria-label={query.label}
role="tab"
key={query.label}
onClick={() => onSelectQuery(query.targetQuery)}
$direction="row"
@@ -85,6 +86,7 @@ export const LeftPanelTargetFilters = () => {
<Icon
$variation={isActive ? '1000' : '700'}
iconName={query.icon}
aria-hidden="true"
/>
<Text $variation={isActive ? '1000' : '700'} $size="sm">
{query.label}
@@ -39,7 +39,7 @@ export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => {
`}
key={doc.id}
>
<StyledLink href={`/docs/${doc.id}`} $css="overflow: auto;">
<StyledLink href={`/docs/${doc.id}`}>
<SimpleDocItem showAccesses doc={doc} />
</StyledLink>
<div className="pinned-actions">
@@ -31,7 +31,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
};
const createNewDoc = () => {
createDoc();
createDoc({ title: t('Untitled document') });
};
return (
@@ -50,8 +50,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
onClick={goToHome}
size="medium"
color="tertiary-text"
aria-label={t('Back to home page')}
icon={
<Icon $variation="800" $theme="primary" iconName="house" />
<span aria-hidden="true">
<Icon $variation="800" $theme="primary" iconName="house" />
</span>
}
/>
{authenticated && (
@@ -59,8 +62,15 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
onClick={searchModal.open}
size="medium"
color="tertiary-text"
aria-label={t('Search')}
icon={
<Icon $variation="800" $theme="primary" iconName="search" />
<span aria-hidden="true">
<Icon
$variation="800"
$theme="primary"
iconName="search"
/>
</span>
}
/>
)}
@@ -7,6 +7,8 @@
"AI seems busy! Please try again.": "KI scheint beschäftigt! Bitte versuchen Sie es erneut.",
"Accessibility": "Barrierefreiheit",
"Accessibility statement": "Erklärung zur Barrierefreiheit",
"Accessible to anyone": "Für jeden zugänglich",
"Accessible to authenticated users": "Für authentifizierte Benutzer zugänglich",
"Add": "Hinzufügen",
"Address:": "Anschrift:",
"All docs": "Alle Dokumente",
@@ -92,6 +94,7 @@
"Pending invitations": "Ausstehende Einladungen",
"Personal data and cookies": "Personenbezogene Daten und Cookies",
"Pin": "Anheften",
"Pinned document.": "Angeheftetes Dokument",
"Pinned documents": "Angepinnte Dokumente",
"Private": "Privat",
"Public": "Öffentlich",
@@ -118,6 +121,7 @@
"Share with {{count}} users_many": "Teilen mit {{count}} Benutzern",
"Share with {{count}} users_one": "Teilen mit {{count}} Benutzern",
"Share with {{count}} users_other": "Teilen mit {{count}} Benutzern",
"Shared with {{count}} users": "Mit {{count}} Benutzern geteilt",
"Shared with me": "Mit mir geteilt",
"Something bad happens, please retry.": "Etwas ist schiefgelaufen, bitte versuchen Sie es erneut.",
"Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Interministerielle Digitaldirektorin (DINUM).",
@@ -133,6 +137,7 @@
"This site places a small text file (a \"cookie\") on your computer when you visit it.": "Diese Website platziert beim Besuch auf Ihrem Computer eine kleine Textdatei (ein \"Cookie\").",
"This will protect your privacy, but will also prevent the owner from learning from your actions and creating a better experience for you and other users.": "Dies schützt Ihre Privatsphäre, verhindert jedoch auch, dass der Eigentümer aus Ihren Aktionen lernt und eine bessere Erfahrung für Sie und andere Benutzer schafft.",
"Too many requests. Please wait 60 seconds.": "Zu viele Anfragen. Bitte warten Sie 60 Sekunden.",
"Translate": "Übersetzen",
"Type a name or email": "Geben Sie einen Namen oder eine E-Mail-Adresse ein",
"Type the name of a document": "Geben Sie den Namen eines Dokuments ein",
"Unless otherwise stated, all content on this site is under": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
@@ -255,7 +260,7 @@
"It's true, you didn't have to click on a block that covers half the page to say you agree to the placement of cookies — even if you don't know what it means!": "C'est vrai, vous n'avez pas à cliquer sur un bloc qui couvre la moitié de la page pour dire que vous acceptez le placement de cookies — même si vous ne savez pas ce que cela signifie !",
"Language": "Langue",
"Last update: {{update}}": "Dernière mise à jour : {{update}}",
"Legal Notice": "Mentions Legales",
"Legal Notice": "Mentions Légales",
"Legal notice": "Mention légale",
"Link Copied !": "Lien copié !",
"Link parameters": "Paramètres du lien",
@@ -289,6 +294,7 @@
"Personal data and cookies": "Données personnelles et cookies",
"Pin": "Épingler",
"Pin document icon": "Icône épingler un document",
"Pinned document.": "Document épinglé",
"Pinned documents": "Documents épinglés",
"Private": "Privé",
"ProConnect Image": "Image ProConnect",
@@ -317,6 +323,7 @@
"Share with {{count}} users_many": "Partager avec {{count}} utilisateurs",
"Share with {{count}} users_one": "Partager avec {{count}} utilisateur",
"Share with {{count}} users_other": "Partager avec {{count}} utilisateurs",
"Shared with {{count}} users": "Partagé avec {{count}} utilisateurs",
"Shared with me": "Partagés avec moi",
"Shared with {{count}} users_many": "Partager avec {{count}} utilisateurs",
"Shared with {{count}} users_one": "Partager avec {{count}} utilisateur",
@@ -342,6 +349,7 @@
"This will protect your privacy, but will also prevent the owner from learning from your actions and creating a better experience for you and other users.": "Cela protégera votre vie privée, mais empêchera également le propriétaire d'apprendre de vos actions et de créer une meilleure expérience pour vous et les autres utilisateurs.",
"To facilitate the circulation of documents, Docs allows you to export your content to the most common formats: PDF, Word or OpenDocument.": "Pour faciliter la circulation des documents, Docs permet d'exporter vos contenus vers les formats les plus courants : PDF, Word ou OpenDocument.",
"Too many requests. Please wait 60 seconds.": "Trop de demandes. Veuillez patienter 60 secondes.",
"Translate": "Traduire",
"Type a name or email": "Tapez un nom ou un email",
"Type the name of a document": "Tapez le nom d'un document",
"Unless otherwise stated, all content on this site is under": "Sauf mention contraire, tout le contenu de ce site est sous",
@@ -19,8 +19,8 @@ export function MainLayout({
}: PropsWithChildren<MainLayoutProps>) {
const { isDesktop } = useResponsiveStore();
const { colorsTokens } = useCunninghamTheme();
const colors = colorsTokens();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
return (
<div>
@@ -39,10 +39,10 @@ export function MainLayout({
$width="100%"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$padding={{
all: isDesktop ? 'base' : '0',
all: isDesktop ? 'base' : '2xs',
}}
$background={
currentBackgroundColor === 'white'
backgroundColor === 'white'
? colors['greyscale-000']
: colors['greyscale-050']
}
@@ -6,27 +6,17 @@ import { HEADER_HEIGHT, Header } from '@/features/header';
import { LeftPanel } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
interface PageLayoutProps {
withFooter?: boolean;
}
export function PageLayout({
children,
withFooter = true,
}: PropsWithChildren<PageLayoutProps>) {
export function PageLayout({ children }: PropsWithChildren) {
const { isDesktop } = useResponsiveStore();
return (
<Box
$minHeight={`calc(100vh - ${HEADER_HEIGHT}px)`}
$margin={{ top: `${HEADER_HEIGHT}px` }}
>
<Box $minHeight="100vh" $margin={{ top: `${HEADER_HEIGHT}px` }}>
<Header />
<Box as="main" $width="100%" $css="flex-grow:1;">
{!isDesktop && <LeftPanel />}
{children}
</Box>
{withFooter && <Footer />}
<Footer />
</Box>
);
}
@@ -1,57 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { ReactElement, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import img401 from '@/assets/icons/icon-401.png';
import { Box, Text } from '@/components';
import { gotoLogin, useAuth } from '@/features/auth';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const { authenticated } = useAuth();
const { replace } = useRouter();
useEffect(() => {
if (authenticated) {
void replace(`/`);
}
}, [authenticated, replace]);
return (
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
src={img401}
alt={t('Image 401')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t('Log in to access the document.')}
</Text>
<Button onClick={() => gotoLogin(false)} aria-label={t('Login')}>
{t('Login')}
</Button>
</Box>
</Box>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;
@@ -1,60 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import img403 from '@/assets/icons/icon-403.png';
import { Box, StyledLink, Text } from '@/components';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
width: fit-content;
`;
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<Box
$align="center"
$margin="auto"
$gap="1rem"
$padding={{ bottom: '2rem' }}
>
<Image
src={img403}
alt={t('Image 403')}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="primary">
{t('You do not have permission to view this document.')}
</Text>
<StyledLink href="/">
<StyledButton
icon={
<Text $isMaterialIcon $color="white">
house
</Text>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
</Box>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;
+3 -11
View File
@@ -5,7 +5,7 @@ import styled from 'styled-components';
import Icon404 from '@/assets/icons/icon-404.svg';
import { Box, StyledLink, Text } from '@/components';
import { PageLayout } from '@/layouts';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
@@ -33,15 +33,7 @@ const Page: NextPageWithLayout = () => {
<Box $margin={{ top: 'large' }}>
<StyledLink href="/">
<StyledButton
icon={
<Text $isMaterialIcon $color="white">
house
</Text>
}
>
{t('Home')}
</StyledButton>
<StyledButton>{t('Back to home page')}</StyledButton>
</StyledLink>
</Box>
</Box>
@@ -49,7 +41,7 @@ const Page: NextPageWithLayout = () => {
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
return <MainLayout>{page}</MainLayout>;
};
export default Page;
@@ -5,7 +5,7 @@ import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Box, Text, TextErrors } from '@/components';
import { setAuthUrl } from '@/features/auth';
import { gotoLogin } from '@/features/auth';
import { DocEditor } from '@/features/docs/doc-editor';
import {
Doc,
@@ -99,19 +99,13 @@ const DocPage = ({ id }: DocProps) => {
}, [addTask, doc?.id, queryClient]);
if (isError && error) {
if (error.status === 403) {
void replace(`/403`);
return null;
}
if (error.status === 404) {
void replace(`/404`);
return null;
}
if (error.status === 401) {
setAuthUrl();
void replace(`/401`);
gotoLogin();
return null;
}
@@ -31,15 +31,7 @@ const Page: NextPageWithLayout = () => {
<Box $margin={{ top: 'large' }}>
<StyledLink href="/">
<StyledButton
icon={
<Text $isMaterialIcon $color="white">
house
</Text>
}
>
{t('Home')}
</StyledButton>
<StyledButton>{t('Back to home page')}</StyledButton>
</StyledLink>
</Box>
</Box>
@@ -1,9 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// This file is used to declare the types for the @ag-media/react-pdf-table package
// This library does not export its types in the "exports" field in the package.json.
declare module '@ag-media/react-pdf-table' {
export const TD: React.FC<any>;
export const TH: React.FC<any>;
export const TR: React.FC<any>;
export const Table: React.FC<any>;
}
-5
View File
@@ -7,11 +7,6 @@
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.1.tgz#2447a230bfe072c1659e6815129c03cf170710e3"
integrity sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==
"@ag-media/react-pdf-table@2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@ag-media/react-pdf-table/-/react-pdf-table-2.0.1.tgz#609e51992faed54bcf379a376442997c6bac53cc"
integrity sha512-UMNdGYAfuI6L1wLRziYmwcp/8I2JgbwX+PY7bHXGb2+P6MwgFJH8W71qZO1bxfxrmVUTP8YblQwl1PkXG2m6Rg==
"@ampproject/remapping@^2.2.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4"
-2
View File
@@ -170,8 +170,6 @@ ingressMedia:
nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header Content-Security-Policy "default-src 'none'" always;
## @param serviceMedia.host
## @param serviceMedia.port
+1977
View File
File diff suppressed because it is too large Load Diff