mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-08 08:02:15 +02:00
Compare commits
42 Commits
v3.4.1
...
fix/1136-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
012b06f3b1 | ||
|
|
0c8bf4013a | ||
|
|
f5f9d8a877 | ||
|
|
e7709badbb | ||
|
|
2a7c0ef800 | ||
|
|
155e7dfe22 | ||
|
|
afa48b6675 | ||
|
|
f12d30cffa | ||
|
|
30dfea744a | ||
|
|
2cbe363a5f | ||
|
|
7f450e8aa8 | ||
|
|
7021c0f849 | ||
|
|
e8d18d85e9 | ||
|
|
67a195f89c | ||
|
|
09b6fef63f | ||
|
|
11d0bafc94 | ||
|
|
1ae831cabd | ||
|
|
f1c2219270 | ||
|
|
8c9380c356 | ||
|
|
3ff6d2541c | ||
|
|
34ce276222 | ||
|
|
04273c3b3e | ||
|
|
0b301b95c8 | ||
|
|
228bdf733e | ||
|
|
bbf48f088f | ||
|
|
b28ff8f632 | ||
|
|
14b7cdf561 | ||
|
|
c534fed196 | ||
|
|
c1a740b7d4 | ||
|
|
83f2b3886e | ||
|
|
966e514c5a | ||
|
|
ef6d6c6a59 | ||
|
|
e79f3281b1 | ||
|
|
b78550b513 | ||
|
|
5a23c97681 | ||
|
|
040eddbe6b | ||
|
|
f2e54308d2 | ||
|
|
cd6e0ef9e1 | ||
|
|
02acc7233f | ||
|
|
1c71e830a2 | ||
|
|
ac0c16a44a | ||
|
|
ca09f9a158 |
6
.github/ISSUE_TEMPLATE.md
vendored
6
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +0,0 @@
|
||||
<!---
|
||||
Thanks for filing an issue 😄 ! Before you submit, please read the following:
|
||||
|
||||
Check the other issue templates if you are trying to submit a bug report, feature request, or question
|
||||
Search open/closed issues before submitting since someone might have asked the same thing before!
|
||||
-->
|
||||
4
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -6,6 +6,10 @@ labels: ["bug", "triage"]
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Before you file your issue**
|
||||
- Check the other [issues](https://github.com/suitenumerique/docs/issues) before filing your own
|
||||
- If your report is related to the ([BlockNote](https://github.com/TypeCellOS/BlockNote)) text editor, [file it on their repo](https://github.com/TypeCellOS/BlockNote/issues). If you're not sure whether your issue is with BlockNote or Docs, file it on our repo: if we support it, we'll backport it upstream ourselves 😊, otherwise we'll ask you to do so.
|
||||
|
||||
**Problematic behavior**
|
||||
A clear and concise description of the behavior.
|
||||
|
||||
|
||||
4
.github/workflows/impress-frontend.yml
vendored
4
.github/workflows/impress-frontend.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,8 +40,7 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
env.d/development/*
|
||||
!env.d/development/*.dist
|
||||
env.d/development/*.local
|
||||
env.d/terraform
|
||||
|
||||
# npm
|
||||
|
||||
133
CHANGELOG.md
133
CHANGELOG.md
@@ -8,15 +8,54 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) Service Account support for K8s Resources in Helm Charts #778
|
||||
- ✨(backend) allow masking documents from the list view #1171
|
||||
- ✨(frontend) subdocs can manage link reach #1190
|
||||
- ✨(frontend) add duplicate action to doc tree #1175
|
||||
- ✨(frontend) Interlinking doc #904
|
||||
- ✨(frontend) add multi columns support for editor #1219
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) search on all docs if no children #1184
|
||||
- ♻️(frontend) redirect to doc after duplicate #1175
|
||||
- 🔧(project) change env.d system by using local files #1200
|
||||
- ⚡️(frontend) improve tree stability #1207
|
||||
- ⚡️(frontend) improve accessibility
|
||||
- #1232
|
||||
- #1251
|
||||
- 🛂(frontend) block drag n drop when not desktop #1239
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(service-worker) Fix useOffline Maximum update depth exceeded #1196
|
||||
- 🐛(frontend) fix empty left panel after deleting root doc #1197
|
||||
- 🐛(helm) charts generate invalid YAML for collaboration API / WS #890
|
||||
- 🐛(frontend) 401 redirection overridden #1214
|
||||
- 🐛(frontend) include root parent in search #1243
|
||||
|
||||
## [3.4.2] - 2025-07-18
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(docker) Optimize Dockerfile to use apk with --no-cache #743
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
|
||||
|
||||
## [3.4.1] - 2025-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🌐(frontend) keep simple tag during export #1154
|
||||
- 🐛(back) manage can-edit endpoint without created room
|
||||
- 🐛(back) manage can-edit endpoint without created room
|
||||
in the ws #1152
|
||||
- 🐛(frontend) fix action buttons not clickable #1162
|
||||
- 🐛(frontend) fix crash share modal on grid options #1174
|
||||
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
|
||||
|
||||
## [3.4.0] - 2025-07-09
|
||||
|
||||
@@ -27,16 +66,16 @@ and this project adheres to
|
||||
- ✨Ask for access #1081
|
||||
- ✨(frontend) add customization for translations #857
|
||||
- ✨(backend) add ancestors links definitions to document abilities #846
|
||||
- ✨(backend) include ancestors accesses on document accesses list view # 846
|
||||
- ✨(backend) include ancestors accesses on document accesses list view #846
|
||||
- ✨(backend) add ancestors links reach and role to document API #846
|
||||
- 📝(project) add troubleshoot doc #1066
|
||||
- 📝(project) add system-requirement doc #1066
|
||||
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
|
||||
- ✨(backend) allow to disable checking unsafe mimetype on
|
||||
- 🔧(frontend) configure x-frame-options to DENY in nginx conf #1084
|
||||
- ✨(backend) allow to disable checking unsafe mimetype on
|
||||
attachment upload #1099
|
||||
- ✨(doc) add documentation to install with compose #855
|
||||
- ✨ Give priority to users connected to collaboration server
|
||||
(aka no websocket feature) #1093
|
||||
- ✨ Give priority to users connected to collaboration server
|
||||
(aka no websocket feature) #1093
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -62,7 +101,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove Beta from logo #1095
|
||||
|
||||
|
||||
## [3.3.0] - 2025-05-06
|
||||
|
||||
### Added
|
||||
@@ -88,13 +126,13 @@ and this project adheres to
|
||||
- ⬆️(docker) upgrade node images to alpine 3.21 #973
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(y-provider) increase JSON size limits for transcription conversion #989
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(back) remove footer endpoint #948
|
||||
|
||||
|
||||
## [3.2.1] - 2025-05-06
|
||||
|
||||
## Fixed
|
||||
@@ -102,7 +140,6 @@ and this project adheres to
|
||||
- 🐛(frontend) fix list copy paste #943
|
||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||
|
||||
|
||||
## [3.2.0] - 2025-05-05
|
||||
|
||||
## Added
|
||||
@@ -113,7 +150,7 @@ and this project adheres to
|
||||
- ✨(settings) Allow configuring PKCE for the SSO #886
|
||||
- 🌐(i18n) activate chinese and spanish languages #884
|
||||
- 🔧(backend) allow overwriting the data directory #893
|
||||
- ➕(backend) add `django-lasuite` dependency #839
|
||||
- ➕(backend) add `django-lasuite` dependency #839
|
||||
- ✨(frontend) advanced table features #908
|
||||
|
||||
## Changed
|
||||
@@ -130,7 +167,6 @@ and this project adheres to
|
||||
- 🐛(backend) race condition create doc #633
|
||||
- 🐛(frontend) fix breaklines in custom blocks #908
|
||||
|
||||
|
||||
## [3.1.0] - 2025-04-07
|
||||
|
||||
## Added
|
||||
@@ -165,7 +201,6 @@ and this project adheres to
|
||||
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
||||
- 🔒️(back) restrict access to document accesses #801
|
||||
|
||||
|
||||
## [2.6.0] - 2025-03-21
|
||||
|
||||
## Added
|
||||
@@ -184,7 +219,6 @@ and this project adheres to
|
||||
- 🔒️(back) throttle user list endpoint #636
|
||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||
|
||||
|
||||
## [2.5.0] - 2025-03-18
|
||||
|
||||
## Added
|
||||
@@ -207,15 +241,14 @@ and this project adheres to
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) SVG export #706
|
||||
- 🐛(frontend) remove scroll listener table content #688
|
||||
- 🐛(frontend) remove scroll listener table content #688
|
||||
- 🔒️(back) restrict access to favorite_list endpoint #690
|
||||
- 🐛(backend) refactor to fix filtering on children
|
||||
and descendants views #695
|
||||
- 🐛(backend) refactor to fix filtering on children
|
||||
and descendants views #695
|
||||
- 🐛(action) fix notify-argocd workflow #713
|
||||
- 🚨(helm) fix helmfile lint #736
|
||||
- 🚚(frontend) redirect to 401 page when 401 error #759
|
||||
|
||||
|
||||
## [2.4.0] - 2025-03-06
|
||||
|
||||
## Added
|
||||
@@ -230,7 +263,6 @@ and this project adheres to
|
||||
|
||||
- 🐛(frontend) fix collaboration error #684
|
||||
|
||||
|
||||
## [2.3.0] - 2025-03-03
|
||||
|
||||
## Added
|
||||
@@ -646,35 +678,36 @@ 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/v3.4.1...main
|
||||
[v3.4.1]: https://github.com/numerique-gouv/impress/releases/v3.4.1
|
||||
[v3.4.0]: https://github.com/numerique-gouv/impress/releases/v3.4.0
|
||||
[v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0
|
||||
[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1
|
||||
[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0
|
||||
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
|
||||
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
|
||||
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
||||
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
||||
[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
|
||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
||||
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
||||
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
|
||||
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
|
||||
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
|
||||
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
|
||||
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
|
||||
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
|
||||
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
|
||||
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
|
||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.4.2...main
|
||||
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
|
||||
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
|
||||
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
|
||||
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
|
||||
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
|
||||
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
|
||||
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
|
||||
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
|
||||
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
|
||||
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
|
||||
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
|
||||
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
|
||||
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
|
||||
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
|
||||
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
|
||||
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
|
||||
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
|
||||
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
|
||||
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
|
||||
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
|
||||
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
|
||||
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
|
||||
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
|
||||
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
|
||||
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
|
||||
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
|
||||
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
|
||||
|
||||
@@ -7,8 +7,7 @@ FROM python:3.13.3-alpine AS base
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
RUN apk update && apk upgrade --no-cache
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
@@ -45,7 +44,7 @@ FROM base AS link-collector
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
|
||||
# Install pango & rdfind
|
||||
RUN apk add \
|
||||
RUN apk add --no-cache \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
@@ -71,7 +70,7 @@ FROM base AS core
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apk add \
|
||||
RUN apk add --no-cache \
|
||||
cairo \
|
||||
file \
|
||||
font-noto \
|
||||
@@ -117,7 +116,7 @@ FROM core AS backend-development
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
RUN apk add --no-cache postgresql-client
|
||||
|
||||
# Uninstall impress and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
|
||||
30
Makefile
30
Makefile
@@ -67,18 +67,18 @@ data/static:
|
||||
|
||||
# -- Project
|
||||
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql
|
||||
.PHONY: create-env-files
|
||||
create-env-local-files: ## create env.local files in env.d/development
|
||||
create-env-local-files:
|
||||
@touch env.d/development/crowdin.local
|
||||
@touch env.d/development/common.local
|
||||
@touch env.d/development/postgresql.local
|
||||
@touch env.d/development/kc_postgresql.local
|
||||
.PHONY: create-env-local-files
|
||||
|
||||
pre-bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files
|
||||
create-env-local-files
|
||||
.PHONY: pre-bootstrap
|
||||
|
||||
post-bootstrap: \
|
||||
@@ -258,20 +258,6 @@ resetdb: ## flush database and create a superuser "admin"
|
||||
@${MAKE} superuser
|
||||
.PHONY: resetdb
|
||||
|
||||
env.d/development/common:
|
||||
cp -n env.d/development/common.dist env.d/development/common
|
||||
|
||||
env.d/development/postgresql:
|
||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||
|
||||
env.d/development/kc_postgresql:
|
||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
||||
|
||||
crowdin-download: ## Download translated message from crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||
.PHONY: crowdin-download
|
||||
|
||||
@@ -24,5 +24,6 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
ports:
|
||||
- "4444:4444"
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "15432:5432"
|
||||
|
||||
@@ -66,7 +67,9 @@ services:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
- env.d/development/postgresql
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "8071:8000"
|
||||
volumes:
|
||||
@@ -91,7 +94,9 @@ services:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
- env.d/development/postgresql
|
||||
- env.d/development/postgresql.local
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -135,6 +140,7 @@ services:
|
||||
- ".:/app"
|
||||
env_file:
|
||||
- env.d/development/crowdin
|
||||
- env.d/development/crowdin.local
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
working_dir: /app
|
||||
|
||||
@@ -156,6 +162,7 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
ports:
|
||||
- "4444:4444"
|
||||
volumes:
|
||||
@@ -174,6 +181,7 @@ services:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
- env.d/development/kc_postgresql.local
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
|
||||
@@ -136,9 +136,10 @@ NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
|
||||
|
||||
Packages with licences incompatible with the MIT licence:
|
||||
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
||||
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE)
|
||||
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
|
||||
* `xl-multi-column`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
|
||||
|
||||
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
|
||||
|
||||
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
||||
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installation with docker compose
|
||||
|
||||
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/k8s.md)
|
||||
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/kubernetes.md)
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ class ListDocumentFilter(DocumentFilter):
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
is_masked = django_filters.BooleanFilter(
|
||||
method="filter_is_masked", label=_("Masked")
|
||||
)
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
@@ -106,3 +109,22 @@ class ListDocumentFilter(DocumentFilter):
|
||||
return queryset
|
||||
|
||||
return queryset.filter(is_favorite=bool(value))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_masked(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on whether they are masked by the current user.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_masked=true
|
||||
→ Filters documents marked as masked by the logged-in user
|
||||
- /api/v1.0/documents/?is_masked=false
|
||||
→ Filters documents not marked as masked by the logged-in user
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
queryset_method = queryset.filter if bool(value) else queryset.exclude
|
||||
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
|
||||
|
||||
@@ -455,9 +455,8 @@ class DocumentViewSet(
|
||||
|
||||
# Annotate favorite status and filter if applicable as late as possible
|
||||
queryset = queryset.annotate_is_favorite(user)
|
||||
queryset = filterset.filters["is_favorite"].filter(
|
||||
queryset, filter_data["is_favorite"]
|
||||
)
|
||||
for field in ["is_favorite", "is_masked"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
# Apply ordering only now that everything is filtered and annotated
|
||||
queryset = filters.OrderingFilter().filter_queryset(
|
||||
@@ -1109,15 +1108,50 @@ class DocumentViewSet(
|
||||
document=document, user=user
|
||||
).delete()
|
||||
if deleted:
|
||||
return drf.response.Response(
|
||||
{"detail": "Document unmarked as favorite"},
|
||||
status=drf.status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
|
||||
return drf.response.Response(
|
||||
{"detail": "Document was already not marked as favorite"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="mask")
|
||||
def mask(self, request, *args, **kwargs):
|
||||
"""Mask or unmask the document for the logged-in user based on the HTTP method."""
|
||||
# Check permissions first
|
||||
document = self.get_object()
|
||||
user = request.user
|
||||
|
||||
try:
|
||||
link_trace = models.LinkTrace.objects.get(document=document, user=user)
|
||||
except models.LinkTrace.DoesNotExist:
|
||||
return drf.response.Response(
|
||||
{"detail": "User never accessed this document before."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
if link_trace.is_masked:
|
||||
return drf.response.Response(
|
||||
{"detail": "Document was already masked"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
)
|
||||
link_trace.is_masked = True
|
||||
link_trace.save(update_fields=["is_masked"])
|
||||
return drf.response.Response(
|
||||
{"detail": "Document was masked"},
|
||||
status=drf.status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
# Handle DELETE method to unmask document
|
||||
if not link_trace.is_masked:
|
||||
return drf.response.Response(
|
||||
{"detail": "Document was already not masked"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
)
|
||||
link_trace.is_masked = False
|
||||
link_trace.save(update_fields=["is_masked"])
|
||||
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
|
||||
def attachment_upload(self, request, *args, **kwargs):
|
||||
"""Upload a file related to a given document"""
|
||||
|
||||
@@ -150,7 +150,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
"""Add link traces to document from a given list of users."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
models.LinkTrace.objects.create(document=self, user=item)
|
||||
models.LinkTrace.objects.update_or_create(document=self, user=item)
|
||||
|
||||
@factory.post_generation
|
||||
def favorited_by(self, create, extracted, **kwargs):
|
||||
@@ -159,6 +159,15 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
for item in extracted:
|
||||
models.DocumentFavorite.objects.create(document=self, user=item)
|
||||
|
||||
@factory.post_generation
|
||||
def masked_by(self, create, extracted, **kwargs):
|
||||
"""Mark document as masked by a list of users."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
models.LinkTrace.objects.update_or_create(
|
||||
document=self, user=item, defaults={"is_masked": True}
|
||||
)
|
||||
|
||||
|
||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake document user accesses for testing."""
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.3 on 2025-07-13 08:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0023_remove_document_is_public_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="linktrace",
|
||||
name="is_masked",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("fr-fr", "Français"),
|
||||
("de-de", "Deutsch"),
|
||||
("nl-nl", "Nederlands"),
|
||||
("es-es", "Español"),
|
||||
],
|
||||
default=None,
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
null=True,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -793,6 +793,7 @@ class Document(MP_Node, BaseModel):
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
"invite_owner": is_owner,
|
||||
"mask": can_get and user.is_authenticated,
|
||||
"move": is_owner_or_admin and not self.ancestors_deleted_at,
|
||||
"partial_update": can_update,
|
||||
"restore": is_owner,
|
||||
@@ -958,6 +959,7 @@ class LinkTrace(BaseModel):
|
||||
related_name="link_traces",
|
||||
)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
|
||||
is_masked = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_link_trace"
|
||||
|
||||
@@ -9,7 +9,8 @@ from core import enums
|
||||
|
||||
AI_ACTIONS = {
|
||||
"prompt": (
|
||||
"Answer the prompt in markdown format. "
|
||||
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||
"Preserve the language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
|
||||
@@ -175,8 +175,11 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
||||
"formatting. Do not provide any other information. Preserve the language."
|
||||
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||
"Preserve the language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -249,8 +252,11 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
||||
"formatting. Do not provide any other information. Preserve the language."
|
||||
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||
"Preserve the language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
|
||||
@@ -45,7 +45,10 @@ def test_api_document_favorite_anonymous_user(method, reach):
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
||||
"""
|
||||
Authenticated users should be able to mark a document to which they have access
|
||||
as favorite using POST.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
client = APIClient()
|
||||
@@ -69,7 +72,10 @@ def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_post_forbidden():
|
||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
||||
"""
|
||||
Authenticated users should not be allowed to mark a document to which they don't
|
||||
have access as favorite using POST.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
client = APIClient()
|
||||
|
||||
@@ -41,8 +41,8 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# User don't have access to this document, let say it had access and this access has been
|
||||
# removed. It should not be in the favorite list anymore.
|
||||
# If the user doesn't have access to this document (e.g the user had access
|
||||
# and this access was removed), it should not be in the favorite list anymore.
|
||||
factories.DocumentFactory(favorited_by=[user])
|
||||
|
||||
document = factories.UserDocumentAccessFactory(
|
||||
|
||||
@@ -312,6 +312,84 @@ def test_api_documents_list_filter_is_favorite_invalid():
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_masked
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_masked_true():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they marked as masked.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
masked_documents = factories.DocumentFactory.create_batch(
|
||||
3, users=[user], masked_by=[user]
|
||||
)
|
||||
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
|
||||
for document in unmasked_documents:
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_masked=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
# Ensure all results are marked as masked by the current user
|
||||
masked_documents_ids = [str(doc.id) for doc in masked_documents]
|
||||
for result in results:
|
||||
assert result["id"] in masked_documents_ids
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_masked_false():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they didn't mark as masked.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
masked_documents = factories.DocumentFactory.create_batch(
|
||||
3, users=[user], masked_by=[user]
|
||||
)
|
||||
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
|
||||
for document in unmasked_documents:
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_masked=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 4
|
||||
|
||||
# Ensure all results are not marked as masked by the current user
|
||||
masked_documents_ids = [str(doc.id) for doc in masked_documents]
|
||||
for result in results:
|
||||
assert result["id"] not in masked_documents_ids
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_masked_invalid():
|
||||
"""Filtering with an invalid `is_masked` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
factories.DocumentFactory.create_batch(3, users=[user], masked_by=[user])
|
||||
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
|
||||
for document in unmasked_documents:
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_masked=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 7
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
|
||||
353
src/backend/core/tests/documents/test_api_documents_mask.py
Normal file
353
src/backend/core/tests/documents/test_api_documents_mask.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""Test mask document API endpoint for users in impress's core app."""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach",
|
||||
[
|
||||
"restricted",
|
||||
"authenticated",
|
||||
"public",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("method", ["post", "delete"])
|
||||
def test_api_document_mask_anonymous_user(method, reach):
|
||||
"""Anonymous users should not be able to mask/unmask documents."""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
|
||||
response = getattr(APIClient(), method)(
|
||||
f"/api/v1.0/documents/{document.id!s}/mask/"
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert models.LinkTrace.objects.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_mask_authenticated_post_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to mask a document to which they have access."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try masking the document without a link trace
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "User never accessed this document before."}
|
||||
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
|
||||
|
||||
models.LinkTrace.objects.create(document=document, user=user)
|
||||
# Mask document
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document was masked"}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_post_forbidden():
|
||||
"""
|
||||
Authenticated users should no be allowed to mask a document
|
||||
to which they don't have access.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
# Try masking
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_mask_authenticated_post_already_masked_allowed(reach, has_role):
|
||||
"""POST should not create duplicate link trace if already marked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try masking again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"detail": "Document was already masked"}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_post_already_masked_forbidden():
|
||||
"""POST should not create duplicate masks if already marked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
|
||||
# Try masking again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.LinkTrace.objects.filter(document=document, user=user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_mask_authenticated_post_unmasked_allowed(reach, has_role):
|
||||
"""POST should not create duplicate link trace if unmasked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try masking again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document was masked"}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_post_unmasked_forbidden():
|
||||
"""POST should not create duplicate masks if unmasked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
# Try masking again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=False
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_mask_authenticated_delete_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to unmask a document using DELETE."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Unmask document
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert response.content == b"" # No body
|
||||
assert response.text == "" # Empty decoded text
|
||||
assert "Content-Type" not in response.headers # No Content-Type for 204
|
||||
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=False
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_delete_forbidden():
|
||||
"""
|
||||
Authenticated users should not be allowed to unmask a document if
|
||||
they don't have access to it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
|
||||
|
||||
# Unmask document
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_mask_authenticated_delete_not_masked_allowed(reach, has_role):
|
||||
"""DELETE should be idempotent if the document is not masked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try unmasking the document without a link trace
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "User never accessed this document before."}
|
||||
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
|
||||
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
# Unmask document
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"detail": "Document was already not masked"}
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=False
|
||||
).exists()
|
||||
|
||||
|
||||
def test_api_document_mask_authenticated_delete_not_masked_forbidden():
|
||||
"""DELETE should be idempotent if the document is not masked."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
# Try to unmask when no entry exists
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
assert (
|
||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_mask_authenticated_post_unmark_then_mark_again_allowed(
|
||||
reach, has_role
|
||||
):
|
||||
"""A user should be able to mask, unmask, and mask a document again."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/mask/"
|
||||
|
||||
# Mask document
|
||||
response = client.post(url)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Unmask document
|
||||
response = client.delete(url)
|
||||
assert response.status_code == 204
|
||||
assert response.content == b"" # No body
|
||||
assert response.text == "" # Empty decoded text
|
||||
assert "Content-Type" not in response.headers # No Content-Type for 204
|
||||
|
||||
# Mask document again
|
||||
response = client.post(url)
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document was masked"}
|
||||
|
||||
assert models.LinkTrace.objects.filter(
|
||||
document=document, user=user, is_masked=True
|
||||
).exists()
|
||||
@@ -49,6 +49,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": False,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -121,6 +122,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||
**links_definition
|
||||
),
|
||||
"mask": False,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -226,6 +228,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -305,6 +308,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||
**links_definition
|
||||
),
|
||||
"mask": True,
|
||||
"move": False,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
@@ -498,6 +502,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||
**link_definition
|
||||
),
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": access.role in ["administrator", "owner"],
|
||||
|
||||
@@ -91,6 +91,7 @@ def test_api_documents_trashbin_format():
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False, # Can't move a deleted document
|
||||
|
||||
@@ -165,6 +165,7 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"duplicate": False,
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"mask": False,
|
||||
"media_auth": False,
|
||||
"media_check": False,
|
||||
"move": False,
|
||||
@@ -233,6 +234,7 @@ def test_models_documents_get_abilities_reader(
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": is_authenticated,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -297,6 +299,7 @@ def test_models_documents_get_abilities_editor(
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": is_authenticated,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -350,6 +353,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": True,
|
||||
@@ -400,6 +404,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": True,
|
||||
@@ -453,6 +458,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -513,6 +519,7 @@ def test_models_documents_get_abilities_reader_user(
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -571,6 +578,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Breton\n"
|
||||
"Language: br_FR\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Lenner"
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
msgstr "Embanner"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
msgid "Administrator"
|
||||
@@ -91,11 +91,11 @@ msgstr "Perc'henn"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
msgstr "Strishaet"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
msgstr "Anavezet"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Public"
|
||||
@@ -111,11 +111,11 @@ msgstr "Bugel diwezhañ"
|
||||
|
||||
#: build/lib/core/enums.py:38 core/enums.py:38
|
||||
msgid "First sibling"
|
||||
msgstr ""
|
||||
msgstr "Breur pe c'hoar kentañ"
|
||||
|
||||
#: build/lib/core/enums.py:39 core/enums.py:39
|
||||
msgid "Last sibling"
|
||||
msgstr ""
|
||||
msgstr "Liamm diwezhañ"
|
||||
|
||||
#: build/lib/core/enums.py:40 core/enums.py:40
|
||||
msgid "Left"
|
||||
@@ -131,7 +131,7 @@ msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
msgstr "alc'hwez kentañ evit an enrollañ evel UIID"
|
||||
|
||||
#: build/lib/core/models.py:86 core/models.py:86
|
||||
msgid "created on"
|
||||
@@ -139,7 +139,7 @@ msgstr "krouet d'ar/al"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
msgstr "deiziad hag eurvezh krouidigezh an enrolladenn"
|
||||
|
||||
#: build/lib/core/models.py:92 core/models.py:92
|
||||
msgid "updated on"
|
||||
@@ -147,23 +147,23 @@ msgstr "hizivaet d'ar/al"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
msgstr "deiziad hag eurvezh m'eo bet hizivaet an enrolladenn"
|
||||
|
||||
#: build/lib/core/models.py:129 core/models.py:129
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
msgstr "N'hon eus kavet implijer ebet gant an isstrollad-mañ met ar postel a zo liammet ouzh un implijer enrollet."
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
msgstr "Ebarzhit un isstrollad mat. An talvoud-mañ a c'hall enderc'hel lizhiri, sifroù hag arouezioù @/./+/-/_/: hepken."
|
||||
|
||||
#: build/lib/core/models.py:148 core/models.py:148
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
msgstr "isstrollad"
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
msgstr "Rekis. 255 arouezenn pe nebeutoc'h. Lizhiri, sifroù hag arouezioù @/./+/-/_/: hepken."
|
||||
|
||||
#: build/lib/core/models.py:159 core/models.py:159
|
||||
msgid "full name"
|
||||
@@ -175,11 +175,11 @@ msgstr "anv berr"
|
||||
|
||||
#: build/lib/core/models.py:162 core/models.py:162
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
msgstr "postel identelezh"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
msgstr "postel ar merour"
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "language"
|
||||
@@ -187,11 +187,11 @@ msgstr "yezh"
|
||||
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
msgstr "Ar yezh en deus c'hoant da welet an implijer an etrefas enni."
|
||||
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
msgstr "Ar gwerzhid-eur en deus c'hoant da welet an implijer an eur drezañ."
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
msgid "device"
|
||||
@@ -199,23 +199,23 @@ msgstr "trevnad"
|
||||
|
||||
#: build/lib/core/models.py:188 core/models.py:188
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
msgstr "Pe vefe an implijer un aparailh pe un implijer gwirion."
|
||||
|
||||
#: build/lib/core/models.py:191 core/models.py:191
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
msgstr "statud ar skipailh"
|
||||
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
msgstr "Ma c'hall an implijer kevreañ ouzh al lec'hienn verañ-mañ."
|
||||
|
||||
#: build/lib/core/models.py:196 core/models.py:196
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
msgstr "oberiant"
|
||||
|
||||
#: build/lib/core/models.py:199 core/models.py:199
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
|
||||
|
||||
#: build/lib/core/models.py:211 core/models.py:211
|
||||
msgid "user"
|
||||
@@ -232,115 +232,115 @@ msgstr "titl"
|
||||
|
||||
#: build/lib/core/models.py:369 core/models.py:369
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
msgstr "bomm"
|
||||
|
||||
#: build/lib/core/models.py:418 core/models.py:418
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
msgstr "Teul"
|
||||
|
||||
#: build/lib/core/models.py:419 core/models.py:419
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
msgstr "Teulioù"
|
||||
|
||||
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
msgstr "Teuliad hep titl"
|
||||
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
msgstr "{name} en deus rannet un teul ganeoc'h!"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war an teul da-heul:"
|
||||
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
msgstr "{name} en deus rannet un teul ganeoc'h: {title}"
|
||||
|
||||
#: build/lib/core/models.py:964 core/models.py:964
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
msgstr "Roud liamm an teuliad/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
msgstr "Roudoù liamm an teuliad/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:971 core/models.py:971
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
msgstr "Ur roud liamm a zo dija evit an teul/an implijer."
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
msgstr "Teuliad muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
msgstr "Teuliadoù muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1001 core/models.py:1001
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
msgstr "An teul-mañ a zo un teul muiañ karet gant an implijer-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1023 core/models.py:1023
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
msgstr "Liamm teul/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
msgstr "Liammoù teul/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1030 core/models.py:1030
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
msgstr "An implijer-mañ a zo dija en teul-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1036 core/models.py:1036
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
msgstr "Ar skipailh-mañ a zo dija en teul-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
|
||||
#: core/models.py:1042 core/models.py:1367
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
|
||||
|
||||
#: build/lib/core/models.py:1188 core/models.py:1188
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
msgstr "Goulenn tizhout an teul"
|
||||
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
msgstr "Goulennoù tizhout an teul"
|
||||
|
||||
#: build/lib/core/models.py:1195 core/models.py:1195
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
msgstr "An implijer en deus goulennet tizhout an teul-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1260 core/models.py:1260
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
msgstr "{name} en defe c'hoant da dizhout an teul-mañ!"
|
||||
|
||||
#: build/lib/core/models.py:1264 core/models.py:1264
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
msgstr "{name} en defe c'hoant da dizhout an teul da-heul:"
|
||||
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
msgstr "{name} en defe c'hoant da dizhout an teul: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1282 core/models.py:1282
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
msgstr "deskrivadur"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
msgstr "kod"
|
||||
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "css"
|
||||
@@ -352,7 +352,7 @@ msgstr "publik"
|
||||
|
||||
#: build/lib/core/models.py:1288 core/models.py:1288
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
|
||||
|
||||
#: build/lib/core/models.py:1294 core/models.py:1294
|
||||
msgid "Template"
|
||||
@@ -364,40 +364,40 @@ msgstr "Patromoù"
|
||||
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
msgstr "Liamm patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
msgstr "Liammoù patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1355 core/models.py:1355
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
msgstr "An implijer-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1361 core/models.py:1361
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1438 core/models.py:1438
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
msgstr "postel"
|
||||
|
||||
#: build/lib/core/models.py:1457 core/models.py:1457
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
msgstr "Pedadenn d'un teul"
|
||||
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
msgstr "Pedadennoù d'un teul"
|
||||
|
||||
#: build/lib/core/models.py:1478 core/models.py:1478
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
|
||||
|
||||
#: core/templates/mail/html/template.html:162
|
||||
#: core/templates/mail/text/template.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
msgstr "Logo ar postel"
|
||||
|
||||
#: core/templates/mail/html/template.html:209
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
@@ -407,11 +407,11 @@ msgstr "Digeriñ"
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
msgstr " Docs, hoc'h ostilh nevez ret-holl evit aozañ, rannañ ha kenlabourat war an teulioù e skipailh. "
|
||||
|
||||
#: core/templates/mail/html/template.html:233
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
msgstr " Kinniget gant %(brandname)s "
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 11:52\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Language: pt_PT\n"
|
||||
@@ -19,87 +19,87 @@ msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
msgstr "Informações Pessoais"
|
||||
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
msgstr "Permissões"
|
||||
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
msgstr "Datas importantes"
|
||||
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
msgstr "Estrutura de árvore"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
msgstr "Título"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
msgstr "Eu sou o criador"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
msgstr "Favorito"
|
||||
|
||||
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
msgstr "Um novo documento foi criado em seu nome!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
msgstr "A propriedade de um novo documento foi concedida a você:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
msgstr "Corpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
msgstr "Tipo de corpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
msgstr "cópia de {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
msgstr "Leitor"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
msgstr "Editor"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
msgstr "Administrador"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
msgstr "Dono"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
msgstr "Restrito"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
msgstr "Autenticado"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
msgstr "Público"
|
||||
|
||||
#: build/lib/core/enums.py:36 core/enums.py:36
|
||||
msgid "First child"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Language: sv_SE\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
|
||||
@@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
|
||||
"PO-Revision-Date: 2025-07-09 10:42\n"
|
||||
"PO-Revision-Date: 2025-07-18 10:25\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Language: zh_CN\n"
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "3.4.1"
|
||||
version = "3.4.2"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -64,10 +64,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Bug Tracker" = "https://github.com/numerique-gouv/impress/issues/new"
|
||||
"Changelog" = "https://github.com/numerique-gouv/impress/blob/main/CHANGELOG.md"
|
||||
"Homepage" = "https://github.com/numerique-gouv/impress"
|
||||
"Repository" = "https://github.com/numerique-gouv/impress"
|
||||
"Bug Tracker" = "https://github.com/suitenumerique/docs/issues/new"
|
||||
"Changelog" = "https://github.com/suitenumerique/docs/blob/main/CHANGELOG.md"
|
||||
"Homepage" = "https://github.com/suitenumerique/docs"
|
||||
"Repository" = "https://github.com/suitenumerique/docs"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FullConfig, FullProject, chromium, expect } from '@playwright/test';
|
||||
|
||||
import { keyCloakSignIn } from './common';
|
||||
import { keyCloakSignIn } from './utils-common';
|
||||
|
||||
const saveStorageState = async (
|
||||
browserConfig: FullProject<unknown, unknown>,
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { CONFIG, createDoc, overrideConfig } from './common';
|
||||
import { CONFIG, createDoc, overrideConfig } from './utils-common';
|
||||
|
||||
test.describe('Config', () => {
|
||||
test('it checks that sentry is trying to init from config endpoint', async ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
keyCloakSignIn,
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
} from './utils-common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -29,6 +29,50 @@ test.describe('Doc Create', () => {
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(docsGrid.getByText(docTitle)).toBeVisible();
|
||||
});
|
||||
|
||||
test('it creates a sub doc from slash menu editor', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [title] = await createDoc(page, 'my-new-slash-doc', browserName, 1);
|
||||
|
||||
await verifyDocName(page, title);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page
|
||||
.getByText('New sub-doc', {
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'doc title input' });
|
||||
await expect(input).toHaveText('');
|
||||
await expect(
|
||||
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it creates a sub doc from interlinking dropdown', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [title] = await createDoc(page, 'my-new-slash-doc', browserName, 1);
|
||||
|
||||
await verifyDocName(page, title);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Link a doc').first().click();
|
||||
await page
|
||||
.locator('.quick-search-container')
|
||||
.getByText('New sub-doc')
|
||||
.click();
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'doc title input' });
|
||||
await expect(input).toHaveText('');
|
||||
await expect(
|
||||
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Create: Not logged', () => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Doc Editor - Heading Accessibility', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('should filter heading options progressively (h1 -> h2 -> h3)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Nouveau doc' }).click();
|
||||
|
||||
await page.waitForURL('**/docs/**', {
|
||||
timeout: 10000,
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
const input = page.getByLabel('doc title input');
|
||||
await input.fill('heading-accessibility-test');
|
||||
await input.blur();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
|
||||
await page.keyboard.type('/');
|
||||
await expect(page.getByText('Titre 1')).toBeVisible();
|
||||
await expect(page.getByText('Titre 2')).toBeHidden();
|
||||
await expect(page.getByText('Titre 3')).toBeHidden();
|
||||
|
||||
await page.getByText('Titre 1').click();
|
||||
await page.keyboard.type('Main Title');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await editor.click();
|
||||
await page.keyboard.type('/');
|
||||
|
||||
await expect(page.getByText('Titre 1')).toBeHidden();
|
||||
await expect(page.getByText('Titre 2')).toBeVisible();
|
||||
await expect(page.getByText('Titre 3')).toBeHidden();
|
||||
|
||||
await page.getByText('Titre 2').click();
|
||||
await page.keyboard.type('Sub Title');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await editor.click();
|
||||
await page.keyboard.type('/');
|
||||
|
||||
await expect(page.getByText('Titre 1')).toBeHidden();
|
||||
await expect(page.getByText('Titre 2')).toBeVisible();
|
||||
await expect(page.getByText('Titre 3')).toBeVisible();
|
||||
|
||||
await page.getByText('Titre 3').click();
|
||||
await page.keyboard.type('Sub Sub Title');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await editor.click();
|
||||
await page.keyboard.type('/');
|
||||
|
||||
await expect(page.getByText('Titre 1')).toBeHidden();
|
||||
await expect(page.getByText('Titre 2')).toBeHidden();
|
||||
await expect(page.getByText('Titre 3')).toBeVisible();
|
||||
|
||||
await page.getByText('Titre 3').click();
|
||||
await page.keyboard.type('Another Sub Sub Title');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await editor.click();
|
||||
await page.keyboard.type('/');
|
||||
|
||||
await expect(page.getByText('Titre 1')).toBeHidden();
|
||||
await expect(page.getByText('Titre 2')).toBeHidden();
|
||||
await expect(page.getByText('Titre 3')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
mockedDocument,
|
||||
overrideConfig,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
import { createRootSubPage } from './sub-pages-utils';
|
||||
} from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -706,4 +706,75 @@ test.describe('Doc Editor', () => {
|
||||
'pink',
|
||||
);
|
||||
});
|
||||
|
||||
test('it checks interlink feature', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const { name: docChild1 } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-interlink-child-1',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild1);
|
||||
|
||||
const { name: docChild2 } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-interlink-child-2',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
"span[data-inline-content-type='interlinkingSearchInline'] input",
|
||||
);
|
||||
const searchContainer = page.locator('.quick-search-container');
|
||||
|
||||
await input.fill('doc-interlink');
|
||||
|
||||
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
|
||||
await expect(searchContainer.getByText(docChild1)).toBeVisible();
|
||||
await expect(searchContainer.getByText(docChild2)).toBeVisible();
|
||||
|
||||
await input.pressSequentially('-child');
|
||||
|
||||
await expect(searchContainer.getByText(docChild1)).toBeVisible();
|
||||
await expect(searchContainer.getByText(docChild2)).toBeVisible();
|
||||
await expect(searchContainer.getByText(randomDoc)).toBeHidden();
|
||||
|
||||
// use keydown to select the second result
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
const interlink = page.getByRole('link', {
|
||||
name: 'child-2',
|
||||
});
|
||||
|
||||
await expect(interlink).toBeVisible();
|
||||
await interlink.click();
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
});
|
||||
|
||||
test('it checks interlink shortcut @', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = page.locator('.bn-block-outer').last();
|
||||
await editor.click();
|
||||
await page.keyboard.press('@');
|
||||
|
||||
await expect(
|
||||
page.locator(
|
||||
"span[data-inline-content-type='interlinkingSearchInline'] input",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import pdf from 'pdf-parse';
|
||||
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
import { createDoc, verifyDocName } from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -346,4 +347,137 @@ test.describe('Doc Export', () => {
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
expect(pdfData.text).toContain('Hello World');
|
||||
});
|
||||
|
||||
test('it exports the doc with multi columns', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-multi-columns',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
|
||||
await page.getByText('Three Columns', { exact: true }).click();
|
||||
|
||||
await page.locator('.bn-block-column').first().fill('Column 1');
|
||||
await page.locator('.bn-block-column').nth(1).fill('Column 2');
|
||||
await page.locator('.bn-block-column').last().fill('Column 3');
|
||||
|
||||
expect(await page.locator('.bn-block-column').count()).toBe(3);
|
||||
await expect(
|
||||
page.locator('.bn-block-column[data-node-type="column"]').first(),
|
||||
).toHaveText('Column 1');
|
||||
await expect(
|
||||
page.locator('.bn-block-column[data-node-type="column"]').nth(1),
|
||||
).toHaveText('Column 2');
|
||||
await expect(
|
||||
page.locator('.bn-block-column[data-node-type="column"]').last(),
|
||||
).toHaveText('Column 3');
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'download',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Download',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
void page
|
||||
.getByRole('button', {
|
||||
name: 'Download',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
expect(pdfData.text).toContain('Column 1');
|
||||
expect(pdfData.text).toContain('Column 2');
|
||||
expect(pdfData.text).toContain('Column 3');
|
||||
});
|
||||
|
||||
test('it exports the doc with interlinking', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'export-interlinking',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'export-interlink-child',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
await page
|
||||
.locator(
|
||||
"span[data-inline-content-type='interlinkingSearchInline'] input",
|
||||
)
|
||||
.fill('interlink-child');
|
||||
|
||||
await page
|
||||
.locator('.quick-search-container')
|
||||
.getByText('interlink-child')
|
||||
.click();
|
||||
|
||||
const interlink = page.getByRole('link', {
|
||||
name: 'interlink-child',
|
||||
});
|
||||
|
||||
await expect(interlink).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${docChild}.pdf`);
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'download',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
|
||||
void page
|
||||
.getByRole('button', {
|
||||
name: 'Download',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
|
||||
expect(pdfData.text).toContain('interlink-child'); // This is the pdf text
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, mockedListDocs } from './common';
|
||||
import { createDoc, mockedListDocs } from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc grid dnd', () => {
|
||||
test('it creates a doc', async ({ page, browserName }) => {
|
||||
@@ -165,6 +166,40 @@ test.describe('Doc grid dnd', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc grid dnd mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test('DND is deactivated on mobile', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(page.getByTestId('docs-grid')).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
await expect(docsGrid.getByRole('row').first()).toBeVisible();
|
||||
await expect(docsGrid.locator('.--docs--grid-droppable')).toHaveCount(0);
|
||||
|
||||
await createDoc(page, 'Draggable doc mobile', browserName, 1, true);
|
||||
|
||||
await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'Draggable doc mobile child',
|
||||
true,
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
|
||||
await expect(page.locator('.--docs-sub-page-item').first()).toHaveAttribute(
|
||||
'draggable',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const data = [
|
||||
{
|
||||
id: 'can-drop-and-drag',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, getGridRow } from './common';
|
||||
import { createDoc, getGridRow } from './utils-common';
|
||||
|
||||
type SmallDoc = {
|
||||
id: string;
|
||||
|
||||
@@ -4,12 +4,11 @@ import {
|
||||
createDoc,
|
||||
getGridRow,
|
||||
goToGridDoc,
|
||||
mockedAccesses,
|
||||
mockedDocument,
|
||||
mockedInvitations,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
import { createRootSubPage } from './sub-pages-utils';
|
||||
} from './utils-common';
|
||||
import { mockedAccesses, mockedInvitations } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -443,9 +442,10 @@ test.describe('Doc Header', () => {
|
||||
page.getByText('Document duplicated successfully!'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const duplicateTitle = 'Copy of ' + docTitle;
|
||||
await verifyDocName(page, duplicateTitle);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const row = await getGridRow(page, duplicateTitle);
|
||||
|
||||
@@ -471,16 +471,24 @@ test.describe('Doc Header', () => {
|
||||
await editor.click();
|
||||
await editor.fill('Hello Duplicated World');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
const duplicateTitle = 'Copy of ' + childTitle;
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
|
||||
const child = docTree
|
||||
.getByRole('treeitem')
|
||||
.locator('.--docs-sub-page-item')
|
||||
.filter({
|
||||
hasText: childTitle,
|
||||
});
|
||||
await child.hover();
|
||||
await child.getByText(`more_horiz`).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await expect(
|
||||
page.getByText('Document duplicated successfully!'),
|
||||
).toBeVisible();
|
||||
|
||||
const duplicateDuplicateTitle = 'Copy of ' + childTitle;
|
||||
await verifyDocName(page, duplicateTitle);
|
||||
|
||||
await expect(
|
||||
page.getByTestId('doc-tree').getByText(duplicateDuplicateTitle),
|
||||
page.getByTestId('doc-tree').getByText(duplicateTitle),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
import { updateShareLink } from './share-utils';
|
||||
import { createRootSubPage } from './sub-pages-utils';
|
||||
import { createDoc, verifyDocName } from './utils-common';
|
||||
import { updateShareLink } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Inherited share accesses', () => {
|
||||
test('it checks inherited accesses', async ({ page, browserName }) => {
|
||||
@@ -31,9 +31,7 @@ test.describe('Inherited share accesses', () => {
|
||||
|
||||
await verifyDocName(page, parentTitle);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Inherited share link', () => {
|
||||
test('it checks if the link is inherited', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
// Create root doc
|
||||
@@ -47,12 +45,50 @@ test.describe('Inherited share link', () => {
|
||||
// Create sub page
|
||||
await createRootSubPage(page, browserName, 'sub-page');
|
||||
|
||||
// // verify share link is restricted and reader
|
||||
// Verify share link is like the parent document
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
// await expect(page.getByText('Inherited share')).toBeVisible();
|
||||
const docVisibilityCard = page.getByLabel('Doc visibility card');
|
||||
await expect(docVisibilityCard).toBeVisible();
|
||||
|
||||
await expect(docVisibilityCard.getByText('Connected')).toBeVisible();
|
||||
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
|
||||
|
||||
// Verify inherited link
|
||||
await docVisibilityCard.getByText('Connected').click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Private' }),
|
||||
).toBeDisabled();
|
||||
|
||||
// Update child link
|
||||
await page.getByRole('menuitem', { name: 'Public' }).click();
|
||||
|
||||
await docVisibilityCard.getByText('Reading').click();
|
||||
await page.getByRole('menuitem', { name: 'Editing' }).click();
|
||||
|
||||
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
|
||||
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
|
||||
await expect(
|
||||
docVisibilityCard.getByText('Public', {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
|
||||
await expect(
|
||||
docVisibilityCard.getByText(
|
||||
'The link sharing rules differ from the parent document',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore inherited link
|
||||
await page.getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
await expect(docVisibilityCard.getByText('Connected')).toBeVisible();
|
||||
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
|
||||
await expect(docVisibilityCard.getByText('Public')).toBeHidden();
|
||||
await expect(docVisibilityCard.getByText('Editing')).toBeHidden();
|
||||
await expect(
|
||||
docVisibilityCard.getByText(
|
||||
'The link sharing rules differ from the parent document',
|
||||
),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
keyCloakSignIn,
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
import { createRootSubPage } from './sub-pages-utils';
|
||||
} from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Document create member', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common';
|
||||
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
|
||||
import { addNewMember } from './utils-share';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -148,7 +149,11 @@ test.describe('Document list members', () => {
|
||||
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
|
||||
);
|
||||
await expect(soloOwner).toBeVisible();
|
||||
await list.click();
|
||||
|
||||
await list.click({
|
||||
// eslint-disable-next-line playwright/no-force-option
|
||||
force: true, // Force click to close the dropdown
|
||||
});
|
||||
const newUserEmail = await addNewMember(page, 0, 'Owner');
|
||||
const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`);
|
||||
const newUserRoles = newUser.getByLabel('doc-role-dropdown');
|
||||
@@ -157,10 +162,16 @@ test.describe('Document list members', () => {
|
||||
|
||||
await currentUserRole.click();
|
||||
await expect(soloOwner).toBeHidden();
|
||||
await list.click();
|
||||
await list.click({
|
||||
// eslint-disable-next-line playwright/no-force-option
|
||||
force: true, // Force click to close the dropdown
|
||||
});
|
||||
|
||||
await newUserRoles.click();
|
||||
await list.click();
|
||||
await list.click({
|
||||
// eslint-disable-next-line playwright/no-force-option
|
||||
force: true, // Force click to close the dropdown
|
||||
});
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
|
||||
@@ -8,25 +8,19 @@ import {
|
||||
keyCloakSignIn,
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
} from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Routing', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('Check the presence of the meta tag noindex', async ({ page }) => {
|
||||
const buttonCreateHomepage = page.getByRole('button', {
|
||||
name: 'New doc',
|
||||
});
|
||||
|
||||
await expect(buttonCreateHomepage).toBeVisible();
|
||||
await buttonCreateHomepage.click();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Share',
|
||||
}),
|
||||
).toBeVisible();
|
||||
test('Check the presence of the meta tag noindex', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-routing-test', browserName, 1);
|
||||
const metaDescription = page.locator('meta[name="robots"]');
|
||||
await expect(metaDescription).toHaveAttribute('content', 'noindex');
|
||||
});
|
||||
@@ -60,16 +54,20 @@ test.describe('Doc Routing', () => {
|
||||
});
|
||||
|
||||
test('checks 401 on docs/[id] page', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, '401-doc', browserName, 1);
|
||||
const [docTitle] = await createDoc(page, '401-doc-parent', browserName, 1);
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await createRootSubPage(page, browserName, '401-doc-child');
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
const responsePromise = page.route(
|
||||
/.*\/link-configuration\/$|users\/me\/$/,
|
||||
/.*\/documents\/.*\/$|users\/me\/$/,
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (
|
||||
request.method().includes('PUT') ||
|
||||
request.method().includes('PATCH') ||
|
||||
request.method().includes('GET')
|
||||
) {
|
||||
await route.fulfill({
|
||||
@@ -84,11 +82,7 @@ test.describe('Doc Routing', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page.getByLabel('Connected').click();
|
||||
await page.getByRole('link', { name: '401-doc-parent' }).click();
|
||||
|
||||
await responsePromise;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, randomName, verifyDocName } from './common';
|
||||
import { createDoc, verifyDocName } from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -98,39 +99,46 @@ test.describe('Document search', () => {
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("it checks we don't see filters in search modal", async ({ page }) => {
|
||||
const searchButton = page
|
||||
.getByTestId('left-panel-desktop')
|
||||
.getByRole('button', { name: 'search' });
|
||||
|
||||
await expect(searchButton).toBeVisible();
|
||||
await page.getByRole('button', { name: 'search', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Quick search input' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('doc-search-filters')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Sub page search', () => {
|
||||
test('it check the presence of filters in search modal', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
const [doc1Title] = await createDoc(
|
||||
page,
|
||||
'My sub page search',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, doc1Title);
|
||||
// Doc grid filters are not visible
|
||||
const searchButton = page
|
||||
.getByTestId('left-panel-desktop')
|
||||
.getByRole('button', { name: 'search' });
|
||||
await searchButton.click();
|
||||
.getByRole('button', { name: 'search', exact: true });
|
||||
|
||||
const filters = page.getByTestId('doc-search-filters');
|
||||
|
||||
await searchButton.click();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Quick search input' }),
|
||||
).toBeVisible();
|
||||
await expect(filters).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
// Create a doc without children for the moment
|
||||
// and check that filters are not visible
|
||||
const [doc1Title] = await createDoc(page, 'My page search', browserName, 1);
|
||||
await verifyDocName(page, doc1Title);
|
||||
|
||||
await searchButton.click();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Quick search input' }),
|
||||
).toBeVisible();
|
||||
await expect(filters).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
// Create a sub page
|
||||
// and check that filters are visible
|
||||
await createRootSubPage(page, browserName, 'My sub page search');
|
||||
|
||||
await searchButton.click();
|
||||
|
||||
await expect(filters).toBeVisible();
|
||||
|
||||
await filters.click();
|
||||
await filters.getByRole('button', { name: 'Current doc' }).click();
|
||||
await expect(
|
||||
@@ -139,43 +147,69 @@ test.describe('Sub page search', () => {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Current doc' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Current doc' }).click();
|
||||
await page.getByRole('menuitem', { name: 'All docs' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('it searches sub pages', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const [doc1Title] = await createDoc(
|
||||
// First doc
|
||||
const [firstDocTitle] = await createDoc(
|
||||
page,
|
||||
'My sub page search',
|
||||
'My first sub page search',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, doc1Title);
|
||||
await page.getByRole('button', { name: 'New doc' }).click();
|
||||
await verifyDocName(page, '');
|
||||
await page.getByRole('textbox', { name: 'doc title input' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'doc title input' })
|
||||
.press('ControlOrMeta+a');
|
||||
const [randomDocName] = randomName('doc-sub-page', browserName, 1);
|
||||
await page
|
||||
.getByRole('textbox', { name: 'doc title input' })
|
||||
.fill(randomDocName);
|
||||
await verifyDocName(page, firstDocTitle);
|
||||
|
||||
// Create a new doc - for the moment without children
|
||||
const [secondDocTitle] = await createDoc(
|
||||
page,
|
||||
'My second sub page search',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
const searchButton = page
|
||||
.getByTestId('left-panel-desktop')
|
||||
.getByRole('button', { name: 'search' });
|
||||
|
||||
await searchButton.click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Current doc' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('combobox', { name: 'Quick search input' }).click();
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Quick search input' })
|
||||
.fill('sub');
|
||||
await expect(page.getByLabel(randomDocName)).toBeVisible();
|
||||
.fill('sub page search');
|
||||
|
||||
// Expect to find the first doc
|
||||
await expect(
|
||||
page.getByRole('presentation').getByLabel(firstDocTitle),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('presentation').getByLabel(secondDocTitle),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
// Create a sub page
|
||||
const { name: secondChildDocTitle } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'second - Child doc',
|
||||
);
|
||||
await searchButton.click();
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Quick search input' })
|
||||
.fill('second');
|
||||
|
||||
// Now there is a sub page - expect to have the focus on the current doc
|
||||
await expect(
|
||||
page.getByRole('presentation').getByLabel(secondDocTitle),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('presentation').getByLabel(secondChildDocTitle),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('presentation').getByLabel(firstDocTitle),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
import { createDoc, verifyDocName } from './utils-common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
addNewMember,
|
||||
createDoc,
|
||||
expectLoginPage,
|
||||
keyCloakSignIn,
|
||||
randomName,
|
||||
updateDocTitle,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
import { clickOnAddRootSubPage, createRootSubPage } from './sub-pages-utils';
|
||||
} from './utils-common';
|
||||
import { addNewMember } from './utils-share';
|
||||
import { clickOnAddRootSubPage, createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Tree', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
} from './utils-common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
expectLoginPage,
|
||||
keyCloakSignIn,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
import { createRootSubPage } from './sub-pages-utils';
|
||||
} from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Visibility', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { overrideConfig } from './common';
|
||||
import { overrideConfig } from './utils-common';
|
||||
|
||||
test.describe('Footer', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { expectLoginPage, keyCloakSignIn, overrideConfig } from './common';
|
||||
import {
|
||||
expectLoginPage,
|
||||
keyCloakSignIn,
|
||||
overrideConfig,
|
||||
} from './utils-common';
|
||||
|
||||
test.describe('Header', () => {
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { overrideConfig } from './common';
|
||||
import { overrideConfig } from './utils-common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/docs/');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
import { createDoc } from './utils-common';
|
||||
|
||||
test.describe.serial('Language', () => {
|
||||
let page: Page;
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Locator, Page, expect } from '@playwright/test';
|
||||
|
||||
export type UserSearchResult = {
|
||||
email: string;
|
||||
full_name?: string | null;
|
||||
};
|
||||
|
||||
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
|
||||
export type LinkReach = 'Private' | 'Connected' | 'Public';
|
||||
export type LinkRole = 'Reading' | 'Edition';
|
||||
|
||||
export const searchUserToInviteToDoc = async (
|
||||
page: Page,
|
||||
inputFill?: string,
|
||||
): Promise<UserSearchResult[]> => {
|
||||
const inputFillValue = inputFill ?? 'user ';
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response
|
||||
.url()
|
||||
.includes(`/users/?q=${encodeURIComponent(inputFillValue)}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
await expect(inputSearch).toBeVisible();
|
||||
await inputSearch.fill(inputFillValue);
|
||||
const response = await responsePromise;
|
||||
const users = (await response.json()) as UserSearchResult[];
|
||||
return users;
|
||||
};
|
||||
|
||||
export const addMemberToDoc = async (
|
||||
page: Page,
|
||||
role: Role,
|
||||
users: UserSearchResult[],
|
||||
) => {
|
||||
const list = page.getByTestId('doc-share-add-member-list');
|
||||
await expect(list).toBeHidden();
|
||||
const quickSearchContent = page.getByTestId('doc-share-quick-search');
|
||||
for (const user of users) {
|
||||
await quickSearchContent
|
||||
.getByTestId(`search-user-row-${user.email}`)
|
||||
.click();
|
||||
}
|
||||
|
||||
await list.getByLabel('doc-role-dropdown').click();
|
||||
await expect(page.getByLabel(role)).toBeVisible();
|
||||
await page.getByLabel(role).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
};
|
||||
|
||||
export const verifyMemberAddedToDoc = async (
|
||||
page: Page,
|
||||
user: UserSearchResult,
|
||||
role: Role,
|
||||
): Promise<Locator> => {
|
||||
const container = page.getByLabel('List members card');
|
||||
await expect(container).toBeVisible();
|
||||
const userRow = container.getByTestId(`doc-share-member-row-${user.email}`);
|
||||
await expect(userRow).toBeVisible();
|
||||
await expect(userRow.getByText(role)).toBeVisible();
|
||||
await expect(userRow.getByText(user.full_name || user.email)).toBeVisible();
|
||||
return userRow;
|
||||
};
|
||||
|
||||
export const updateShareLink = async (
|
||||
page: Page,
|
||||
linkReach: LinkReach,
|
||||
linkRole?: LinkRole | null,
|
||||
) => {
|
||||
await page.getByRole('button', { name: 'Visibility', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: linkReach }).click();
|
||||
|
||||
const visibilityUpdatedText = page
|
||||
.getByText('The document visibility has been updated')
|
||||
.first();
|
||||
|
||||
await expect(visibilityUpdatedText).toBeVisible();
|
||||
|
||||
if (linkRole) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Visibility mode', exact: true })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: linkRole }).click();
|
||||
await expect(visibilityUpdatedText).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyLinkReachIsDisabled = async (
|
||||
page: Page,
|
||||
linkReach: LinkReach,
|
||||
) => {
|
||||
await page.getByRole('button', { name: 'Visibility', exact: true }).click();
|
||||
const item = page.getByRole('menuitem', { name: linkReach });
|
||||
await expect(item).toBeDisabled();
|
||||
await page.click('body');
|
||||
};
|
||||
|
||||
export const verifyLinkReachIsEnabled = async (
|
||||
page: Page,
|
||||
linkReach: LinkReach,
|
||||
) => {
|
||||
await page.getByRole('button', { name: 'Visibility', exact: true }).click();
|
||||
const item = page.getByRole('menuitem', { name: linkReach });
|
||||
await expect(item).toBeEnabled();
|
||||
await page.click('body');
|
||||
};
|
||||
|
||||
export const verifyLinkRoleIsDisabled = async (
|
||||
page: Page,
|
||||
linkRole: LinkRole,
|
||||
) => {
|
||||
await page
|
||||
.getByRole('button', { name: 'Visibility mode', exact: true })
|
||||
.click();
|
||||
const item = page.getByRole('menuitem', { name: linkRole });
|
||||
await expect(item).toBeDisabled();
|
||||
await page.click('body');
|
||||
};
|
||||
|
||||
export const verifyLinkRoleIsEnabled = async (
|
||||
page: Page,
|
||||
linkRole: LinkRole,
|
||||
) => {
|
||||
await page
|
||||
.getByRole('button', { name: 'Visibility mode', exact: true })
|
||||
.click();
|
||||
const item = page.getByRole('menuitem', { name: linkRole });
|
||||
await expect(item).toBeEnabled();
|
||||
await page.click('body');
|
||||
};
|
||||
|
||||
export const verifyShareLink = async (
|
||||
page: Page,
|
||||
linkReach: LinkReach,
|
||||
linkRole?: LinkRole | null,
|
||||
) => {
|
||||
const visibilityDropdownButton = page.getByRole('button', {
|
||||
name: 'Visibility',
|
||||
exact: true,
|
||||
});
|
||||
await expect(visibilityDropdownButton).toBeVisible();
|
||||
await expect(visibilityDropdownButton.getByText(linkReach)).toBeVisible();
|
||||
|
||||
if (linkRole) {
|
||||
const visibilityModeButton = page.getByRole('button', {
|
||||
name: 'Visibility mode',
|
||||
exact: true,
|
||||
});
|
||||
await expect(visibilityModeButton).toBeVisible();
|
||||
await expect(page.getByText(linkRole)).toBeVisible();
|
||||
}
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
import { randomName, updateDocTitle, waitForResponseCreateDoc } from './common';
|
||||
|
||||
export const createRootSubPage = async (
|
||||
page: Page,
|
||||
browserName: string,
|
||||
docName: string,
|
||||
) => {
|
||||
// Get response
|
||||
const responsePromise = waitForResponseCreateDoc(page);
|
||||
await clickOnAddRootSubPage(page);
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const subPageJson = (await response.json()) as { id: string };
|
||||
|
||||
// Get doc tree
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree).toBeVisible();
|
||||
|
||||
// Get sub page item
|
||||
const subPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
|
||||
.first();
|
||||
await expect(subPageItem).toBeVisible();
|
||||
await subPageItem.click();
|
||||
|
||||
// Update sub page name
|
||||
const randomDocs = randomName(docName, browserName, 1);
|
||||
await updateDocTitle(page, randomDocs[0]);
|
||||
|
||||
// Return sub page data
|
||||
return { name: randomDocs[0], docTreeItem: subPageItem, item: subPageJson };
|
||||
};
|
||||
|
||||
export const clickOnAddRootSubPage = async (page: Page) => {
|
||||
const rootItem = page.getByTestId('doc-tree-root-item');
|
||||
await expect(rootItem).toBeVisible();
|
||||
await rootItem.hover();
|
||||
await rootItem.getByRole('button', { name: 'add_box' }).click();
|
||||
};
|
||||
|
||||
export const createSubPageFromParent = async (
|
||||
page: Page,
|
||||
browserName: string,
|
||||
parentId: string,
|
||||
subPageName: string,
|
||||
) => {
|
||||
// Get parent doc tree item
|
||||
const parentDocTreeItem = page.getByTestId(`doc-sub-page-item-${parentId}`);
|
||||
await expect(parentDocTreeItem).toBeVisible();
|
||||
await parentDocTreeItem.hover();
|
||||
|
||||
// Create sub page
|
||||
const responsePromise = waitForResponseCreateDoc(page);
|
||||
await parentDocTreeItem.getByRole('button', { name: 'add_box' }).click();
|
||||
|
||||
// Get response
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const subPageJson = (await response.json()) as { id: string };
|
||||
|
||||
// Get doc tree
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree).toBeVisible();
|
||||
|
||||
// Get sub page item
|
||||
const subPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
|
||||
.first();
|
||||
await expect(subPageItem).toBeVisible();
|
||||
await subPageItem.click();
|
||||
|
||||
// Update sub page name
|
||||
const subPageTitle = randomName(subPageName, browserName, 1)[0];
|
||||
await updateDocTitle(page, subPageTitle);
|
||||
|
||||
// Return sub page data
|
||||
return { name: subPageTitle, docTreeItem: subPageItem, item: subPageJson };
|
||||
};
|
||||
@@ -78,14 +78,16 @@ export const createDoc = async (
|
||||
docName: string,
|
||||
browserName: string,
|
||||
length: number = 1,
|
||||
isChild: boolean = false,
|
||||
isMobile: boolean = false,
|
||||
) => {
|
||||
const randomDocs = randomName(docName, browserName, length);
|
||||
|
||||
for (let i = 0; i < randomDocs.length; i++) {
|
||||
if (!isChild) {
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
}
|
||||
|
||||
await page
|
||||
@@ -127,42 +129,6 @@ export const verifyDocName = async (page: Page, docName: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
index: number,
|
||||
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
|
||||
fillText: string = 'user ',
|
||||
) => {
|
||||
const responsePromiseSearchUser = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/users/?q=${encodeURIComponent(fillText)}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
// Select a new user
|
||||
await inputSearch.fill(fillText);
|
||||
|
||||
// Intercept response
|
||||
const responseSearchUser = await responsePromiseSearchUser;
|
||||
const users = (await responseSearchUser.json()) as {
|
||||
email: string;
|
||||
}[];
|
||||
|
||||
// Choose user
|
||||
await page.getByRole('option', { name: users[index].email }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByLabel(role).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
return users[index].email;
|
||||
};
|
||||
|
||||
export const getGridRow = async (page: Page, title: string) => {
|
||||
const docsGrid = page.getByRole('grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
@@ -301,107 +267,6 @@ export const mockedListDocs = async (page: Page, data: object[] = []) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
let result = [
|
||||
{
|
||||
id: '120ec765-43af-4602-83eb-7f4e1224548a',
|
||||
abilities: {
|
||||
destroy: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
created_at: '2024-10-03T12:19:26.107687Z',
|
||||
email: 'test@invitation.test',
|
||||
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
|
||||
role: 'editor',
|
||||
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
|
||||
is_expired: false,
|
||||
...json,
|
||||
},
|
||||
];
|
||||
await page.route('**/invitations/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
request.url().includes('invitations') &&
|
||||
request.url().includes('page=')
|
||||
) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: 1,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: result,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route(
|
||||
'**/invitations/120ec765-43af-4602-83eb-7f4e1224548a/**/',
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('DELETE')) {
|
||||
result = [];
|
||||
|
||||
await route.fulfill({
|
||||
json: {},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const mockedAccesses = async (page: Page, json?: object) => {
|
||||
await page.route('**/accesses/**/', async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
request.url().includes('accesses')
|
||||
) {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
|
||||
user: {
|
||||
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
|
||||
email: 'test@accesses.test',
|
||||
},
|
||||
team: '',
|
||||
max_ancestors_role: null,
|
||||
max_role: 'reader',
|
||||
role: 'reader',
|
||||
document: {
|
||||
id: 'mocked-document-id',
|
||||
path: '000000',
|
||||
depth: 1,
|
||||
},
|
||||
abilities: {
|
||||
destroy: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
link_select_options: {
|
||||
public: ['reader', 'editor'],
|
||||
authenticated: ['reader', 'editor'],
|
||||
restricted: null,
|
||||
},
|
||||
set_role_to: ['administrator', 'editor'],
|
||||
},
|
||||
...json,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const expectLoginPage = async (page: Page) =>
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Collaborative writing' }),
|
||||
165
src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts
Normal file
165
src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
|
||||
export type LinkReach = 'Private' | 'Connected' | 'Public';
|
||||
export type LinkRole = 'Reading' | 'Edition';
|
||||
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
index: number,
|
||||
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
|
||||
fillText: string = 'user ',
|
||||
) => {
|
||||
const responsePromiseSearchUser = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/users/?q=${encodeURIComponent(fillText)}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
// Select a new user
|
||||
await inputSearch.fill(fillText);
|
||||
|
||||
// Intercept response
|
||||
const responseSearchUser = await responsePromiseSearchUser;
|
||||
const users = (await responseSearchUser.json()) as {
|
||||
email: string;
|
||||
}[];
|
||||
|
||||
// Choose user
|
||||
await page.getByRole('option', { name: users[index].email }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByLabel(role).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
return users[index].email;
|
||||
};
|
||||
|
||||
export const updateShareLink = async (
|
||||
page: Page,
|
||||
linkReach: LinkReach,
|
||||
linkRole?: LinkRole | null,
|
||||
) => {
|
||||
await page.getByRole('button', { name: 'Visibility', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: linkReach }).click();
|
||||
|
||||
const visibilityUpdatedText = page
|
||||
.getByText('The document visibility has been updated')
|
||||
.first();
|
||||
|
||||
await expect(visibilityUpdatedText).toBeVisible();
|
||||
|
||||
if (linkRole) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Visibility mode', exact: true })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: linkRole }).click();
|
||||
await expect(visibilityUpdatedText).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
let result = [
|
||||
{
|
||||
id: '120ec765-43af-4602-83eb-7f4e1224548a',
|
||||
abilities: {
|
||||
destroy: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
created_at: '2024-10-03T12:19:26.107687Z',
|
||||
email: 'test@invitation.test',
|
||||
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
|
||||
role: 'editor',
|
||||
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
|
||||
is_expired: false,
|
||||
...json,
|
||||
},
|
||||
];
|
||||
await page.route('**/invitations/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
request.url().includes('invitations') &&
|
||||
request.url().includes('page=')
|
||||
) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: 1,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: result,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route(
|
||||
'**/invitations/120ec765-43af-4602-83eb-7f4e1224548a/**/',
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('DELETE')) {
|
||||
result = [];
|
||||
|
||||
await route.fulfill({
|
||||
json: {},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const mockedAccesses = async (page: Page, json?: object) => {
|
||||
await page.route('**/accesses/**/', async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
request.url().includes('accesses')
|
||||
) {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
|
||||
user: {
|
||||
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
|
||||
email: 'test@accesses.test',
|
||||
},
|
||||
team: '',
|
||||
max_ancestors_role: null,
|
||||
max_role: 'reader',
|
||||
role: 'reader',
|
||||
document: {
|
||||
id: 'mocked-document-id',
|
||||
path: '000000',
|
||||
depth: 1,
|
||||
},
|
||||
abilities: {
|
||||
destroy: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
link_select_options: {
|
||||
public: ['reader', 'editor'],
|
||||
authenticated: ['reader', 'editor'],
|
||||
restricted: null,
|
||||
},
|
||||
set_role_to: ['administrator', 'editor'],
|
||||
},
|
||||
...json,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
import {
|
||||
randomName,
|
||||
updateDocTitle,
|
||||
waitForResponseCreateDoc,
|
||||
} from './utils-common';
|
||||
|
||||
export const createRootSubPage = async (
|
||||
page: Page,
|
||||
browserName: string,
|
||||
docName: string,
|
||||
isMobile: boolean = false,
|
||||
) => {
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
}
|
||||
|
||||
// Get response
|
||||
const responsePromise = waitForResponseCreateDoc(page);
|
||||
await clickOnAddRootSubPage(page);
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const subPageJson = (await response.json()) as { id: string };
|
||||
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
}
|
||||
|
||||
// Get doc tree
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree).toBeVisible();
|
||||
|
||||
// Get sub page item
|
||||
const subPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
|
||||
.first();
|
||||
await expect(subPageItem).toBeVisible();
|
||||
await subPageItem.click();
|
||||
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('close')
|
||||
.click();
|
||||
}
|
||||
|
||||
// Update sub page name
|
||||
const randomDocs = randomName(docName, browserName, 1);
|
||||
await updateDocTitle(page, randomDocs[0]);
|
||||
|
||||
// Return sub page data
|
||||
return { name: randomDocs[0], docTreeItem: subPageItem, item: subPageJson };
|
||||
};
|
||||
|
||||
export const clickOnAddRootSubPage = async (page: Page) => {
|
||||
const rootItem = page.getByTestId('doc-tree-root-item');
|
||||
await expect(rootItem).toBeVisible();
|
||||
await rootItem.hover();
|
||||
await rootItem.getByRole('button', { name: 'add_box' }).click();
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "3.4.1",
|
||||
"version": "3.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "3.4.1",
|
||||
"version": "3.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -16,23 +16,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@blocknote/code-block": "0.33.0",
|
||||
"@blocknote/core": "0.33.0",
|
||||
"@blocknote/mantine": "0.33.0",
|
||||
"@blocknote/react": "0.33.0",
|
||||
"@blocknote/xl-docx-exporter": "0.33.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.33.0",
|
||||
"@blocknote/code-block": "0.35.0",
|
||||
"@blocknote/core": "0.35.0",
|
||||
"@blocknote/mantine": "0.35.0",
|
||||
"@blocknote/react": "0.35.0",
|
||||
"@blocknote/xl-docx-exporter": "0.35.0",
|
||||
"@blocknote/xl-multi-column": "0.35.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.35.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.8.2",
|
||||
"@gouvfr-lasuite/ui-kit": "0.10.0",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@openfun/cunningham-react": "3.1.0",
|
||||
"@openfun/cunningham-react": "3.2.0",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@sentry/nextjs": "9.38.0",
|
||||
"@sentry/nextjs": "9.42.0",
|
||||
"@tanstack/react-query": "5.83.0",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
@@ -45,12 +46,12 @@
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.7.1",
|
||||
"next": "15.4.1",
|
||||
"posthog-js": "1.257.0",
|
||||
"next": "15.4.4",
|
||||
"posthog-js": "1.258.2",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.10.1",
|
||||
"react-aria-components": "1.11.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.6.0",
|
||||
"react-i18next": "15.6.1",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-select": "5.10.2",
|
||||
"styled-components": "6.1.19",
|
||||
@@ -73,18 +74,18 @@
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "17.2.0",
|
||||
"dotenv": "17.2.1",
|
||||
"eslint-config-impress": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "30.0.4",
|
||||
"jest-environment-jsdom": "30.0.4",
|
||||
"jest": "30.0.5",
|
||||
"jest-environment-jsdom": "30.0.5",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.6.2",
|
||||
"stylelint": "16.21.1",
|
||||
"stylelint": "16.22.0",
|
||||
"stylelint-config-standard": "38.0.0",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"webpack": "5.100.1",
|
||||
"webpack": "5.100.2",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface BoxProps {
|
||||
$background?: CSSProperties['background'];
|
||||
$color?: CSSProperties['color'];
|
||||
$css?: string | RuleSet<object>;
|
||||
$cursor?: CSSProperties['cursor'];
|
||||
$direction?: CSSProperties['flexDirection'];
|
||||
$display?: CSSProperties['display'];
|
||||
$effect?: 'show' | 'hide';
|
||||
@@ -44,13 +45,13 @@ export interface BoxProps {
|
||||
export type BoxType = ComponentPropsWithRef<typeof Box>;
|
||||
|
||||
export const Box = styled('div')<BoxProps>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
${({ $align }) => $align && `align-items: ${$align};`}
|
||||
${({ $background }) => $background && `background: ${$background};`}
|
||||
${({ $color }) => $color && `color: ${$color};`}
|
||||
${({ $direction }) => $direction && `flex-direction: ${$direction};`}
|
||||
${({ $display }) => $display && `display: ${$display};`}
|
||||
${({ $cursor }) => $cursor && `cursor: ${$cursor};`}
|
||||
${({ $direction }) => `flex-direction: ${$direction || 'column'};`}
|
||||
${({ $display, as }) =>
|
||||
`display: ${$display || as?.match('span|input') ? 'inline-flex' : 'flex'};`}
|
||||
${({ $flex }) => $flex && `flex: ${$flex};`}
|
||||
${({ $gap }) => $gap && `gap: ${$gap};`}
|
||||
${({ $height }) => $height && `height: ${$height};`}
|
||||
|
||||
@@ -31,11 +31,11 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
|
||||
$background="none"
|
||||
$margin="none"
|
||||
$padding="none"
|
||||
$hasTransition
|
||||
$css={css`
|
||||
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
font-family: inherit;
|
||||
|
||||
color: ${props.disabled
|
||||
|
||||
@@ -25,6 +25,7 @@ export type DropdownMenuProps = {
|
||||
arrowCss?: BoxProps['$css'];
|
||||
buttonCss?: BoxProps['$css'];
|
||||
disabled?: boolean;
|
||||
opened?: boolean;
|
||||
topMessage?: string;
|
||||
selectedValues?: string[];
|
||||
afterOpenChange?: (isOpen: boolean) => void;
|
||||
@@ -38,12 +39,13 @@ export const DropdownMenu = ({
|
||||
arrowCss,
|
||||
buttonCss,
|
||||
label,
|
||||
opened,
|
||||
topMessage,
|
||||
afterOpenChange,
|
||||
selectedValues,
|
||||
}: PropsWithChildren<DropdownMenuProps>) => {
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(opened ?? false);
|
||||
const blockButtonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './BoxButton';
|
||||
export * from './Card';
|
||||
export * from './DropButton';
|
||||
export * from './DropdownMenu';
|
||||
export * from './quick-search';
|
||||
export * from './Icon';
|
||||
export * from './InfiniteScroll';
|
||||
export * from './Link';
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode, useRef } from 'react';
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { hasChildrens } from '@/utils/children';
|
||||
|
||||
@@ -30,7 +36,6 @@ export type QuickSearchProps = {
|
||||
loading?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const QuickSearch = ({
|
||||
@@ -42,14 +47,47 @@ export const QuickSearch = ({
|
||||
label,
|
||||
placeholder,
|
||||
children,
|
||||
}: QuickSearchProps) => {
|
||||
}: PropsWithChildren<QuickSearchProps>) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedValue, setSelectedValue] = useState<string>('');
|
||||
|
||||
// Auto-select first item when children change
|
||||
useEffect(() => {
|
||||
if (!children) {
|
||||
setSelectedValue('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Small delay for DOM to update
|
||||
const timeoutId = setTimeout(() => {
|
||||
const firstItem = ref.current?.querySelector('[cmdk-item]');
|
||||
if (firstItem) {
|
||||
const value =
|
||||
firstItem.getAttribute('data-value') ||
|
||||
firstItem.getAttribute('value') ||
|
||||
firstItem.textContent?.trim() ||
|
||||
'';
|
||||
if (value) {
|
||||
setSelectedValue(value);
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<QuickSearchStyle />
|
||||
<div className="quick-search-container">
|
||||
<Command label={label} shouldFilter={false} ref={ref}>
|
||||
<Command
|
||||
label={label}
|
||||
shouldFilter={false}
|
||||
ref={ref}
|
||||
value={selectedValue}
|
||||
onValueChange={setSelectedValue}
|
||||
tabIndex={0}
|
||||
>
|
||||
{showInput && (
|
||||
<QuickSearchInput
|
||||
loading={loading}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { Box } from '../Box';
|
||||
import { Box, Text } from '@/components';
|
||||
|
||||
import { QuickSearchData } from './QuickSearch';
|
||||
import { QuickSearchItem } from './QuickSearchItem';
|
||||
@@ -23,6 +23,7 @@ export const QuickSearchGroup = <T,>({
|
||||
key={group.groupName}
|
||||
heading={group.groupName}
|
||||
forceMount={false}
|
||||
contentEditable={false}
|
||||
>
|
||||
{group.startActions?.map((action, index) => {
|
||||
return (
|
||||
@@ -58,7 +59,13 @@ export const QuickSearchGroup = <T,>({
|
||||
);
|
||||
})}
|
||||
{group.emptyString && group.elements.length === 0 && (
|
||||
<span className="ml-b clr-greyscale-500">{group.emptyString}</span>
|
||||
<Text
|
||||
$variation="500"
|
||||
$margin={{ left: '2xs', bottom: '3xs' }}
|
||||
$size="sm"
|
||||
>
|
||||
{group.emptyString}
|
||||
</Text>
|
||||
)}
|
||||
</Command.Group>
|
||||
</Box>
|
||||
|
||||
@@ -1,133 +1,136 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
export const QuickSearchStyle = createGlobalStyle`
|
||||
& *:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.quick-search-container {
|
||||
[cmdk-root] {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: transform 100ms ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[cmdk-input] {
|
||||
border: none;
|
||||
width: 100%;
|
||||
font-size: 17px;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
outline: none;
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
border-radius: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-item] {
|
||||
content-visibility: auto;
|
||||
cursor: pointer;
|
||||
border-radius: var(--c--theme--spacings--xs);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
will-change: background, color;
|
||||
transition: all 150ms ease;
|
||||
transition-property: none;
|
||||
|
||||
.show-right-on-focus {
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: transform 100ms ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-selected='true'] {
|
||||
background: var(--c--theme--colors--greyscale-100);
|
||||
.show-right-on-focus {
|
||||
opacity: 1;
|
||||
[cmdk-input] {
|
||||
border: none;
|
||||
width: 100%;
|
||||
font-size: 17px;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
outline: none;
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
border-radius: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-disabled='true'] {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
& + [cmdk-item] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-list] {
|
||||
flex:1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
[cmdk-vercel-shortcuts] {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
gap: 8px;
|
||||
|
||||
kbd {
|
||||
font-size: 12px;
|
||||
min-width: 20px;
|
||||
padding: 4px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
background: var(--c--theme--colors--greyscale-500);
|
||||
display: inline-flex;
|
||||
[cmdk-item] {
|
||||
content-visibility: auto;
|
||||
cursor: pointer;
|
||||
border-radius: var(--c--theme--spacings--xs);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
will-change: background, color;
|
||||
transition: all 150ms ease;
|
||||
transition-property: none;
|
||||
|
||||
.show-right-on-focus {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-selected='true'] {
|
||||
background: var(--c--theme--colors--greyscale-100);
|
||||
.show-right-on-focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-disabled='true'] {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
& + [cmdk-item] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-list] {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
[cmdk-vercel-shortcuts] {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
gap: 8px;
|
||||
|
||||
kbd {
|
||||
font-size: 12px;
|
||||
min-width: 20px;
|
||||
padding: 4px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
background: var(--c--theme--colors--greyscale-500);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-separator] {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--c--theme--colors--greyscale-500);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
*:not([hidden]) + [cmdk-group] {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[cmdk-group-heading] {
|
||||
user-select: none;
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
font-weight: bold;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--c--theme--spacings--xs);
|
||||
}
|
||||
|
||||
[cmdk-empty] {
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-separator] {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--c--theme--colors--greyscale-500);
|
||||
margin: 4px 0;
|
||||
.c__modal__scroller:has(.quick-search-container),
|
||||
.c__modal__scroller:has(.noPadding) {
|
||||
padding: 0 !important;
|
||||
|
||||
.c__modal__close .c__button {
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.c__modal__title {
|
||||
font-size: var(--c--theme--font--sizes--xs);
|
||||
padding: var(--c--theme--spacings--base);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
*:not([hidden]) + [cmdk-group] {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[cmdk-group-heading] {
|
||||
user-select: none;
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
font-weight: bold;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--c--theme--spacings--xs);
|
||||
}
|
||||
|
||||
[cmdk-empty] {
|
||||
}
|
||||
}
|
||||
|
||||
.c__modal__scroller:has(.quick-search-container),
|
||||
.c__modal__scroller:has(.noPadding) {
|
||||
padding: 0 !important;
|
||||
|
||||
.c__modal__close .c__button {
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.c__modal__title {
|
||||
font-size: var(--c--theme--font--sizes--xs);
|
||||
|
||||
padding: var(--c--theme--spacings--base);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
MutationCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
@@ -24,8 +28,24 @@ const defaultOptions = {
|
||||
retry: DEFAULT_QUERY_RETRY,
|
||||
},
|
||||
};
|
||||
|
||||
let globalRouterReplace: ((url: string) => void) | null = null;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions,
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => {
|
||||
if (error instanceof Error && 'status' in error && error.status === 401) {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_AUTH],
|
||||
});
|
||||
setAuthUrl();
|
||||
if (globalRouterReplace) {
|
||||
void globalRouterReplace('/401');
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -40,25 +60,14 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
return initializeResizeListener();
|
||||
}, [initializeResizeListener]);
|
||||
|
||||
/**
|
||||
* Update the global router replace function
|
||||
* This allows us to use the router replace function globally
|
||||
*/
|
||||
useEffect(() => {
|
||||
queryClient.setDefaultOptions({
|
||||
...defaultOptions,
|
||||
mutations: {
|
||||
onError: (error) => {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'status' in error &&
|
||||
error.status === 401
|
||||
) {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_AUTH],
|
||||
});
|
||||
setAuthUrl();
|
||||
void replace(`/401`);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
globalRouterReplace = (url: string) => {
|
||||
void replace(url);
|
||||
};
|
||||
}, [replace]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -51,6 +51,17 @@
|
||||
filter: var(--c--components--image-system-filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast
|
||||
*/
|
||||
.c__toast__container {
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.c__toast__container:has(.c__toast) {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: italic;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="18" height="23" viewBox="0 0 18 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.57954 5.93282C4.44486 5.79021 4.37793 5.61484 4.37793 5.41299C4.37793 5.21112 4.44495 5.03795 4.58154 4.90136C4.72456 4.75833 4.90437 4.6875 5.11367 4.6875H12.8964C13.0991 4.6875 13.2722 4.75857 13.408 4.90133C13.5508 5.03718 13.6219 5.21027 13.6219 5.41299C13.6219 5.61613 13.5506 5.7918 13.409 5.93387C13.273 6.07732 13.0996 6.14873 12.8964 6.14873H5.11367C4.90437 6.14873 4.72456 6.0779 4.58154 5.93487L4.57954 5.93282ZM4.57954 9.51144C4.44486 9.36882 4.37793 9.19346 4.37793 8.9916C4.37793 8.78973 4.44495 8.61656 4.58154 8.47997C4.72456 8.33695 4.90437 8.26611 5.11367 8.26611H12.8964C13.0991 8.26611 13.2722 8.33719 13.408 8.47995C13.5508 8.61579 13.6219 8.78888 13.6219 8.9916C13.6219 9.19475 13.5506 9.37042 13.409 9.51249C13.273 9.65593 13.0996 9.72734 12.8964 9.72734H5.11367C4.90437 9.72734 4.72456 9.65651 4.58154 9.51348L4.57954 9.51144ZM4.57954 13.1003C4.44561 12.9585 4.37793 12.7869 4.37793 12.5907C4.37793 12.3831 4.44414 12.204 4.57954 12.0606L4.58151 12.0586C4.72453 11.9155 4.90437 11.8447 5.11367 11.8447H8.79482C9.00363 11.8447 9.18092 11.9153 9.3177 12.0596C9.46006 12.2024 9.53057 12.3819 9.53057 12.5907C9.53057 12.7887 9.45812 12.9609 9.31671 13.1024C9.17936 13.2397 9.00235 13.306 8.79482 13.306H5.11367C4.90609 13.306 4.72695 13.2397 4.58358 13.1043L4.57954 13.1003ZM1.09476 0.851519C1.65317 0.285946 2.47955 0.0117188 3.55508 0.0117188H14.4447C15.52 0.0117188 16.3433 0.28583 16.895 0.851748C17.4529 1.41698 17.7234 2.24966 17.7234 3.33145V18.8866C17.7234 19.975 17.4531 20.8082 16.8945 21.3668C16.3427 21.9256 15.5196 22.1961 14.4447 22.1961H3.55508C2.47988 22.1961 1.65367 21.9255 1.09521 21.367C0.543652 20.8083 0.276367 19.9747 0.276367 18.8866V3.33145C0.276367 2.24984 0.543796 1.41679 1.09476 0.851519ZM15.5624 20.0351C15.2958 20.3085 14.8959 20.4452 14.3627 20.4452H3.63711C3.10391 20.4452 2.70059 20.3085 2.42715 20.0351L2.49875 19.9652L2.49786 19.9643L2.42715 20.0351C2.16055 19.7616 2.02725 19.3686 2.02725 18.8559V3.36221C2.02725 2.84951 2.16055 2.45645 2.42715 2.18301C2.70059 1.90273 3.10391 1.7626 3.63711 1.7626H14.3627C14.8959 1.7626 15.2958 1.90273 15.5624 2.18301C15.8358 2.45645 15.9726 2.84951 15.9726 3.36221V18.8559C15.9726 19.3686 15.8358 19.7616 15.5624 20.0351Z" fill="#3A3A3A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
width="24"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20.709 15.8262C21.202 15.8262 21.5875 15.9577 21.8574 16.2275L21.9521 16.333C22.1583 16.592 22.2588 16.939 22.2588 17.3682V22.5098C22.2588 22.9389 22.1583 23.286 21.9521 23.5449L21.8574 23.6504C21.5875 23.9202 21.202 24.0517 20.709 24.0518H15.584C15.1523 24.0518 14.8024 23.9513 14.541 23.7451L14.4346 23.6504C14.1647 23.3833 14.0332 23 14.0332 22.5098V17.3682C14.0332 16.8779 14.1646 16.4947 14.4346 16.2275L14.541 16.1328C14.8024 15.9267 15.1523 15.8262 15.584 15.8262H20.709ZM17.4443 0.961914C18.5273 0.961914 19.3662 1.23768 19.9307 1.81641L20.0342 1.92773C20.5341 2.50088 20.7734 3.30971 20.7734 4.33105V13.6318C20.7734 14.1429 20.3587 14.5576 19.8477 14.5576C19.3367 14.5574 18.9229 14.1428 18.9229 13.6318V4.3623C18.9229 3.85862 18.7884 3.48005 18.5273 3.21875L18.5264 3.21777C18.2723 2.95072 17.8871 2.8125 17.3623 2.8125H6.63672C6.17751 2.81256 5.82275 2.91826 5.56641 3.12402L5.46289 3.21777C5.20816 3.47904 5.07715 3.85807 5.07715 4.3623V19.8555C5.07715 20.336 5.19802 20.7014 5.42969 20.9609L5.46289 20.9287L5.49805 20.8936L5.5332 20.9297H5.53418L5.56934 20.9658L5.5332 21.001L5.5 21.0332C5.76013 21.2713 6.13568 21.3954 6.63672 21.3955H11.625C12.1358 21.3957 12.5496 21.8095 12.5498 22.3203C12.5498 22.8313 12.1359 23.2459 11.625 23.2461H6.55469C5.53947 23.246 4.7368 23.0064 4.16992 22.5059L4.05957 22.4023C3.49544 21.8309 3.22658 20.9822 3.22656 19.8867V4.33105C3.22663 3.24207 3.49545 2.39422 4.05859 1.81641H4.05957L4.16895 1.71191C4.73581 1.20479 5.53881 0.961976 6.55469 0.961914H17.4443ZM17.3682 18.1484C17.2625 18.1485 17.1758 18.1703 17.1055 18.21L17.04 18.2559C16.9619 18.3244 16.9209 18.4175 16.9209 18.541C16.9209 18.6617 16.9604 18.7537 17.0361 18.8223L17.1006 18.8682C17.171 18.908 17.2583 18.9287 17.3643 18.9287H18.0039L18.5303 18.8613L18.6934 18.8398L18.5703 18.9482L17.9668 19.4795L16.5449 20.9004V20.9014C16.4462 20.9975 16.3985 21.1065 16.3984 21.2305C16.3984 21.3719 16.4428 21.4799 16.5273 21.5596H16.5264C16.6159 21.6414 16.723 21.6825 16.8496 21.6826C16.9174 21.6826 16.981 21.6706 17.04 21.6475L17.125 21.6025C17.1525 21.5839 17.1799 21.5613 17.2061 21.5352L18.6162 20.125L19.1416 19.5273L19.2451 19.4092L19.2285 19.5664L19.1689 20.1182V20.7168C19.169 20.8574 19.2064 20.9646 19.2754 21.0439L19.3311 21.0947C19.392 21.1378 19.4683 21.1592 19.5615 21.1592C19.6851 21.1591 19.7763 21.1186 19.8418 21.041H19.8428L19.8887 20.9756C19.9283 20.9057 19.9492 20.8201 19.9492 20.7168V18.6855C19.9492 18.5477 19.9209 18.4395 19.8682 18.3574L19.8076 18.2842C19.7144 18.196 19.5832 18.1484 19.4082 18.1484H17.3682ZM11.7949 12.7949C12.0155 12.7949 12.2056 12.87 12.3525 13.0244H12.3535C12.5057 13.1772 12.5811 13.3698 12.5811 13.5908C12.581 13.7495 12.5367 13.8931 12.4512 14.0186L12.3525 14.1377C12.2046 14.2856 12.0139 14.3564 11.7949 14.3564H8.11328C7.89437 14.3564 7.70227 14.2855 7.54883 14.1406L7.54297 14.1348C7.40022 13.9836 7.32815 13.7995 7.32812 13.5908C7.32812 13.3718 7.39806 13.1799 7.54297 13.0264L7.5459 13.0234L7.60547 12.9697C7.74785 12.8528 7.91945 12.795 8.11328 12.7949H11.7949ZM15.8965 9.21582C16.1111 9.21584 16.2969 9.29151 16.4424 9.44336L16.4961 9.5C16.6134 9.63628 16.6718 9.80257 16.6719 9.99121C16.6719 10.2068 16.5949 10.3944 16.4443 10.5459L16.4453 10.5469C16.2994 10.7008 16.1126 10.7773 15.8965 10.7773H8.11328C7.89178 10.7773 7.69883 10.7018 7.5459 10.5488V10.5479L7.54395 10.5459H7.54297C7.39907 10.3935 7.32812 10.2051 7.32812 9.99121C7.32821 9.77745 7.39921 9.59103 7.5459 9.44434L7.60547 9.39062C7.74779 9.27383 7.91955 9.2159 8.11328 9.21582H15.8965ZM15.8965 5.6377C16.1112 5.63772 16.2968 5.71321 16.4424 5.86523L16.4961 5.92188C16.6133 6.0582 16.6719 6.22441 16.6719 6.41309C16.6719 6.62878 16.595 6.81624 16.4443 6.96777L16.4453 6.96875C16.2994 7.12253 16.1125 7.19822 15.8965 7.19824H8.11328C7.89183 7.19815 7.69881 7.12357 7.5459 6.9707V6.96973L7.54297 6.9668C7.39925 6.81448 7.32815 6.62682 7.32812 6.41309C7.32812 6.19928 7.39928 6.01295 7.5459 5.86621C7.69883 5.71328 7.89178 5.63778 8.11328 5.6377H15.8965Z"
|
||||
fill="#3A3A3A"
|
||||
stroke="#3A3A3A"
|
||||
stroke-width="0.1"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M18.4661 8.93913C17.8524 8.93913 17.2752 8.82368 16.7344 8.59277C16.1997 8.35579 15.7257 8.03375 15.3125 7.62663C14.9054 7.21343 14.5833 6.73644 14.3464 6.19564C14.1155 5.65484 14 5.08062 14 4.47298C14 3.65267 14.2005 2.90527 14.6016 2.23079C15.0087 1.55632 15.5495 1.01855 16.224 0.617513C16.8984 0.210395 17.6458 0.00683594 18.4661 0.00683594C19.2804 0.00683594 20.0247 0.210395 20.6992 0.617513C21.3798 1.01855 21.9206 1.55632 22.3216 2.23079C22.7227 2.90527 22.9232 3.65267 22.9232 4.47298C22.9232 5.08062 22.8047 5.65484 22.5677 6.19564C22.3368 6.73036 22.0148 7.20432 21.6016 7.61751C21.1944 8.03071 20.7205 8.35579 20.1797 8.59277C19.6389 8.82368 19.0677 8.93913 18.4661 8.93913ZM18.4753 7.31673C18.6576 7.31673 18.8034 7.26204 18.9128 7.15267C19.0282 7.03722 19.0859 6.88835 19.0859 6.70606V5.08366H20.6992C20.8815 5.08366 21.0273 5.02897 21.1367 4.9196C21.2522 4.80415 21.3099 4.65527 21.3099 4.47298C21.3099 4.28461 21.2522 4.13574 21.1367 4.02637C21.0273 3.91699 20.8815 3.8623 20.6992 3.8623H19.0859V2.24902C19.0859 2.06673 19.0282 1.9209 18.9128 1.81152C18.8034 1.69607 18.6576 1.63835 18.4753 1.63835C18.2869 1.63835 18.135 1.69607 18.0195 1.81152C17.9102 1.9209 17.8555 2.06673 17.8555 2.24902V3.8623H16.2422C16.0599 3.8623 15.911 3.91699 15.7956 4.02637C15.6862 4.13574 15.6315 4.28461 15.6315 4.47298C15.6315 4.65527 15.6862 4.80415 15.7956 4.9196C15.911 5.02897 16.0599 5.08366 16.2422 5.08366H17.8555V6.70606C17.8555 6.88835 17.9102 7.03722 18.0195 7.15267C18.135 7.26204 18.2869 7.31673 18.4753 7.31673Z"
|
||||
fill="#3A3A3A"
|
||||
/>
|
||||
<path
|
||||
d="M20.7234 19.8862V9.53888C20.1803 9.77921 19.591 9.93419 18.9726 9.98682V19.8555C18.9726 20.3682 18.8358 20.7612 18.5624 21.0347C18.2958 21.3081 17.8959 21.4448 17.3627 21.4448H6.63711C6.10391 21.4448 5.70059 21.3081 5.42715 21.0347L5.49875 20.9649L5.49786 20.964L5.42715 21.0347C5.16055 20.7612 5.02725 20.3682 5.02725 19.8555V4.36181C5.02725 3.84911 5.16055 3.45605 5.42715 3.18261C5.70059 2.90234 6.10391 2.7622 6.63711 2.7622H13.2825C13.4981 2.11701 13.8301 1.52509 14.2535 1.01132H6.55508C5.47955 1.01132 4.65317 1.28555 4.09476 1.85112C3.5438 2.41639 3.27637 3.24944 3.27637 4.33105V19.8862C3.27637 20.9743 3.54365 21.8079 4.09521 22.3666C4.65367 22.9251 5.47988 23.1957 6.55508 23.1957H17.4447C18.5196 23.1957 19.3427 22.9252 19.8945 22.3664C20.4531 21.8078 20.7234 20.9746 20.7234 19.8862Z"
|
||||
fill="#3A3A3A"
|
||||
/>
|
||||
<path
|
||||
d="M13.6747 7.14833C13.4266 6.69614 13.2402 6.20528 13.1269 5.6871H8.11367C7.90437 5.6871 7.72456 5.75794 7.58154 5.90096C7.44495 6.03755 7.37793 6.21072 7.37793 6.41259C7.37793 6.61445 7.44486 6.78981 7.57954 6.93243L7.58154 6.93447C7.72456 7.0775 7.90437 7.14833 8.11367 7.14833H13.6747Z"
|
||||
fill="#3A3A3A"
|
||||
/>
|
||||
<path
|
||||
d="M16.5378 9.64649C16.2607 9.54063 15.9943 9.41301 15.7408 9.26572H8.11367C7.90437 9.26572 7.72456 9.33655 7.58154 9.47958C7.44495 9.61616 7.37793 9.78933 7.37793 9.99121C7.37793 10.1931 7.44486 10.3684 7.57954 10.511L7.58154 10.5131C7.72456 10.6561 7.90437 10.7269 8.11367 10.7269H15.8964C16.0996 10.7269 16.273 10.6555 16.409 10.5121C16.5506 10.37 16.6219 10.1944 16.6219 9.99121C16.6219 9.86401 16.5939 9.74847 16.5378 9.64649Z"
|
||||
fill="#3A3A3A"
|
||||
/>
|
||||
<path
|
||||
d="M7.57954 14.0999C7.44561 13.9581 7.37793 13.7865 7.37793 13.5903C7.37793 13.3827 7.44414 13.2036 7.57954 13.0602L7.58151 13.0582C7.72453 12.9151 7.90437 12.8443 8.11367 12.8443H11.7948C12.0036 12.8443 12.1809 12.9149 12.3177 13.0592C12.4601 13.2021 12.5306 13.3815 12.5306 13.5903C12.5306 13.7883 12.4581 13.9605 12.3167 14.102C12.1794 14.2393 12.0024 14.3056 11.7948 14.3056H8.11367C7.90609 14.3056 7.72695 14.2393 7.58358 14.1039L7.57954 14.0999Z"
|
||||
fill="#3A3A3A"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3.40918 4.70117C3.28613 4.70117 3.18359 4.66016 3.10156 4.57812C3.02409 4.49609 2.98535 4.39583 2.98535 4.27734C2.98535 4.15885 3.02409 4.06087 3.10156 3.9834C3.18359 3.90137 3.28613 3.86035 3.40918 3.86035H8.59766C8.71615 3.86035 8.81413 3.90137 8.8916 3.9834C8.97363 4.06087 9.01465 4.15885 9.01465 4.27734C9.01465 4.39583 8.97363 4.49609 8.8916 4.57812C8.81413 4.66016 8.71615 4.70117 8.59766 4.70117H3.40918ZM3.40918 7.08691C3.28613 7.08691 3.18359 7.0459 3.10156 6.96387C3.02409 6.88184 2.98535 6.78158 2.98535 6.66309C2.98535 6.5446 3.02409 6.44661 3.10156 6.36914C3.18359 6.28711 3.28613 6.24609 3.40918 6.24609H8.59766C8.71615 6.24609 8.81413 6.28711 8.8916 6.36914C8.97363 6.44661 9.01465 6.5446 9.01465 6.66309C9.01465 6.78158 8.97363 6.88184 8.8916 6.96387C8.81413 7.0459 8.71615 7.08691 8.59766 7.08691H3.40918ZM3.40918 9.47266C3.28613 9.47266 3.18359 9.43392 3.10156 9.35645C3.02409 9.27441 2.98535 9.17643 2.98535 9.0625C2.98535 8.93945 3.02409 8.83691 3.10156 8.75488C3.18359 8.67285 3.28613 8.63184 3.40918 8.63184H5.86328C5.98633 8.63184 6.08659 8.67285 6.16406 8.75488C6.24609 8.83691 6.28711 8.93945 6.28711 9.0625C6.28711 9.17643 6.24609 9.27441 6.16406 9.35645C6.08659 9.43392 5.98633 9.47266 5.86328 9.47266H3.40918ZM0.250977 13.2598V2.88965C0.250977 2.17871 0.426432 1.64323 0.777344 1.2832C1.13281 0.923177 1.66374 0.743164 2.37012 0.743164H9.62988C10.3363 0.743164 10.8649 0.923177 11.2158 1.2832C11.5713 1.64323 11.749 2.17871 11.749 2.88965V13.2598C11.749 13.9753 11.5713 14.5107 11.2158 14.8662C10.8649 15.2217 10.3363 15.3994 9.62988 15.3994H2.37012C1.66374 15.3994 1.13281 15.2217 0.777344 14.8662C0.426432 14.5107 0.250977 13.9753 0.250977 13.2598ZM1.35156 13.2393C1.35156 13.5811 1.44043 13.8431 1.61816 14.0254C1.80046 14.2077 2.06934 14.2988 2.4248 14.2988H9.5752C9.93066 14.2988 10.1973 14.2077 10.375 14.0254C10.5573 13.8431 10.6484 13.5811 10.6484 13.2393V2.91016C10.6484 2.56836 10.5573 2.30632 10.375 2.12402C10.1973 1.93717 9.93066 1.84375 9.5752 1.84375H2.4248C2.06934 1.84375 1.80046 1.93717 1.61816 2.12402C1.44043 2.30632 1.35156 2.56836 1.35156 2.91016V13.2393Z"
|
||||
fill="#8585F6"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -2,6 +2,7 @@ import { codeBlock } from '@blocknote/code-block';
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
withPageBreak,
|
||||
} from '@blocknote/core';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
@@ -18,8 +19,13 @@ import { Box, TextErrors } from '@/components';
|
||||
import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management';
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
import { useHeadings, useUploadFile, useUploadStatus } from '../hook/';
|
||||
import useSaveDoc from '../hook/useSaveDoc';
|
||||
import {
|
||||
useHeadings,
|
||||
useSaveDoc,
|
||||
useShortcuts,
|
||||
useUploadFile,
|
||||
useUploadStatus,
|
||||
} from '../hook';
|
||||
import { useEditorStore } from '../stores';
|
||||
import { cssEditor } from '../styles';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
@@ -28,17 +34,34 @@ import { randomColor } from '../utils';
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { CalloutBlock, DividerBlock } from './custom-blocks';
|
||||
import {
|
||||
InterlinkingLinkInlineContent,
|
||||
InterlinkingSearchInlineContent,
|
||||
} from './custom-inline-content';
|
||||
import XLMultiColumn from './xl-multi-column';
|
||||
|
||||
export const blockNoteSchema = withPageBreak(
|
||||
const multiColumnDropCursor = XLMultiColumn?.multiColumnDropCursor;
|
||||
const multiColumnLocales = XLMultiColumn?.locales;
|
||||
const withMultiColumn = XLMultiColumn?.withMultiColumn;
|
||||
|
||||
const baseBlockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
callout: CalloutBlock,
|
||||
divider: DividerBlock,
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
interlinkingSearchInline: InterlinkingSearchInlineContent,
|
||||
interlinkingLinkInline: InterlinkingLinkInlineContent,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
|
||||
baseBlockNoteSchema) as typeof baseBlockNoteSchema;
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
doc: Doc;
|
||||
provider: HocuspocusProvider;
|
||||
@@ -116,7 +139,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
},
|
||||
showCursorLabels: showCursorLabels as 'always' | 'activity',
|
||||
},
|
||||
dictionary: locales[lang as keyof typeof locales],
|
||||
dictionary: {
|
||||
...locales[lang as keyof typeof locales],
|
||||
multi_column:
|
||||
multiColumnLocales?.[lang as keyof typeof multiColumnLocales],
|
||||
},
|
||||
tables: {
|
||||
splitCells: true,
|
||||
cellBackgroundColor: true,
|
||||
@@ -125,11 +152,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
},
|
||||
uploadFile,
|
||||
schema: blockNoteSchema,
|
||||
dropCursor: multiColumnDropCursor,
|
||||
},
|
||||
[collabName, lang, provider, uploadFile],
|
||||
);
|
||||
|
||||
useHeadings(editor);
|
||||
useShortcuts(editor);
|
||||
useUploadStatus(editor);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,32 +9,71 @@ import {
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DocsBlockSchema } from '../types';
|
||||
import { useHeadingAccessibilityFilter } from '../hook';
|
||||
import {
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
getCalloutReactSlashMenuItems,
|
||||
getDividerReactSlashMenuItems,
|
||||
} from './custom-blocks';
|
||||
import { useGetInterlinkingMenuItems } from './custom-inline-content';
|
||||
import XLMultiColumn from './xl-multi-column';
|
||||
|
||||
const getMultiColumnSlashMenuItems =
|
||||
XLMultiColumn?.getMultiColumnSlashMenuItems;
|
||||
|
||||
export const BlockNoteSuggestionMenu = () => {
|
||||
const editor = useBlockNoteEditor<DocsBlockSchema>();
|
||||
const editor = useBlockNoteEditor<
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema
|
||||
>();
|
||||
const { t } = useTranslation();
|
||||
const basicBlocksName = useDictionary().slash_menu.page_break.group;
|
||||
const getInterlinkingMenuItems = useGetInterlinkingMenuItems();
|
||||
const { filterHeadingItemsByAccessibility } = useHeadingAccessibilityFilter();
|
||||
|
||||
const getSlashMenuItems = useMemo(() => {
|
||||
// We insert it after the "Code Block" item to have the interlinking block displayed after the basic blocks
|
||||
const defaultMenu = getDefaultReactSlashMenuItems(editor);
|
||||
const index = defaultMenu.findIndex(
|
||||
(item) => item.aliases?.includes('code') && item.aliases?.includes('pre'),
|
||||
);
|
||||
const newSlashMenuItems = [
|
||||
...defaultMenu.slice(0, index + 1),
|
||||
...getInterlinkingMenuItems(editor, t),
|
||||
...defaultMenu.slice(index + 1),
|
||||
];
|
||||
|
||||
const filteredMenuItems = filterHeadingItemsByAccessibility(
|
||||
newSlashMenuItems,
|
||||
editor,
|
||||
);
|
||||
|
||||
return async (query: string) =>
|
||||
Promise.resolve(
|
||||
filterSuggestionItems(
|
||||
combineByGroup(
|
||||
getDefaultReactSlashMenuItems(editor),
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
filteredMenuItems,
|
||||
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getMultiColumnSlashMenuItems?.(editor) || [],
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
),
|
||||
query,
|
||||
),
|
||||
);
|
||||
}, [basicBlocksName, editor, t]);
|
||||
}, [
|
||||
basicBlocksName,
|
||||
editor,
|
||||
getInterlinkingMenuItems,
|
||||
t,
|
||||
filterHeadingItemsByAccessibility,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SuggestionMenuController
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getFormattingToolbarItems,
|
||||
useDictionary,
|
||||
} from '@blocknote/react';
|
||||
import React, { JSX, useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConfig } from '@/core/config/api';
|
||||
@@ -50,7 +50,7 @@ export const BlockNoteToolbar = () => {
|
||||
);
|
||||
}
|
||||
|
||||
return toolbarItems as JSX.Element[];
|
||||
return toolbarItems;
|
||||
}, [dict, t]);
|
||||
|
||||
const formattingToolbar = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { createReactInlineContentSpec } from '@blocknote/react';
|
||||
import { useEffect } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { StyledLink, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
|
||||
import { useDoc } from '@/docs/doc-management';
|
||||
|
||||
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
propSchema: {
|
||||
url: {
|
||||
default: '',
|
||||
},
|
||||
docId: {
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: ({ inlineContent, updateInlineContent }) => {
|
||||
const { data: doc } = useDoc({ id: inlineContent.props.docId });
|
||||
|
||||
useEffect(() => {
|
||||
if (doc?.title && doc.title !== inlineContent.props.title) {
|
||||
updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
...inlineContent.props,
|
||||
title: doc.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [inlineContent.props, doc?.title, updateInlineContent]);
|
||||
|
||||
return <LinkSelected {...inlineContent.props} />;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface LinkSelectedProps {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<StyledLink
|
||||
href={url}
|
||||
$css={css`
|
||||
display: inline;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
& svg {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
}
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
`}
|
||||
>
|
||||
<SelectedPageIcon width={11.5} />
|
||||
<Text $weight="500" spellCheck="false" $size="16px" $display="inline">
|
||||
{title}
|
||||
</Text>
|
||||
</StyledLink>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { createReactInlineContentSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { DocsBlockNoteEditor } from '@/docs/doc-editor';
|
||||
import LinkPageIcon from '@/docs/doc-editor/assets/doc-link.svg';
|
||||
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
|
||||
import { useCreateChildDocTree, useDocStore } from '@/docs/doc-management';
|
||||
|
||||
import { SearchPage } from './SearchPage';
|
||||
|
||||
export const InterlinkingSearchInlineContent = createReactInlineContentSpec(
|
||||
{
|
||||
type: 'interlinkingSearchInline',
|
||||
propSchema: {
|
||||
trigger: {
|
||||
default: '/',
|
||||
values: ['/', '@'],
|
||||
},
|
||||
disabled: {
|
||||
default: false,
|
||||
values: [true, false],
|
||||
},
|
||||
},
|
||||
content: 'styled',
|
||||
},
|
||||
{
|
||||
render: (props) => {
|
||||
if (props.inlineContent.props.disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchPage
|
||||
{...props}
|
||||
trigger={props.inlineContent.props.trigger}
|
||||
contentRef={props.contentRef}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getInterlinkinghMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
group: string,
|
||||
createPage: () => void,
|
||||
) => [
|
||||
{
|
||||
title: t('Link a doc'),
|
||||
onItemClick: () => {
|
||||
editor.insertInlineContent([
|
||||
{
|
||||
type: 'interlinkingSearchInline',
|
||||
props: {
|
||||
disabled: false,
|
||||
trigger: '/',
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
aliases: ['interlinking', 'link', 'anchor', 'a'],
|
||||
group,
|
||||
icon: <LinkPageIcon />,
|
||||
subtext: t('Link this doc to another doc'),
|
||||
},
|
||||
{
|
||||
title: t('New sub-doc'),
|
||||
onItemClick: createPage,
|
||||
aliases: ['new sub-doc'],
|
||||
group,
|
||||
icon: <AddPageIcon />,
|
||||
subtext: t('Create a new sub-doc'),
|
||||
},
|
||||
];
|
||||
|
||||
export const useGetInterlinkingMenuItems = () => {
|
||||
const { currentDoc } = useDocStore();
|
||||
const createChildDoc = useCreateChildDocTree(currentDoc?.id);
|
||||
|
||||
return (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
) => getInterlinkinghMenuItems(editor, t, t('Links'), createChildDoc);
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import {
|
||||
PartialCustomInlineContentFromConfig,
|
||||
StyleSchema,
|
||||
} from '@blocknote/core';
|
||||
import { useBlockNoteEditor } from '@blocknote/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Icon,
|
||||
QuickSearch,
|
||||
QuickSearchGroup,
|
||||
QuickSearchItemContent,
|
||||
Text,
|
||||
} from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema,
|
||||
} from '@/docs/doc-editor';
|
||||
import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg';
|
||||
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
|
||||
import {
|
||||
useCreateChildDocTree,
|
||||
useDocStore,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { DocSearchSubPageContent, DocSearchTarget } from '@/docs/doc-search';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const inputStyle = css`
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--c--theme--colors--greyscale-700);
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
font-family: 'Inter';
|
||||
`;
|
||||
|
||||
type SearchPageProps = {
|
||||
trigger: string;
|
||||
updateInlineContent: (
|
||||
update: PartialCustomInlineContentFromConfig<
|
||||
{
|
||||
type: string;
|
||||
propSchema: {
|
||||
disabled: {
|
||||
default: boolean;
|
||||
};
|
||||
trigger: {
|
||||
default: string;
|
||||
};
|
||||
};
|
||||
content: 'styled';
|
||||
},
|
||||
StyleSchema
|
||||
>,
|
||||
) => void;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
};
|
||||
|
||||
export const SearchPage = ({
|
||||
contentRef,
|
||||
trigger,
|
||||
updateInlineContent,
|
||||
}: SearchPageProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const editor = useBlockNoteEditor<
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema
|
||||
>();
|
||||
const { t } = useTranslation();
|
||||
const { currentDoc } = useDocStore();
|
||||
const createChildDoc = useCreateChildDocTree(currentDoc?.id);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
/**
|
||||
* createReactInlineContentSpec add automatically the focus after
|
||||
* the inline content, so we need to set the focus on the input
|
||||
* after the component is mounted.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, 100);
|
||||
}, [inputRef]);
|
||||
|
||||
return (
|
||||
<Box as="span" $position="relative">
|
||||
<Box
|
||||
as="span"
|
||||
className="inline-content"
|
||||
$background={colorsTokens['greyscale-100']}
|
||||
$color="var(--c--theme--colors--greyscale-700)"
|
||||
$direction="row"
|
||||
$radius="3px"
|
||||
$padding="1px"
|
||||
$display="inline-flex"
|
||||
tabIndex={-1} // Ensure the span is focusable
|
||||
>
|
||||
{' '}
|
||||
{trigger}
|
||||
<Box
|
||||
as="input"
|
||||
$padding={{ left: '3px' }}
|
||||
$css={inputStyle}
|
||||
ref={inputRef}
|
||||
$display="inline-flex"
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
setSearch(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.key === 'Backspace' && search.length === 0) ||
|
||||
e.key === 'Escape'
|
||||
) {
|
||||
e.preventDefault();
|
||||
|
||||
updateInlineContent({
|
||||
type: 'interlinkingSearchInline',
|
||||
props: {
|
||||
disabled: true,
|
||||
trigger,
|
||||
},
|
||||
});
|
||||
|
||||
contentRef(null);
|
||||
editor.focus();
|
||||
editor.insertInlineContent(['']);
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
// Allow arrow keys to be handled by the command menu for navigation
|
||||
const commandList = e.currentTarget
|
||||
.closest('.inline-content')
|
||||
?.nextElementSibling?.querySelector('[cmdk-list]');
|
||||
|
||||
// Create a synthetic keyboard event for the command menu
|
||||
const syntheticEvent = new KeyboardEvent('keydown', {
|
||||
key: e.key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
commandList?.dispatchEvent(syntheticEvent);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Enter') {
|
||||
// Handle Enter key to select the currently highlighted item
|
||||
const selectedItem = e.currentTarget
|
||||
.closest('.inline-content')
|
||||
?.nextElementSibling?.querySelector(
|
||||
'[cmdk-item][data-selected="true"]',
|
||||
) as HTMLElement;
|
||||
|
||||
selectedItem?.click();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
$minWidth={isDesktop ? '330px' : '220px'}
|
||||
$width="fit-content"
|
||||
$position="absolute"
|
||||
$css={css`
|
||||
top: 28px;
|
||||
z-index: 1000;
|
||||
|
||||
& .quick-search-container [cmdk-root] {
|
||||
border-radius: inherit;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<QuickSearch showInput={false}>
|
||||
<Card
|
||||
$css={css`
|
||||
box-shadow: 0 0 3px 0px var(--c--theme--colors--greyscale-200);
|
||||
& > div {
|
||||
margin-top: 0;
|
||||
& [cmdk-group-heading] {
|
||||
padding: 0.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& [cmdk-group-items] .ml-b {
|
||||
margin-left: 0rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
& [cmdk-item] {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
& .--docs--doc-search-item > div {
|
||||
gap: 0.8rem;
|
||||
}
|
||||
}
|
||||
`}
|
||||
$margin={{ top: '0.5rem' }}
|
||||
>
|
||||
<DocSearchSubPageContent
|
||||
search={search}
|
||||
filters={{ target: DocSearchTarget.CURRENT }}
|
||||
onSelect={(doc) => {
|
||||
updateInlineContent({
|
||||
type: 'interlinkingSearchInline',
|
||||
props: {
|
||||
disabled: true,
|
||||
trigger,
|
||||
},
|
||||
});
|
||||
|
||||
editor.insertInlineContent([
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
url: `/docs/${doc.id}`,
|
||||
docId: doc.id,
|
||||
title: doc.title || untitledDocument,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
editor.focus();
|
||||
}}
|
||||
renderElement={(doc) => (
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="0.6rem"
|
||||
$align="center"
|
||||
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
|
||||
$width="100%"
|
||||
>
|
||||
<FoundPageIcon />
|
||||
<Text
|
||||
$size="14px"
|
||||
$color="var(--c--theme--colors--greyscale-1000)"
|
||||
spellCheck="false"
|
||||
>
|
||||
{doc.title}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
right={
|
||||
<Icon
|
||||
iconName="keyboard_return"
|
||||
$variation="600"
|
||||
spellCheck="false"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<QuickSearchGroup
|
||||
group={{
|
||||
groupName: '',
|
||||
elements: [],
|
||||
endActions: [
|
||||
{
|
||||
onSelect: createChildDoc,
|
||||
content: (
|
||||
<Box
|
||||
$css={css`
|
||||
border-top: 1px solid
|
||||
var(--c--theme--colors--greyscale-200);
|
||||
`}
|
||||
$width="100%"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="0.4rem"
|
||||
$align="center"
|
||||
$padding={{
|
||||
vertical: '0.5rem',
|
||||
horizontal: '0.3rem',
|
||||
}}
|
||||
$css={css`
|
||||
&:hover {
|
||||
background-color: var(
|
||||
--c--theme--colors--greyscale-100
|
||||
);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<AddPageIcon />
|
||||
<Text
|
||||
$size="14px"
|
||||
$color="var(--c--theme--colors--greyscale-1000)"
|
||||
contentEditable={false}
|
||||
>
|
||||
{t('New sub-doc')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</QuickSearch>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './InterlinkingLinkInlineContent';
|
||||
export * from './InterlinkingSearchInlineContent';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Interlinking';
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* To import XL modules you must import from the index file.
|
||||
* This is to ensure that the XL modules are only loaded when
|
||||
* the application is not published as MIT.
|
||||
*/
|
||||
import * as XLMultiColumn from '@blocknote/xl-multi-column';
|
||||
|
||||
let modulesXL = undefined;
|
||||
if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
|
||||
modulesXL = XLMultiColumn;
|
||||
}
|
||||
|
||||
type ModulesXL = typeof XLMultiColumn | undefined;
|
||||
|
||||
export default modulesXL as ModulesXL;
|
||||
@@ -5,7 +5,7 @@ import * as Y from 'yjs';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import useSaveDoc from '../useSaveDoc';
|
||||
import { useSaveDoc } from '../useSaveDoc';
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: jest.fn(),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './useHeadings';
|
||||
export * from './useSaveDoc';
|
||||
export * from './useShortcuts';
|
||||
export * from './useUploadFile';
|
||||
export * from './useHeadingAccessibilityFilter';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getDefaultReactSlashMenuItems } from '@blocknote/react';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
|
||||
export const useHeadingAccessibilityFilter = () => {
|
||||
// function to extract heading level from menu item
|
||||
const getHeadingLevel = (
|
||||
item: ReturnType<typeof getDefaultReactSlashMenuItems>[0],
|
||||
): number => {
|
||||
const title = item.title?.toLowerCase() || '';
|
||||
const aliases = item.aliases || [];
|
||||
const HEADING_2 = 'heading 2';
|
||||
const HEADING_3 = 'heading 3';
|
||||
const TITLE_2 = 'titre 2';
|
||||
const TITLE_3 = 'titre 3';
|
||||
|
||||
if (
|
||||
title.includes(HEADING_2) ||
|
||||
title.includes(TITLE_2) ||
|
||||
aliases.some(
|
||||
(alias: string) => alias.includes(HEADING_2) || alias.includes(TITLE_2),
|
||||
)
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (
|
||||
title.includes(HEADING_3) ||
|
||||
title.includes(TITLE_3) ||
|
||||
aliases.some(
|
||||
(alias: string) => alias.includes(HEADING_3) || alias.includes(TITLE_3),
|
||||
)
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
// function to check if item is a heading
|
||||
const isHeadingItem = (
|
||||
item: ReturnType<typeof getDefaultReactSlashMenuItems>[0],
|
||||
): boolean => {
|
||||
return item.onItemClick?.toString().includes('heading');
|
||||
};
|
||||
|
||||
const filterHeadingItemsByAccessibility = (
|
||||
items: ReturnType<typeof getDefaultReactSlashMenuItems>,
|
||||
editor: DocsBlockNoteEditor,
|
||||
) => {
|
||||
const existingLevels = editor.document
|
||||
.filter((block) => block.type === 'heading')
|
||||
.map((block) => (block.props as { level: number }).level);
|
||||
|
||||
const hasH1 = existingLevels.includes(1);
|
||||
|
||||
if (existingLevels.length === 0) {
|
||||
return items.filter(
|
||||
(item) => !isHeadingItem(item) || getHeadingLevel(item) === 1,
|
||||
);
|
||||
}
|
||||
|
||||
const maxLevel = Math.max(...existingLevels);
|
||||
const minLevel = Math.min(...existingLevels);
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!isHeadingItem(item)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const headingLevel = getHeadingLevel(item);
|
||||
|
||||
// Never allow h1 if one already exists >> accessibility tells that we can only have one h1 per document
|
||||
if (headingLevel === 1 && hasH1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
headingLevel === maxLevel ||
|
||||
headingLevel === maxLevel + 1 ||
|
||||
(headingLevel === minLevel - 1 && minLevel > 1)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return { filterHeadingItemsByAccessibility };
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { toBase64 } from '../utils';
|
||||
|
||||
const SAVE_INTERVAL = 60000;
|
||||
|
||||
const useSaveDoc = (
|
||||
export const useSaveDoc = (
|
||||
docId: string,
|
||||
yDoc: Y.Doc,
|
||||
canSave: boolean,
|
||||
@@ -105,5 +105,3 @@ const useSaveDoc = (
|
||||
};
|
||||
}, [router.events, saveDoc]);
|
||||
};
|
||||
|
||||
export default useSaveDoc;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
|
||||
export const useShortcuts = (editor: DocsBlockNoteEditor) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === '@' && editor?.isFocused()) {
|
||||
const selection = window.getSelection();
|
||||
const previousChar =
|
||||
selection?.anchorNode?.textContent?.charAt(
|
||||
selection.anchorOffset - 1,
|
||||
) || '';
|
||||
|
||||
if (![' ', ''].includes(previousChar)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
editor.insertInlineContent([
|
||||
{
|
||||
type: 'interlinkingSearchInline',
|
||||
props: {
|
||||
disabled: false,
|
||||
trigger: '@',
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// Attach the event listener to the document instead of the window
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [editor]);
|
||||
};
|
||||
@@ -88,7 +88,7 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
a {
|
||||
color: var(--c--theme--colors--greyscale-500);
|
||||
color: var(--c--theme--colors--greyscale-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bn-block-group
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user