Compare commits

..

39 Commits

Author SHA1 Message Date
Anthony LC 9e4e557173 (frontend) Can mask a document in the list view
We can be member of some documents, but sometimes we
want to mask them from the list view because we
don't want to interact with them anymore.
This commit adds the ability to mask a
document in the list view.
2025-07-29 09:48:04 +02:00
Anthony LC 11dfc9ff03 (backend) add is_masked to document view
We added the `is_masked` annotation to the
document view to indicate whether a document
is masked for the current user.
This will allow the frontend to handle
masked documents appropriately in the UI.
2025-07-29 09:48:04 +02:00
AlexB 67a195f89c (helm) add serviceAccountName parameter for services
Add support for specifying custom service accounts
in all Kubernetes resources in our Helm charts
to enable workload identity federation with managed
cloud services (PostgreSQL, Redis, etc.).
This allows deployments to authenticate to cloud
resources without embedding credentials in secrets.
2025-07-28 09:18:12 +02:00
renovate[bot] 09b6fef63f ⬆️(dependencies) update js dependencies 2025-07-25 13:59:03 +00:00
Anthony LC 11d0bafc94 (frontend) add multi columns support for editor
We add multi columns support for editor,
now you can add columns to your document.
Works with export.
📄AGPL feature.
2025-07-25 15:27:01 +02:00
Anthony LC 1ae831cabd ♻️(frontend) search on all docs if no children
When searching for documents, if no children are
found, the search will now include all documents
instead of just those with children.
2025-07-25 14:30:18 +02:00
Manuel Raynaud f1c2219270 🔧(chore) replace old repo url to suitenumerique org
The old repo url on the numerique-gouv orga was still present in the
repo. This commit replaces them to the current repo url.
2025-07-25 12:15:17 +00:00
Anthony LC 8c9380c356 🐛(frontend) fix empty left panel after deleting root doc
When we were deleting a root document, the left panel
was getting empty. It was because the panel thought that
it was a child document and was trying clear
dynamically the panel.
Now, we are checking if the document is a root or not,
if it is a root we just redirect to the homepage.
2025-07-25 12:55:29 +02:00
Anthony LC 3ff6d2541c ♻️(frontend) use more reliable properties in useTreeUtils
Using the treeContext was causing issues with
the current parent detection, in many places
the context is not available.
"depth" property is more reliable than
"nb_accesses_ancestors".
2025-07-25 12:22:48 +02:00
Anthony LC 34ce276222 (frontend) subdocs can manage link reach
The subdocs can now have their own link reach
properties, dissociated from the parent document.
2025-07-25 12:22:47 +02:00
Anthony LC 04273c3b3e 🐛(frontend) redirection 401 overridden
To capture a 401 we were using "onError" in the
queryClient default mutation options. The problem
is this way does not capture globally the onError,
if a mutation uses as well is own "onError", it will
override the default one, causing the 401 to
not be captured anymore.
We now use MutationCache, which allows us to
capture globally the onError, even if a mutation
has its own "onError" defined, this global one will
still be called.
2025-07-25 12:03:43 +02:00
Samuel Paccoud - DINUM 0b301b95c8 (backend) allow masking documents from the list view
Once users have visited a document to which they have access,
they can't remove it from their list view anymore. Several
users reported that this is annoying because a document that
gets a lot of updates keeps popping up at the top of their list
view.

They want to be able to mask the document in a click. We propose
to add a "masked documents" section in the left side bar where the
masked documents can still be found.
2025-07-24 18:39:56 +02:00
Samuel Paccoud - DINUM 228bdf733e (backend) fix wrong docstrings in tests for favorite documents
This was most likely due to copy pasta fail.
2025-07-24 18:39:56 +02:00
Anthony LC bbf48f088f ️(frontend) improve tree stability
Improve tree stability by limiting the requests,
we now only load the tree request one time then
we let the treeContext handle the state without
mutating it directly.
We do not do the doc subpage request anymore,
the treeContext has already the data we need,
we just need to update the tree node when needed.
2025-07-24 13:29:26 +02:00
Anthony LC b28ff8f632 🚨(frontend) fix lint warning about unnecessary assertion
- Recent upgrade highlighted a lint warning about
an unnecessary assertion in the BlockNoteToolbar component.
This commit removes the assertion to resolve the warning.
- Fix a test - upgrade causes an error on a selector click
2025-07-24 12:38:31 +02:00
Anthony LC 14b7cdf561 ⬆️(dependencies) update js dependencies 2025-07-23 16:32:07 +02:00
Krzysztof Cybulski c534fed196 📝(typo) fix link to kubernetes.md in compose.md
Link to kubernetes.md in compose.md was incorrect,
it was pointing to a non-existing file.

Signed-off-by: Krzysztof Cybulski <k.cybulski.dev@tuta.io>
2025-07-23 13:27:14 +02:00
Anthony LC c1a740b7d4 ⬆️(dependency) Bump form-data from 4.0.2 to 4.0.4
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.2 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.2...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-23 12:20:08 +02:00
Anthony LC 83f2b3886e (frontend) add duplicate action to doc tree
We added a duplicate action to the document tree.
2025-07-21 19:48:58 +02:00
Anthony LC 966e514c5a ♻️(frontend) redirect to doc after duplicate
When we duplicate a document from a document page,
we now redirect the user to the newly created
document.
2025-07-21 19:48:57 +02:00
Anthony LC ef6d6c6a59 🏗️(e2e) cleaning and more consistant naming
Clean up e2e tests by removing unused utils
and renaming some files for consistency.
2025-07-21 18:07:10 +02:00
Anthony LC e79f3281b1 🐛(frontend) fix unfold subdocs not clickable at the bottom
At the bottom of the tree panel, the subdocs
were not clickable due to a CSS issue.
This commit adjusts the CSS to ensure that
the subdocs can be unfolded properly.
2025-07-21 17:34:44 +02:00
Anthony LC b78550b513 💄(frontend) visibility icon near title
It was decided to add a visibility icon near the
title of the document in the grid view.
2025-07-21 16:28:17 +02:00
Anthony LC 5a23c97681 🐛(service-worker) Fix useOffline Maximum update depth exceeded
Sentry was reporting a "Maximum update depth exceeded" error
comming from the `useOffline` hook. We updated the hook to
avoid mutation. Seems to impact mainly edge browsers.
2025-07-21 16:05:34 +02:00
Anthony LC 040eddbe6b 🔧(project) change env.d system by using local files
We had lot of problems with the previous env.d system.
Users were often confused by the need to change
the env.d files manually, leading to issues
when using the project locally.
This commit introduces a new system that uses
.env.local files, which are automatically created
and can be modified by users without affecting
the original env.d files. This should simplify
the development process and reduce confusion by
removing the need to manually edit env.d files.
2025-07-21 15:44:52 +02:00
Richard Jones f2e54308d2 🐛(helm) charts generate invalid YAML for collaboration API / WS
Closes #890

Remove the service blocks outside the conditionals in the collaboration
API and WS templates.

Signed-off-by: Richard Jones <rich@linuxplicable.org>
2025-07-18 14:22:03 +02:00
Anthony LC cd6e0ef9e1 🔖(release) patch release 3.4.2
Changed:
- ️(docker) Optimize Dockerfile to use apk with --no-cache

Fixed:
- 🐛(backend) improve prompt to not use code blocks delimiter
2025-07-18 12:30:01 +02:00
AntoLC 02acc7233f 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-18 12:30:01 +02:00
Anthony LC 1c71e830a2 🐛(backend) improve prompt to not use code blocks delimiter
The AI answer was activating the code block feature
in the editor, which was not desired.
The prompt for AI actions has been updated to
instruct the AI to return content directly
without wrapping it in code blocks or markdown
delimiters.
2025-07-18 12:03:48 +02:00
Peter Dave Hello ac0c16a44a ️(docker) Optimize Dockerfile to use apk with --no-cache
Using the `apk` commands with the `--no-cache` parameter for package
installation and upgrade will prevent the package index from being
cached and reduce the built image size.
2025-07-18 10:08:13 +02:00
virgile-dev ca09f9a158 📝(contributing) update contributing policy (#1192)
## Purpose
Update the issue template to help contributors

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

---------

Signed-off-by: virgile-dev <virgile.deville@beta.gouv.fr>
2025-07-17 09:58:31 +00:00
Anthony LC d12b608db9 🔖(patch) release 3.4.1
Fixed:
- 🌐(frontend) keep simple tag during export
- 🐛(back) manage can-edit endpoint without created room in the ws
- 🐛(frontend) fix action buttons not clickable
- 🐛(frontend) fix crash share modal on grid options
2025-07-15 16:14:43 +02:00
Anthony LC 08a0eb59c8 🐛(frontend) fix crash share modal on grid options
The share modal in the DocsGridItem component
was crashing when opened due to a provider not
initialized.
2025-07-15 11:36:44 +02:00
renovate[bot] 0afc50fb93 ⬆️(dependencies) update js dependencies 2025-07-15 03:15:51 +00:00
renovate[bot] c48a4309c1 ⬆️(dependencies) update python dependencies 2025-07-11 06:14:43 +00:00
Anthony LC a212417fb8 🐛(frontend) fix action buttons not clickable (#1162)
If the title was too long, or the children deepness too deep, the action
buttons in the doc tree were not clickable.
This commit fixes the issue by ensuring that the action buttons are
always clickable, regardless of the title length or children depth.
2025-07-11 08:13:01 +02:00
Manuel Raynaud 500d4ea5ac 🐛(back) manage can-edit endpoint without created room in the ws (#1152)
In a scenario where the first user is editing a docs without websocket
and nobody has reached the websocket server first, the y-provider
service will return a 404 and we don't handle this case in the can-edit
endpoint leading to a server error.
2025-07-10 12:24:38 +00:00
Anthony LC 8a057b9c39 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-10 12:48:52 +02:00
Anthony LC 6a12ac560e 🌐(frontend) keep simple tag during export
When we export translations, we want to keep the
simple tags like `<strong>` instead of converting
it to `<1>` and `</1>`.
2025-07-10 12:38:28 +02:00
144 changed files with 19417 additions and 18133 deletions
-6
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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
+1 -2
View File
@@ -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
+79 -34
View File
@@ -8,6 +8,50 @@ 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) add multi columns support for editor #1219
- ✨(frontend) Can mask a document from the list view #1233
### 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
### 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
## [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
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
### Added
@@ -17,11 +61,11 @@ 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
- 🔧(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
@@ -120,7 +164,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
@@ -636,34 +679,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.0...main
[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
+4 -5
View File
@@ -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
+8 -22
View File
@@ -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
+1
View File
@@ -24,5 +24,6 @@ services:
restart: unless-stopped
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
+8
View File
@@ -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
+3 -2
View File
@@ -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 -1
View File
@@ -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
+22
View File
@@ -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)
+4
View File
@@ -66,6 +66,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists."""
is_favorite = serializers.BooleanField(read_only=True)
is_masked = serializers.BooleanField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = serializers.IntegerField(read_only=True)
user_role = serializers.SerializerMethodField(read_only=True)
@@ -85,6 +86,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"depth",
"excerpt",
"is_favorite",
"is_masked",
"link_role",
"link_reach",
"nb_accesses_ancestors",
@@ -107,6 +109,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"depth",
"excerpt",
"is_favorite",
"is_masked",
"link_role",
"link_reach",
"nb_accesses_ancestors",
@@ -176,6 +179,7 @@ class DocumentSerializer(ListDocumentSerializer):
"depth",
"excerpt",
"is_favorite",
"is_masked",
"link_role",
"link_reach",
"nb_accesses_ancestors",
+44 -8
View File
@@ -405,6 +405,7 @@ class DocumentViewSet(
queryset = super().filter_queryset(queryset)
user = self.request.user
queryset = queryset.annotate_is_favorite(user)
queryset = queryset.annotate_is_masked(user)
queryset = queryset.annotate_user_roles(user)
return queryset
@@ -453,11 +454,11 @@ class DocumentViewSet(
)
queryset = queryset.filter(path__in=root_paths)
# Annotate favorite status and filter if applicable as late as possible
# Annotate favorite and masked 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"]
)
queryset = queryset.annotate_is_masked(user)
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 +1110,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"""
+10 -1
View File
@@ -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",
),
),
]
+14
View File
@@ -326,6 +326,18 @@ class DocumentQuerySet(MP_NodeQuerySet):
return self.annotate(is_favorite=models.Value(False))
def annotate_is_masked(self, user):
"""
Annotate document queryset with the masked status for the current user.
"""
if user.is_authenticated:
masked_exists_subquery = LinkTrace.objects.filter(
document_id=models.OuterRef("pk"), user=user, is_masked=True
)
return self.annotate(is_masked=models.Exists(masked_exists_subquery))
return self.annotate(is_masked=models.Value(False))
def annotate_user_roles(self, user):
"""
Annotate document queryset with the roles of the current user
@@ -793,6 +805,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 +971,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"
+2 -1
View File
@@ -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."
@@ -62,10 +62,14 @@ class CollaborationService:
except requests.RequestException as e:
raise requests.HTTPError("Failed to get document connection info.") from e
if response.status_code != 200:
raise requests.HTTPError(
f"Failed to get document connection info. Status code: {response.status_code}, "
f"Response: {response.text}"
)
result = response.json()
return result.get("count", 0), result.get("exists", False)
if response.status_code == 200:
result = response.json()
return result.get("count", 0), result.get("exists", False)
if response.status_code == 404:
return 0, False
raise requests.HTTPError(
f"Failed to get document connection info. Status code: {response.status_code}, "
f"Response: {response.text}"
)
@@ -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"},
@@ -246,3 +246,73 @@ def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_webs
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found(
settings,
):
"""
When the websocket server returns a 404, the document can be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found_other_already_editing(
settings,
):
"""
When the websocket server returns a 404 and another user is editing the document,
the response should be can-edit=False.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
@@ -45,6 +45,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -67,6 +68,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -119,6 +121,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -141,6 +144,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -212,6 +216,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -234,6 +239,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -291,6 +297,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -313,6 +320,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -397,6 +405,7 @@ def test_api_documents_children_list_authenticated_related_direct(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -419,6 +428,7 @@ def test_api_documents_children_list_authenticated_related_direct(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -479,6 +489,7 @@ def test_api_documents_children_list_authenticated_related_parent(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -501,6 +512,7 @@ def test_api_documents_children_list_authenticated_related_parent(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -613,6 +625,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
@@ -635,6 +648,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -42,6 +42,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -66,6 +67,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -88,6 +90,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -139,6 +142,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -161,6 +165,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -183,6 +188,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -255,6 +261,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -277,6 +284,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -299,6 +307,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -356,6 +365,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -378,6 +388,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -400,6 +411,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -478,6 +490,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -500,6 +513,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -522,6 +536,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -580,6 +595,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -602,6 +618,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -624,6 +641,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -728,6 +746,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
@@ -750,6 +769,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"is_masked": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
@@ -772,6 +792,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"is_masked": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
@@ -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(
@@ -72,6 +72,7 @@ def test_api_documents_list_format():
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": True,
"is_masked": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
@@ -408,6 +409,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert len(results) == 5
assert all(result["is_favorite"] is False for result in results)
assert all(result["is_masked"] is False for result in results)
# Mark documents as favorite and check results again
for document in special_documents:
@@ -427,3 +429,5 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert result["is_favorite"] is True
else:
assert result["is_favorite"] is False
# All documents should be unmasked in this test
assert result["is_masked"] is False
@@ -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
@@ -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
@@ -539,6 +539,47 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
"""
When the WebSocket server does not have the room created, the logic should fallback to
no-WebSocket. If another user is already editing, the update must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_force_websocket_param_to_true(settings):
"""
@@ -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,
+60 -60
View File
@@ -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"
+21 -21
View File
@@ -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"
+9 -9
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.4.0"
version = "3.4.2"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -26,7 +26,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.39.3",
"boto3==1.39.4",
"Brotli==1.1.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
@@ -34,7 +34,7 @@ dependencies = [
"django-countries==7.6.1",
"django-csp==4.0",
"django-filter==25.1",
"django-lasuite[all]==0.0.10",
"django-lasuite[all]==0.0.11",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
@@ -52,9 +52,9 @@ dependencies = [
"markdown==3.8.2",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.93.0",
"openai==1.95.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.23",
"pycrdt==0.12.25",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
@@ -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('/');
@@ -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('/');
@@ -4,7 +4,7 @@ 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';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -346,4 +346,69 @@ 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');
});
});
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs } from './common';
import { createDoc, mockedListDocs } from './utils-common';
test.describe('Doc grid dnd', () => {
test('it creates a doc', async ({ page, browserName }) => {
@@ -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;
@@ -91,6 +91,22 @@ test.describe('Document grid item options', () => {
await page.goto('/');
});
test('it checks the share modal', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `check share modal`, browserName);
await page.goto('/');
await expect(page.getByText(docTitle)).toBeVisible();
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(
page.getByRole('dialog').getByText('Share the document'),
).toBeVisible();
});
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
@@ -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,7 +8,8 @@ import {
keyCloakSignIn,
mockedDocument,
verifyDocName,
} from './common';
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc Routing', () => {
test.beforeEach(async ({ page }) => {
@@ -60,16 +61,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 +89,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, randomName, 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,70 @@ 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 verifyDocName(page, firstDocTitle);
// Create a new doc - for the moment without children
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);
const [secondDocTitle] = randomName(
'My second sub page search',
browserName,
1,
);
await page
.getByRole('textbox', { name: 'doc title input' })
.fill(randomDocName);
.fill(secondDocTitle);
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
await createRootSubPage(page, browserName, secondDocTitle);
await searchButton.click();
await page
.getByRole('combobox', { name: 'Quick search input' })
.fill('sub page search');
// 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(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 };
};
@@ -127,42 +127,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 +265,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' }),
@@ -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,45 @@
import { Page, expect } from '@playwright/test';
import {
randomName,
updateDocTitle,
waitForResponseCreateDoc,
} from './utils-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();
};
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.4.0",
"version": "3.4.2",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.53.2",
"@playwright/test": "1.54.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-config-impress": "*",
+25 -24
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.4.0",
"version": "3.4.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,43 +16,44 @@
},
"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.35.0",
"@tanstack/react-query": "5.81.5",
"@sentry/nextjs": "9.42.0",
"@tanstack/react-query": "5.83.0",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"i18next": "25.3.1",
"i18next": "25.3.2",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.5",
"posthog-js": "1.256.2",
"luxon": "3.7.1",
"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.1",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.5",
"y-protocols": "1.0.6",
@@ -61,7 +62,7 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.81.5",
"@tanstack/react-query-devtools": "5.83.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
@@ -73,18 +74,18 @@
"@types/react": "*",
"@types/react-dom": "*",
"cross-env": "7.0.3",
"dotenv": "17.1.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.99.9",
"webpack": "5.100.2",
"workbox-webpack-plugin": "7.1.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;
@@ -28,8 +28,13 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { CalloutBlock, DividerBlock } from './custom-blocks';
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,
@@ -39,6 +44,9 @@ export const blockNoteSchema = withPageBreak(
}),
);
export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
baseBlockNoteSchema) as typeof baseBlockNoteSchema;
interface BlockNoteEditorProps {
doc: Doc;
provider: HocuspocusProvider;
@@ -116,7 +124,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,6 +137,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
},
uploadFile,
schema: blockNoteSchema,
dropCursor: multiColumnDropCursor,
},
[collabName, lang, provider, uploadFile],
);
@@ -15,6 +15,10 @@ import {
getCalloutReactSlashMenuItems,
getDividerReactSlashMenuItems,
} from './custom-blocks';
import XLMultiColumn from './xl-multi-column';
const getMultiColumnSlashMenuItems =
XLMultiColumn?.getMultiColumnSlashMenuItems;
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor<DocsBlockSchema>();
@@ -27,8 +31,9 @@ export const BlockNoteSuggestionMenu = () => {
filterSuggestionItems(
combineByGroup(
getDefaultReactSlashMenuItems(editor),
getPageBreakReactSlashMenuItems(editor),
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
getMultiColumnSlashMenuItems?.(editor) || [],
getPageBreakReactSlashMenuItems(editor),
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
),
query,
@@ -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,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;
@@ -32,6 +32,13 @@ jest.mock('@/features/docs/doc-export/', () => ({
ModalExport: () => <span>ModalExport</span>,
}));
jest.mock('next/router', () => ({
...jest.requireActual('next/router'),
useRouter: () => ({
push: jest.fn(),
}),
}));
const doc = {
nb_accesses: 1,
abilities: {
@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Tooltip } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -12,7 +12,6 @@ import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
KEY_SUB_PAGE,
useDocStore,
useTrans,
useUpdateDoc,
@@ -50,10 +49,10 @@ export const DocTitleText = () => {
const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const treeContext = useTreeContext<Doc>();
const { untitledDocument } = useTrans();
@@ -64,10 +63,16 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
onSuccess(updatedDoc) {
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
queryClient.setQueryData(
[KEY_SUB_PAGE, { id: updatedDoc.id }],
updatedDoc,
);
if (!treeContext) {
return;
}
if (treeContext.root?.id === updatedDoc.id) {
treeContext?.setRoot(updatedDoc);
} else {
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
}
},
});
@@ -1,11 +1,7 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import {
Button,
VariantType,
useModal,
useToastProvider,
} from '@openfun/cunningham-react';
import { Button, useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -28,6 +24,7 @@ import {
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
useDuplicateDoc,
useMaskDocOption,
} from '@/docs/doc-management';
import { DocShareModal } from '@/docs/doc-share';
import {
@@ -62,7 +59,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}, [doc, treeContext?.root]);
const queryClient = useQueryClient();
const { toast } = useToastProvider();
const router = useRouter();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -74,15 +71,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { isSmallMobile, isDesktop } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: () => {
toast(t('Document duplicated successfully!'), VariantType.SUCCESS, {
duration: 3000,
});
},
onError: () => {
toast(t('Failed to duplicate the document...'), VariantType.ERROR, {
duration: 3000,
});
onSuccess: (data) => {
void router.push(`/docs/${data.id}`);
},
});
const { isFeatureFlagActivated } = useAnalytics();
@@ -92,6 +82,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const maskDocOption = useMaskDocOption(doc);
useEffect(() => {
if (selectHistoryModal.isOpen) {
@@ -137,6 +128,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
showSeparator: true,
},
{
label: t('Version history'),
@@ -173,17 +165,23 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
canSave: doc.abilities.partial_update,
});
},
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
showSeparator: true,
},
];
const leaveDocOption: DropdownMenuOption = doc.abilities.destroy
? {
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
}
: maskDocOption;
options.push(leaveDocOption);
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
return (
@@ -4,7 +4,8 @@ export * from './useDeleteFavoriteDoc';
export * from './useDoc';
export * from './useDocOptions';
export * from './useDocs';
export * from './useSubDocs';
export * from './useDuplicateDoc';
export * from './useMaskDoc';
export * from './useSubDocs';
export * from './useUpdateDoc';
export * from './useUpdateDocLink';
@@ -19,7 +19,6 @@ export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
};
export const KEY_DOC = 'doc';
export const KEY_SUB_PAGE = 'sub-page';
export const KEY_DOC_VISIBILITY = 'doc-visibility';
export function useDoc(
@@ -16,6 +16,7 @@ export type DocsParams = {
is_creator_me?: boolean;
title?: string;
is_favorite?: boolean;
is_masked?: boolean;
};
export const constructParams = (params: DocsParams): URLSearchParams => {
@@ -36,6 +37,9 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
if (params.is_favorite !== undefined) {
searchParams.set('is_favorite', params.is_favorite.toString());
}
if (params.is_masked !== undefined) {
searchParams.set('is_masked', params.is_masked.toString());
}
return searchParams;
};
@@ -1,13 +1,14 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { toBase64 } from '@/docs/doc-editor';
import { KEY_DOC_TREE } from '@/docs/doc-tree';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { useProviderStore } from '../stores';
@@ -52,9 +53,10 @@ type DuplicateDocOptions = UseMutationOptions<
DuplicateDocParams
>;
export function useDuplicateDoc(options: DuplicateDocOptions) {
export function useDuplicateDoc(options?: DuplicateDocOptions) {
const queryClient = useQueryClient();
const { toast } = useToastProvider();
const { t } = useTranslation();
const { provider } = useProviderStore();
const { mutateAsync: updateDoc } = useUpdateDoc({
@@ -86,10 +88,19 @@ export function useDuplicateDoc(options: DuplicateDocOptions) {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
void queryClient.resetQueries({
queryKey: [KEY_DOC_TREE],
toast(t('Document duplicated successfully!'), VariantType.SUCCESS, {
duration: 3000,
});
void options.onSuccess?.(data, variables, context);
void options?.onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
toast(t('Failed to duplicate the document...'), VariantType.ERROR, {
duration: 3000,
});
void options?.onError?.(error, variables, context);
},
});
}
@@ -0,0 +1,77 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/docs/doc-management';
export type MaskDocParams = Pick<Doc, 'id'>;
export const maskDoc = async ({ id }: MaskDocParams) => {
const response = await fetchAPI(`documents/${id}/mask/`, {
method: 'POST',
});
if (!response.ok) {
throw new APIError(
'Failed to make the doc as masked',
await errorCauses(response),
);
}
};
interface MaskDocProps {
onSuccess?: () => void;
listInvalideQueries?: string[];
}
export function useMaskDoc({ onSuccess, listInvalideQueries }: MaskDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, MaskDocParams>({
mutationFn: maskDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
onSuccess?.();
},
});
}
export type DeleteMaskDocParams = Pick<Doc, 'id'>;
export const deleteMaskDoc = async ({ id }: DeleteMaskDocParams) => {
const response = await fetchAPI(`documents/${id}/mask/`, {
method: 'DELETE',
});
if (!response.ok) {
throw new APIError(
'Failed to remove the doc as masked',
await errorCauses(response),
);
}
};
interface DeleteMaskDocProps {
onSuccess?: () => void;
listInvalideQueries?: string[];
}
export function useDeleteMaskDoc({
onSuccess,
listInvalideQueries,
}: DeleteMaskDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteMaskDocParams>({
mutationFn: deleteMaskDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
onSuccess?.();
},
});
}
@@ -52,10 +52,16 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
void queryConfig.onSuccess(data, variables, context);
}
},
onError: () => {
onError: (error, variables, context) => {
// If error it means the user is probably not allowed to edit the doc
// so we invalidate the canEdit query to update the UI accordingly
void queryClient.invalidateQueries({
queryKey: [KEY_CAN_EDIT],
});
if (queryConfig?.onError) {
queryConfig.onError(error, variables, context);
}
},
});
}
@@ -1,4 +1,6 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc, KEY_DOC } from '@/docs/doc-management';
@@ -39,6 +41,8 @@ export function useUpdateDocLink({
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
const { toast } = useToastProvider();
const { t } = useTranslation();
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
@@ -52,6 +56,14 @@ export function useUpdateDocLink({
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.id}`);
toast(
t('The document visibility has been updated.'),
VariantType.SUCCESS,
{
duration: 2000,
},
);
onSuccess?.(data);
},
});
@@ -15,15 +15,15 @@ import { useRemoveDoc } from '../api/useRemoveDoc';
import { Doc } from '../types';
interface ModalRemoveDocProps {
onClose: () => void;
doc: Doc;
afterDelete?: (doc: Doc) => void;
onClose: () => void;
onSuccess?: (doc: Doc) => void;
}
export const ModalRemoveDoc = ({
onClose,
doc,
afterDelete,
onClose,
onSuccess,
}: ModalRemoveDocProps) => {
const { toast } = useToastProvider();
const { t } = useTranslation();
@@ -35,19 +35,17 @@ export const ModalRemoveDoc = ({
error,
} = useRemoveDoc({
onSuccess: () => {
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
if (afterDelete) {
afterDelete(doc);
return;
}
if (pathname === '/') {
if (onSuccess) {
onSuccess(doc);
} else if (pathname === '/') {
onClose();
} else {
void push('/');
}
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
},
});
@@ -1,4 +1,6 @@
export * from './useCollaboration';
export * from './useCopyDocLink';
export * from './useDocUtils';
export * from './useIsCollaborativeEditable';
export * from './useMaskDocOption';
export * from './useTrans';
@@ -0,0 +1,14 @@
import { Doc } from '@/docs/doc-management';
export const useDocUtils = (doc: Doc) => {
return {
isTopRoot: doc.depth === 1,
isChild: doc.depth > 1,
hasChildren: doc.numchild > 0,
isDesynchronized: !!(
doc.ancestors_link_reach &&
(doc.computed_link_reach !== doc.ancestors_link_reach ||
doc.computed_link_role !== doc.ancestors_link_role)
),
} as const;
};
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { DropdownMenuOption } from '@/components';
import { KEY_DOC, KEY_LIST_DOC, useDeleteMaskDoc, useMaskDoc } from '../api';
import { Doc } from '../types';
export const useMaskDocOption = (doc: Doc) => {
const { t } = useTranslation();
const maskDoc = useMaskDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const deleteMaskDoc = useDeleteMaskDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const leaveDocOption: DropdownMenuOption = doc.is_masked
? {
label: t('Join the doc'),
icon: 'login',
callback: () => {
deleteMaskDoc.mutate({
id: doc.id,
});
},
disabled: !doc.abilities.mask,
testId: `docs-grid-actions-mask-${doc.id}`,
}
: {
label: t('Leave doc'),
icon: 'logout',
callback: () => {
maskDoc.mutate({
id: doc.id,
});
},
disabled: !doc.abilities.mask,
testId: `docs-grid-actions-mask-${doc.id}`,
};
return leaveDocOption;
};
@@ -59,6 +59,7 @@ export interface Doc {
depth: number;
path: string;
is_favorite: boolean;
is_masked: boolean;
link_reach: LinkReach;
link_role: LinkRole;
nb_accesses_direct: number;
@@ -84,6 +85,7 @@ export interface Doc {
favorite: boolean;
invite_owner: boolean;
link_configuration: boolean;
mask: boolean;
media_auth: boolean;
move: boolean;
partial_update: boolean;
@@ -7,9 +7,9 @@ import { useDebouncedCallback } from 'use-debounce';
import { Box } from '@/components';
import { QuickSearch } from '@/components/quick-search';
import { Doc, useDocUtils } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { Doc } from '../../doc-management';
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
import { DocSearchContent } from './DocSearchContent';
@@ -20,18 +20,18 @@ import {
} from './DocSearchFilters';
import { DocSearchSubPageContent } from './DocSearchSubPageContent';
type DocSearchModalProps = {
type DocSearchModalGlobalProps = {
onClose: () => void;
isOpen: boolean;
showFilters?: boolean;
defaultFilters?: DocSearchFiltersValues;
};
export const DocSearchModal = ({
const DocSearchModalGlobal = ({
showFilters = false,
defaultFilters,
...modalProps
}: DocSearchModalProps) => {
}: DocSearchModalGlobalProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
@@ -126,3 +126,42 @@ export const DocSearchModal = ({
</Modal>
);
};
type DocSearchModalDetailProps = DocSearchModalGlobalProps & {
doc: Doc;
};
const DocSearchModalDetail = ({
doc,
...modalProps
}: DocSearchModalDetailProps) => {
const { hasChildren, isChild } = useDocUtils(doc);
const isWithChildren = isChild || hasChildren;
let defaultFilters = DocSearchTarget.ALL;
let showFilters = false;
if (isWithChildren) {
defaultFilters = DocSearchTarget.CURRENT;
showFilters = true;
}
return (
<DocSearchModalGlobal
{...modalProps}
showFilters={showFilters}
defaultFilters={{ target: defaultFilters }}
/>
);
};
type DocSearchModalProps = DocSearchModalGlobalProps & {
doc?: Doc;
};
export const DocSearchModal = ({ doc, ...modalProps }: DocSearchModalProps) => {
if (doc) {
return <DocSearchModalDetail doc={doc} {...modalProps} />;
}
return <DocSearchModalGlobal {...modalProps} />;
};

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