Compare commits

...

26 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
119 changed files with 18969 additions and 18029 deletions
+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
+58 -36
View File
@@ -8,6 +8,29 @@ 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
@@ -18,7 +41,6 @@ and this project adheres to
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
## [3.4.1] - 2025-07-15
### Fixed
@@ -28,6 +50,7 @@ and this project adheres to
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
@@ -42,7 +65,7 @@ and this project adheres to
- ✨(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
@@ -141,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
@@ -657,36 +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.2...main
[v3.4.2]: https://github.com/numerique-gouv/impress/releases/v3.4.2
[v3.4.1]: https://github.com/numerique-gouv/impress/releases/v3.4.1
[v3.4.0]: https://github.com/numerique-gouv/impress/releases/v3.4.0
[v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0
[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1
[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.4.2...main
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
+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"
@@ -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
@@ -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,
+4 -4
View File
@@ -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;
@@ -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();
};
+19 -18
View File
@@ -16,23 +16,24 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.33.0",
"@blocknote/core": "0.33.0",
"@blocknote/mantine": "0.33.0",
"@blocknote/react": "0.33.0",
"@blocknote/xl-docx-exporter": "0.33.0",
"@blocknote/xl-pdf-exporter": "0.33.0",
"@blocknote/code-block": "0.35.0",
"@blocknote/core": "0.35.0",
"@blocknote/mantine": "0.35.0",
"@blocknote/react": "0.35.0",
"@blocknote/xl-docx-exporter": "0.35.0",
"@blocknote/xl-multi-column": "0.35.0",
"@blocknote/xl-pdf-exporter": "0.35.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.8.2",
"@gouvfr-lasuite/ui-kit": "0.10.0",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.1.0",
"@openfun/cunningham-react": "3.2.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.38.0",
"@sentry/nextjs": "9.42.0",
"@tanstack/react-query": "5.83.0",
"canvg": "4.0.3",
"clsx": "2.1.1",
@@ -45,12 +46,12 @@
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.1",
"next": "15.4.1",
"posthog-js": "1.257.0",
"next": "15.4.4",
"posthog-js": "1.258.2",
"react": "*",
"react-aria-components": "1.10.1",
"react-aria-components": "1.11.0",
"react-dom": "*",
"react-i18next": "15.6.0",
"react-i18next": "15.6.1",
"react-intersection-observer": "9.16.0",
"react-select": "5.10.2",
"styled-components": "6.1.19",
@@ -73,18 +74,18 @@
"@types/react": "*",
"@types/react-dom": "*",
"cross-env": "7.0.3",
"dotenv": "17.2.0",
"dotenv": "17.2.1",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",
"jest": "30.0.4",
"jest-environment-jsdom": "30.0.4",
"jest": "30.0.5",
"jest-environment-jsdom": "30.0.5",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.21.1",
"stylelint": "16.22.0",
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"webpack": "5.100.1",
"webpack": "5.100.2",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -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} />;
};
@@ -0,0 +1,67 @@
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
useUpdateDocLink,
} from '@/docs/doc-management';
import Desync from './../assets/desynchro.svg';
import Undo from './../assets/undo.svg';
interface DocDesynchronizedProps {
doc: Doc;
}
export const DocDesynchronized = ({ doc }: DocDesynchronizedProps) => {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
return (
<Box
$background={colorsTokens['primary-100']}
$padding="3xs"
$direction="row"
$align="center"
$justify="space-between"
$gap={spacingsTokens['4xs']}
$color={colorsTokens['primary-800']}
$css={css`
border: 1px solid ${colorsTokens['primary-300']};
border-radius: ${spacingsTokens['2xs']};
`}
>
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
<Desync />
<Text $size="xs" $theme="primary" $variation="800" $weight="400">
{t('The link sharing rules differ from the parent document')}
</Text>
</Box>
{doc.abilities.accesses_manage && (
<Button
onClick={() =>
updateDocLink({
id: doc.id,
link_reach: doc.ancestors_link_reach,
link_role: doc?.ancestors_link_role || undefined,
})
}
size="small"
color="primary-text"
icon={<Undo />}
>
{t('Restore')}
</Button>
)}
</Box>
);
};
@@ -1,17 +1,10 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Text } from '@/components';
import {
Access,
Doc,
KEY_SUB_PAGE,
Role,
useTrans,
} from '@/docs/doc-management/';
import { Access, Doc, Role, useTrans } from '@/docs/doc-management/';
import { useDeleteDocAccess, useDeleteDocInvitation } from '../api';
import { Invitation, isInvitation } from '../types';
@@ -39,19 +32,9 @@ export const DocRoleDropdown = ({
}: DocRoleDropdownProps) => {
const { t } = useTranslation();
const { transRole, translatedRoles } = useTrans();
const queryClient = useQueryClient();
const { toast } = useToastProvider();
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
onSuccess: () => {
if (!doc) {
return;
}
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during delete invitation'),
@@ -64,14 +47,6 @@ export const DocRoleDropdown = ({
});
const { mutate: removeDocAccess } = useDeleteDocAccess({
onSuccess: () => {
if (!doc) {
return;
}
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: () => {
toast(t('Error while deleting invitation'), VariantType.ERROR, {
duration: 4000,
@@ -3,7 +3,6 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -11,7 +10,7 @@ import { css } from 'styled-components';
import { APIError } from '@/api';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
@@ -45,7 +44,6 @@ export const DocShareAddMemberList = ({
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
const canShare = doc.abilities.accesses_manage;
const queryClient = useQueryClient();
const { mutateAsync: createInvitation } = useCreateDocInvitation();
const { mutateAsync: createDocAccess } = useCreateDocAccess();
@@ -91,32 +89,14 @@ export const DocShareAddMemberList = ({
};
return isInvitationMode
? createInvitation(
{
...payload,
email: user.email,
},
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
},
)
: createDocAccess(
{
...payload,
memberId: user.id,
},
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
},
);
? createInvitation({
...payload,
email: user.email,
})
: createDocAccess({
...payload,
memberId: user.id,
});
});
const settledPromises = await Promise.allSettled(promises);
@@ -1,5 +1,4 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -15,7 +14,7 @@ import {
} from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import {
@@ -38,7 +37,6 @@ export const DocShareInvitationItem = ({
invitation,
}: DocShareInvitationItemProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { spacingsTokens } = useCunninghamTheme();
const invitedUser: User = {
id: invitation.email,
@@ -52,11 +50,6 @@ export const DocShareInvitationItem = ({
const canUpdate = doc.abilities.accesses_manage;
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during update invitation'),
@@ -69,11 +62,6 @@ export const DocShareInvitationItem = ({
});
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during delete invitation'),
@@ -1,5 +1,4 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -7,7 +6,7 @@ import { Box } from '@/components';
import { QuickSearchData } from '@/components/quick-search';
import { QuickSearchGroup } from '@/components/quick-search/QuickSearchGroup';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/';
import { Access, Doc, Role } from '@/docs/doc-management/';
import { useDocAccesses, useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks/';
@@ -26,7 +25,6 @@ export const DocShareMemberItem = ({
isInherited = false,
}: Props) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { isLastOwner } = useWhoAmI(access);
const { toast } = useToastProvider();
@@ -39,14 +37,6 @@ export const DocShareMemberItem = ({
: undefined;
const { mutate: updateDocAccess } = useUpdateDocAccess({
onSuccess: () => {
if (!doc) {
return;
}
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: () => {
toast(t('Error while updating the member role.'), VariantType.ERROR, {
duration: 4000,
@@ -51,8 +51,18 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const { isDesktop } = useResponsiveStore();
/**
* The modal content height is calculated based on the viewport height.
* The formula is:
* 100dvh - 2em - 12px - 34px
* - 34px is the height of the modal title in mobile
* - 2em is the padding of the modal content
* - 12px is the padding of the modal footer
* - 690px is the height of the content in desktop
* This ensures that the modal content is always visible and does not overflow.
*/
const modalContentHeight = isDesktop
? 'min(690px, calc(100dvh - 2em - 12px - 34px))' // 100dvh - 2em - 12px is the max cunningham modal height. 690px is the height of the content in desktop ad 34px is the height of the modal title in mobile
? 'min(690px, calc(100dvh - 2em - 12px - 34px))'
: `calc(100dvh - 34px)`;
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [userQuery, setUserQuery] = useState('');
@@ -230,13 +240,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
</Box>
<Box ref={handleRef}>
{showFooter && (
<DocShareModalFooter
doc={doc}
onClose={onClose}
canEditVisibility={canShare}
/>
)}
{showFooter && <DocShareModalFooter doc={doc} onClose={onClose} />}
</Box>
</Box>
</Modal>
@@ -7,17 +7,15 @@ import { Doc, useCopyDocLink } from '@/docs/doc-management';
import { DocVisibility } from './DocVisibility';
type Props = {
type DocShareModalFooterProps = {
doc: Doc;
onClose: () => void;
canEditVisibility?: boolean;
};
export const DocShareModalFooter = ({
doc,
onClose,
canEditVisibility = true,
}: Props) => {
}: DocShareModalFooterProps) => {
const copyDocLink = useCopyDocLink(doc.id);
const { t } = useTranslation();
return (
@@ -29,7 +27,7 @@ export const DocShareModalFooter = ({
>
<HorizontalSeparator $withPadding={true} customPadding="12px" />
<DocVisibility doc={doc} canEdit={canEditVisibility} />
<DocVisibility doc={doc} />
<HorizontalSeparator customPadding="12px" />
<Box
@@ -1,9 +1,4 @@
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useCallback, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -22,96 +17,61 @@ import {
LinkReach,
LinkRole,
getDocLinkReach,
useDocUtils,
useUpdateDocLink,
} from '@/docs/doc-management';
import { useTreeUtils } from '@/docs/doc-tree';
import { useResponsiveStore } from '@/stores';
import { useTranslatedShareSettings } from '../hooks/';
import Desync from './../assets/desynchro.svg';
import Undo from './../assets/undo.svg';
import { DocDesynchronized } from './DocDesynchronized';
interface DocVisibilityProps {
doc: Doc;
canEdit?: boolean;
}
export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { isDesktop } = useResponsiveStore();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const canManage = doc.abilities.accesses_manage && canEdit;
const [linkReach, setLinkReach] = useState<LinkReach>(getDocLinkReach(doc));
const [docLinkRole, setDocLinkRole] = useState<LinkRole>(
doc.computed_link_role ?? LinkRole.READER,
);
const { isDesyncronized } = useTreeUtils(doc);
const canManage = doc.abilities.accesses_manage;
const docLinkReach = getDocLinkReach(doc);
const docLinkRole = doc.computed_link_role ?? LinkRole.READER;
const { isDesynchronized } = useDocUtils(doc);
const { linkModeTranslations, linkReachChoices, linkReachTranslations } =
useTranslatedShareSettings();
const description =
docLinkRole === LinkRole.READER
? linkReachChoices[linkReach].descriptionReadOnly
: linkReachChoices[linkReach].descriptionEdit;
? linkReachChoices[docLinkReach].descriptionReadOnly
: linkReachChoices[docLinkReach].descriptionEdit;
const api = useUpdateDocLink({
onSuccess: () => {
toast(
t('The document visibility has been updated.'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
},
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const updateReach = useCallback(
(link_reach: LinkReach, link_role?: LinkRole) => {
const params: {
id: string;
link_reach: LinkReach;
link_role?: LinkRole;
} = {
id: doc.id,
link_reach,
};
api.mutate(params);
setLinkReach(link_reach);
if (link_role) {
params.link_role = link_role;
setDocLinkRole(link_role);
}
},
[api, doc.id],
);
const updateLinkRole = useCallback(
(link_role: LinkRole) => {
api.mutate({ id: doc.id, link_role });
setDocLinkRole(link_role);
},
[api, doc.id],
);
const linkReachOptions: DropdownMenuOption[] = useMemo(() => {
return Object.values(LinkReach).map((key) => {
const isDisabled =
doc.abilities.link_select_options[key as LinkReach] === undefined;
const isDisabled = doc.abilities.link_select_options[key] === undefined;
return {
label: linkReachTranslations[key as LinkReach],
callback: () => updateReach(key as LinkReach),
isSelected: linkReach === (key as LinkReach),
label: linkReachTranslations[key],
callback: () =>
updateDocLink({
id: doc.id,
link_reach: key,
}),
isSelected: docLinkReach === key,
disabled: isDisabled,
};
});
}, [doc, linkReach, linkReachTranslations, updateReach]);
}, [
doc.abilities.link_select_options,
doc.id,
docLinkReach,
linkReachTranslations,
updateDocLink,
]);
const haveDisabledOptions = linkReachOptions.some(
(option) => option.disabled,
@@ -120,41 +80,29 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
const showLinkRoleOptions = doc.computed_link_reach !== LinkReach.RESTRICTED;
const linkRoleOptions: DropdownMenuOption[] = useMemo(() => {
const options = doc.abilities.link_select_options[linkReach] ?? [];
const options = doc.abilities.link_select_options[docLinkReach] ?? [];
return Object.values(LinkRole).map((key) => {
const isDisabled = !options.includes(key);
return {
label: linkModeTranslations[key],
callback: () => updateLinkRole(key),
callback: () => updateDocLink({ id: doc.id, link_role: key }),
isSelected: docLinkRole === key,
disabled: isDisabled,
};
});
}, [doc, docLinkRole, linkModeTranslations, updateLinkRole, linkReach]);
}, [
doc.abilities.link_select_options,
doc.id,
docLinkReach,
docLinkRole,
linkModeTranslations,
updateDocLink,
]);
const haveDisabledLinkRoleOptions = linkRoleOptions.some(
(option) => option.disabled,
);
const undoDesync = () => {
const params: {
id: string;
link_reach: LinkReach;
link_role?: LinkRole;
} = {
id: doc.id,
link_reach: doc.ancestors_link_reach,
};
if (doc.ancestors_link_role) {
params.link_role = doc.ancestors_link_role;
}
api.mutate(params);
setLinkReach(doc.ancestors_link_reach);
if (doc.ancestors_link_role) {
setDocLinkRole(doc.ancestors_link_role);
}
};
return (
<Box
$padding={{ horizontal: 'base' }}
@@ -163,40 +111,9 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
className="--docs--doc-visibility"
>
<Text $weight="700" $size="sm" $variation="700">
{t('Link parameters')}
{t('Link settings')}
</Text>
{isDesyncronized && (
<Box
$background={colorsTokens['primary-100']}
$padding="3xs"
$direction="row"
$align="center"
$justify="space-between"
$gap={spacingsTokens['4xs']}
$color={colorsTokens['primary-800']}
$css={css`
border: 1px solid ${colorsTokens['primary-300']};
border-radius: ${spacingsTokens['2xs']};
`}
>
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
<Desync />
<Text $size="xs" $theme="primary" $variation="800" $weight="400">
{t('Sharing rules differ from the parent page')}
</Text>
</Box>
{doc.abilities.accesses_manage && (
<Button
onClick={undoDesync}
size="small"
color="primary-text"
icon={<Undo />}
>
{t('Restore')}
</Button>
)}
</Box>
)}
{isDesynchronized && <DocDesynchronized doc={doc} />}
<Box
$direction="row"
$align="center"
@@ -231,7 +148,7 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
<Icon
$theme={canManage ? 'primary' : 'greyscale'}
$variation={canManage ? '800' : '600'}
iconName={linkReachChoices[linkReach].icon}
iconName={linkReachChoices[docLinkReach].icon}
/>
<Text
$theme={canManage ? 'primary' : 'greyscale'}
@@ -239,7 +156,7 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
$weight="500"
$size="md"
>
{linkReachChoices[linkReach].label}
{linkReachChoices[docLinkReach].label}
</Text>
</Box>
</DropdownMenu>
@@ -251,7 +168,7 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
</Box>
{showLinkRoleOptions && (
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
{linkReach !== LinkReach.RESTRICTED && (
{docLinkReach !== LinkReach.RESTRICTED && (
<DropdownMenu
disabled={!canManage}
showArrow={true}
@@ -9,11 +9,7 @@ export type DocsTreeParams = {
};
export const getDocTree = async ({ docId }: DocsTreeParams): Promise<Doc> => {
const searchParams = new URLSearchParams();
const response = await fetchAPI(
`documents/${docId}/tree/?${searchParams.toString()}`,
);
const response = await fetchAPI(`documents/${docId}/tree/`);
if (!response.ok) {
throw new APIError(
@@ -29,10 +25,7 @@ export const KEY_DOC_TREE = 'doc-tree';
export function useDocTree(
params: DocsTreeParams,
queryConfig?: Omit<
UseQueryOptions<Doc, APIError, Doc>,
'queryKey' | 'queryFn'
>,
queryConfig?: UseQueryOptions<Doc, APIError, Doc>,
) {
return useQuery<Doc, APIError, Doc>({
queryKey: [KEY_DOC_TREE, params],
@@ -4,17 +4,12 @@ import {
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_SUB_PAGE,
useDoc,
useTrans,
} from '@/features/docs/doc-management';
import { Doc, useTrans } from '@/features/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
@@ -31,8 +26,7 @@ const ItemTextCss = css`
-webkit-box-orient: vertical;
`;
type Props = TreeViewNodeProps<Doc>;
export const DocSubPageItem = (props: Props) => {
export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const doc = props.node.data.value as Doc;
const treeContext = useTreeContext<Doc>();
const { untitledDocument } = useTrans();
@@ -44,28 +38,6 @@ export const DocSubPageItem = (props: Props) => {
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
const isInitialLoad = useRef(false);
const { data: docQuery } = useDoc(
{ id: doc.id },
{
initialData: doc,
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
useEffect(() => {
if (docQuery && isInitialLoad.current === true) {
treeContext?.treeData.updateNode(docQuery.id, docQuery);
}
if (docQuery) {
isInitialLoad.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docQuery]);
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
@@ -5,52 +5,46 @@ import {
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, KEY_SUB_PAGE, useDoc, useDocStore } from '@/docs/doc-management';
import { Doc } from '@/docs/doc-management';
import { SimpleDocItem } from '@/docs/docs-grid';
import { useDocTree } from '../api/useDocTree';
import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
import { useMoveDoc } from '../api/useMove';
import { findIndexInTree } from '../utils';
import { DocSubPageItem } from './DocSubPageItem';
import { DocTreeItemActions } from './DocTreeItemActions';
type DocTreeProps = {
initialTargetId: string;
currentDoc: Doc;
};
export const DocTree = ({ initialTargetId }: DocTreeProps) => {
export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const treeContext = useTreeContext<Doc>();
const { currentDoc } = useDocStore();
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const previousDocId = useRef<string | null>(initialTargetId);
const { data: rootNode } = useDoc(
{ id: treeContext?.root?.id ?? '' },
{
enabled: !!treeContext?.root?.id,
initialData: treeContext?.root ?? undefined,
queryKey: [KEY_SUB_PAGE, { id: treeContext?.root?.id ?? '' }],
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined,
);
const { mutate: moveDoc } = useMoveDoc();
const { data } = useDocTree({
docId: initialTargetId,
});
const { data: tree, isFetching } = useDocTree(
{
docId: currentDoc.id,
},
{
enabled: !!!treeContext?.root?.id,
queryKey: [KEY_DOC_TREE, { id: currentDoc.id }],
},
);
const handleMove = (result: TreeViewMoveResult) => {
moveDoc({
@@ -61,12 +55,55 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
treeContext?.treeData.handleMove(result);
};
useEffect(() => {
if (!data) {
/**
* This function resets the tree states.
*/
const resetStateTree = useCallback(() => {
if (!treeContext?.root?.id) {
return;
}
const { children: rootChildren, ...root } = data;
treeContext?.setRoot(null);
setInitialOpenState(undefined);
}, [treeContext]);
/**
* This effect is used to reset the tree when a new document
* that is not part of the current tree is loaded.
*/
useEffect(() => {
if (!treeContext?.root?.id) {
return;
}
const index = findIndexInTree(treeContext.treeData.nodes, currentDoc.id);
if (index === -1 && currentDoc.id !== treeContext.root?.id) {
resetStateTree();
return;
}
}, [currentDoc, resetStateTree, treeContext]);
/**
* This effect is used to reset the tree when the component is unmounted.
*/
useEffect(() => {
return () => {
resetStateTree();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* This effect is used to set the initial open state of the tree when the tree is loaded.
* If the treeContext is already set, we do not need to set it again.
*/
useEffect(() => {
if (!tree || treeContext?.root?.id || isFetching) {
return;
}
const { children: rootChildren, ...root } = tree;
const children = rootChildren ?? [];
treeContext?.setRoot(root);
const initialOpenState: OpenMap = {};
@@ -84,50 +121,30 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
treeContext?.treeData.resetTree(children);
setInitialOpenState(initialOpenState);
if (initialTargetId === root.id) {
treeContext?.treeData.setSelectedNode(root);
} else {
treeContext?.treeData.selectNodeById(initialTargetId);
}
// Because treeData change in the treeContext, we have a infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, initialTargetId]);
}, [tree, treeContext, isFetching]);
/**
* This effect is used to select the current document in the tree
*/
useEffect(() => {
if (
!currentDoc ||
(previousDocId.current && previousDocId.current === currentDoc.id)
) {
if (!treeContext || !treeContext.root?.id) {
return;
}
const item = treeContext?.treeData.getNode(currentDoc?.id ?? '');
if (!item && currentDoc.id !== rootNode?.id) {
treeContext?.treeData.resetTree([]);
treeContext?.setRoot(currentDoc);
treeContext?.setInitialTargetId(currentDoc.id);
} else if (item) {
const { children: _children, ...leftDoc } = currentDoc;
treeContext?.treeData.updateNode(currentDoc.id, {
...leftDoc,
childrenCount: leftDoc.numchild,
});
}
if (currentDoc?.id && currentDoc?.id !== previousDocId.current) {
previousDocId.current = currentDoc?.id;
if (currentDoc.id === treeContext?.root?.id) {
treeContext?.treeData.setSelectedNode(treeContext?.root);
} else {
treeContext?.treeData.selectNodeById(currentDoc.id);
}
}, [currentDoc, treeContext]);
treeContext?.treeData.setSelectedNode(currentDoc);
}, [currentDoc, rootNode?.id, treeContext]);
const rootIsSelected =
treeContext?.treeData.selectedNode?.id === treeContext?.root?.id;
if (!initialTargetId || !treeContext) {
if (!treeContext || !treeContext.root) {
return null;
}
const rootIsSelected =
treeContext.treeData.selectedNode?.id === treeContext.root.id;
return (
<Box
data-testid="doc-tree"
@@ -178,42 +195,39 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
}
`}
>
{treeContext.root !== null && rootNode && (
<StyledLink
$css={css`
width: 100%;
`}
href={`/docs/${treeContext.root.id}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
treeContext.treeData.setSelectedNode(
treeContext.root ?? undefined,
);
router.push(`/docs/${treeContext?.root?.id}`);
}}
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={rootNode} showAccesses={true} />
<div className="doc-tree-root-item-actions">
<DocTreeItemActions
doc={rootNode}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: treeContext.root?.id ?? undefined,
};
treeContext?.treeData.addChild(null, newDoc);
}}
isOpen={rootActionsOpen}
onOpenChange={setRootActionsOpen}
/>
</div>
</Box>
</StyledLink>
)}
<StyledLink
$css={css`
width: 100%;
`}
href={`/docs/${treeContext.root.id}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
treeContext.treeData.setSelectedNode(
treeContext.root ?? undefined,
);
router.push(`/docs/${treeContext?.root?.id}`);
}}
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={treeContext.root} showAccesses={true} />
<DocTreeItemActions
doc={treeContext.root}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: treeContext.root?.id ?? undefined,
};
treeContext?.treeData.addChild(null, newDoc);
}}
isOpen={rootActionsOpen}
isRoot={true}
onOpenChange={setRootActionsOpen}
/>
</Box>
</StyledLink>
</Box>
</Box>
@@ -227,20 +241,17 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
undefined
}
canDrop={({ parentNode }) => {
if (!rootNode) {
return false;
}
const parentDoc = parentNode?.data.value as Doc;
if (!parentDoc) {
return rootNode?.abilities.move;
return currentDoc.abilities.move;
}
return parentDoc?.abilities.move;
return parentDoc.abilities.move;
}}
canDrag={(node) => {
const doc = node.value as Doc;
return doc.abilities.move;
}}
rootNodeId={treeContext.root?.id ?? ''}
rootNodeId={treeContext.root.id}
renderNode={DocSubPageItem}
/>
)}
@@ -5,46 +5,52 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useModal } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import {
Doc,
ModalRemoveDoc,
Role,
useCopyDocLink,
} from '../../doc-management';
useDuplicateDoc,
} from '@/docs/doc-management';
import { useCreateChildrenDoc } from '../api/useCreateChildren';
import { useDetachDoc } from '../api/useDetach';
import MoveDocIcon from '../assets/doc-extract-bold.svg';
import { useTreeUtils } from '../hooks';
type DocTreeItemActionsProps = {
doc: Doc;
parentId?: string | null;
onCreateSuccess?: (newDoc: Doc) => void;
isOpen?: boolean;
isRoot?: boolean;
onCreateSuccess?: (newDoc: Doc) => void;
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
};
export const DocTreeItemActions = ({
doc,
parentId,
onCreateSuccess,
isOpen,
isRoot = false,
onCreateSuccess,
onOpenChange,
parentId,
}: DocTreeItemActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
const deleteModal = useModal();
const copyLink = useCopyDocLink(doc.id);
const { isCurrentParent } = useTreeUtils(doc);
const { mutate: detachDoc } = useDetachDoc();
const treeContext = useTreeContext<Doc>();
const treeContext = useTreeContext<Doc | null>();
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: (duplicatedDoc) => {
// Reset the tree context root will reset the full tree view.
treeContext?.setRoot(null);
void router.push(`/docs/${duplicatedDoc.id}`);
},
});
const handleDetachDoc = () => {
if (!treeContext?.root) {
@@ -55,10 +61,13 @@ export const DocTreeItemActions = ({
{ documentId: doc.id, rootId: treeContext.root.id },
{
onSuccess: () => {
treeContext.treeData.deleteNode(doc.id);
if (treeContext.root) {
treeContext.treeData.setSelectedNode(treeContext.root);
void router.push(`/docs/${treeContext.root.id}`);
void router.push(`/docs/${treeContext.root.id}`).then(() => {
setTimeout(() => {
treeContext?.treeData.deleteNode(doc.id);
}, 100);
});
}
},
},
@@ -71,7 +80,7 @@ export const DocTreeItemActions = ({
icon: <Icon iconName="link" $size="24px" />,
callback: copyLink,
},
...(!isCurrentParent
...(!isRoot
? [
{
label: t('Move to my docs'),
@@ -89,6 +98,18 @@ export const DocTreeItemActions = ({
},
]
: []),
{
label: t('Duplicate'),
icon: <Icon $variation="600" iconName="content_copy" />,
isDisabled: !doc.abilities.duplicate,
callback: () => {
duplicateDoc({
docId: doc.id,
with_accesses: false,
canSave: doc.abilities.partial_update,
});
},
},
{
label: t('Delete'),
isDisabled: !doc.abilities.destroy,
@@ -104,20 +125,26 @@ export const DocTreeItemActions = ({
},
});
const afterDelete = () => {
const onSuccessDelete = () => {
if (parentId) {
treeContext?.treeData.deleteNode(doc.id);
void router.push(`/docs/${parentId}`);
void router.push(`/docs/${parentId}`).then(() => {
setTimeout(() => {
treeContext?.treeData.deleteNode(doc.id);
}, 100);
});
} else if (doc.id === treeContext?.root?.id && !parentId) {
void router.push(`/docs/`);
void router.push(`/`);
} else if (treeContext && treeContext.root) {
treeContext?.treeData.deleteNode(doc.id);
void router.push(`/docs/${treeContext.root.id}`);
void router.push(`/docs/${treeContext.root.id}`).then(() => {
setTimeout(() => {
treeContext?.treeData.deleteNode(doc.id);
}, 100);
});
}
};
return (
<Fragment>
<Box className="doc-tree-root-item-actions">
<Box
$direction="row"
$align="center"
@@ -166,9 +193,9 @@ export const DocTreeItemActions = ({
<ModalRemoveDoc
onClose={deleteModal.onClose}
doc={doc}
afterDelete={afterDelete}
onSuccess={onSuccessDelete}
/>
)}
</Fragment>
</Box>
);
};
@@ -0,0 +1 @@
export * from './DocTree';
@@ -1 +0,0 @@
export * from './useTreeUtils';
@@ -1,19 +0,0 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Doc } from '@/docs/doc-management';
export const useTreeUtils = (doc: Doc) => {
const treeContext = useTreeContext<Doc>();
return {
isParent: doc.nb_accesses_ancestors <= 1, // it is a parent
isChild: doc.nb_accesses_ancestors > 1, // it is a child
isCurrentParent: treeContext?.root?.id === doc.id || doc.depth === 1, // it can be a child but not for the current user
isDesyncronized: !!(
doc.ancestors_link_reach &&
doc.ancestors_link_role &&
(doc.computed_link_reach !== doc.ancestors_link_reach ||
doc.computed_link_role !== doc.ancestors_link_role)
),
} as const;
};
@@ -1,3 +1,3 @@
export * from './api';
export * from './hooks';
export * from './components';
export * from './utils';
@@ -1,4 +1,4 @@
import { TreeViewDataType } from '@gouvfr-lasuite/ui-kit';
import { TreeDataItem, TreeViewDataType } from '@gouvfr-lasuite/ui-kit';
import { Doc } from '../doc-management';
@@ -9,3 +9,24 @@ export const subPageToTree = (children: Doc[]): TreeViewDataType<Doc>[] => {
});
return children;
};
export const findIndexInTree = (
nodes: TreeDataItem<TreeViewDataType<Doc>>[],
key: string,
) => {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].key === key) {
return i;
}
if (nodes[i].children?.length ?? 0 > 0) {
const childIndex: number = nodes[i].children
? findIndexInTree(nodes[i].children ?? [], key)
: -1;
if (childIndex !== -1) {
return childIndex;
}
}
}
return -1;
};
@@ -32,10 +32,14 @@ export const DocsGrid = ({
hasNextPage,
} = useInfiniteDocs({
page: 1,
...(target &&
target !== DocDefaultFilter.ALL_DOCS && {
is_creator_me: target === DocDefaultFilter.MY_DOCS,
}),
is_masked:
!target || target === DocDefaultFilter.ALL_DOCS ? false : undefined,
is_creator_me:
target === DocDefaultFilter.MY_DOCS
? true
: target === DocDefaultFilter.SHARED_WITH_ME
? false
: undefined,
});
const docs = data?.pages.flatMap((page) => page.results) ?? [];
@@ -1,8 +1,4 @@
import {
VariantType,
useModal,
useToastProvider,
} from '@openfun/cunningham-react';
import { useModal } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
@@ -13,6 +9,7 @@ import {
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
useDuplicateDoc,
useMaskDocOption,
} from '@/docs/doc-management';
interface DocsGridActionsProps {
@@ -25,21 +22,9 @@ export const DocsGridActions = ({
openShareModal,
}: DocsGridActionsProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const deleteModal = useModal();
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,
});
},
});
const { mutate: duplicateDoc } = useDuplicateDoc();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC],
@@ -47,6 +32,7 @@ export const DocsGridActions = ({
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC],
});
const maskDocOption = useMaskDocOption(doc);
const options: DropdownMenuOption[] = [
{
@@ -60,6 +46,7 @@ export const DocsGridActions = ({
}
},
testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
showSeparator: true,
},
{
label: t('Share'),
@@ -81,16 +68,22 @@ export const DocsGridActions = ({
canSave: false,
});
},
},
{
label: t('Remove'),
icon: 'delete',
callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`,
showSeparator: true,
},
];
const leaveDocOption: DropdownMenuOption = doc.abilities.destroy
? {
label: t('Delete document'),
icon: 'delete',
callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`,
}
: maskDocOption;
options.push(leaveDocOption);
return (
<>
<DropdownMenu options={options}>
@@ -1,4 +1,3 @@
import { TreeProvider } from '@gouvfr-lasuite/ui-kit';
import { Tooltip, useModal } from '@openfun/cunningham-react';
import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
@@ -27,8 +26,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const shareModal = useModal();
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const showAccesses = isPublic || isAuthenticated;
const isShared = isPublic || isAuthenticated;
const handleShareClick = () => {
shareModal.open();
@@ -67,12 +65,11 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
$direction="row"
$align="center"
$gap={spacingsTokens.xs}
$flex={flexLeft}
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{showAccesses && (
{isShared && (
<Box
$padding={{ top: !isDesktop ? '4xs' : undefined }}
$css={
@@ -144,9 +141,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
</Box>
</Box>
{shareModal.isOpen && (
<TreeProvider initialNodeId={doc.id}>
<DocShareModal doc={doc} onClose={shareModal.close} />
</TreeProvider>
<DocShareModal doc={doc} onClose={shareModal.close} />
)}
</>
);
@@ -2,7 +2,7 @@ import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Box } from '@/components';
import { Doc, useDocStore } from '@/docs/doc-management';
import { DocTree } from '@/features/docs/doc-tree/components/DocTree';
import { DocTree } from '@/docs/doc-tree/';
export const LeftPanelDocContent = () => {
const { currentDoc } = useDocStore();
@@ -20,9 +20,7 @@ export const LeftPanelDocContent = () => {
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
className="--docs--left-panel-doc-content"
>
{tree.initialTargetId && (
<DocTree initialTargetId={tree.initialTargetId} />
)}
<DocTree currentDoc={currentDoc} />
</Box>
);
};

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