Compare commits

..

51 Commits

Author SHA1 Message Date
Fabre Florian
562a0a4285 🔧(backend) setup Docs app dockers to work with Find
Add nginx with 'nginx' alias to the 'lasuite-net' network (keycloak calls)
Add celery-dev to the 'lasuite-net' network (Find API calls in jobs)
Set app-dev alias as 'impress' in the 'lasuite-net' network
Add indexer configuration in common settings

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-07 07:14:51 +02:00
Fabre Florian
8a483a7da0 🔧(backend) force a valid key for token storage in development mode
Generate a fernet key for the OIDC_STORE_REFRESH_TOKEN_KEY in development
settings if not set.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:21:27 +02:00
Fabre Florian
cc47ff2b46 (backend) Index deleted documents
Add SEARCH_INDEXER_COUNTDOWN as configurable setting.
Make the search backend creation simplier (only 'get_document_indexer' now).
Allow indexation of deleted documents.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:21:27 +02:00
Fabre Florian
33b4e2e446 (backend) Index partially empty documents
Only documents without title and content are ignored by indexer.
2025-10-02 16:21:26 +02:00
Fabre Florian
4185ad2419 (backend) add fallback search & default ordering
Filter deleted documents from visited ones.
Set default ordering to the Find API search call (-updated_at)
BaseDocumentIndexer.search now returns a list of document ids instead of models.
Do not call the indexer in signals when SEARCH_INDEXER_CLASS is not defined
or properly configured.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:21:26 +02:00
Fabre Florian
b5a7af99f8 (backend) refactor indexation signals and fix circular import issues
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:21:25 +02:00
Fabre Florian
f830fc6490 (backend) improve search indexer service configuration
New SEARCH_INDEXER_CLASS setting to define the indexer service class.
Raise ImpoperlyConfigured errors instead of RuntimeError in index service.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:08 +02:00
Fabre Florian
1ff0ddacec (backend) add document search view
New API view that calls the indexed documents search view
(resource server) of app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:07 +02:00
Fabre Florian
8e73c88b68 (backend) add unit test for the 'index' command
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:07 +02:00
Fabre Florian
d954986bce 🔧(compose) Add some ignore for docker-compose local overrides
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:06 +02:00
Samuel Paccoud - DINUM
bffb101d5b (backend) add async triggers to enable document indexation with find
On document content or permission changes, start a celery job that will call the
indexation API of the app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:05 +02:00
Samuel Paccoud - DINUM
b702d8dd22 (backend) add document search indexer
Add indexer that loops across documents in the database, formats them
as json objects and indexes them in the remote "Find" mico-service.
2025-10-02 16:18:04 +02:00
Samuel Paccoud - DINUM
d29741b20e (backend) add dummy content to demo documents
We need to content in our demo documents so that we can test
indexing.
2025-10-02 16:18:04 +02:00
Samuel Paccoud - DINUM
76c218a220 🔧(compose) configure external network for communication with search
Search in Docs relies on an external project like "La Suite Find".
We need to declare a common external network in order to connect to
the search app and index our documents.
2025-10-02 16:18:02 +02:00
Cyril
18f4ab880f (frontend) update labels and shared document icon accessibility
remove aria-labels from decorative icons and add sr-only text to shared doc icon

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-02 13:24:47 +02:00
Cyril
e71c45077d (frontend) checked checkboxes: removing strikethrough
removing strikethrough

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-01 11:41:18 +02:00
Cyril
14c84f000e (frontend) add h1 for SR on 40X pages and remove alt texts
improves screen reader support on error pages by clarifying structure

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-30 08:57:30 +02:00
Cyril
6cc42636e5 (frontend) convert to figure/figcaption structure if caption exists
ensure  html structure by using figure/figcaption when captions are present

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-29 10:42:56 +02:00
Anthony LC
cc4bed6f8e ♻️(frontend) add upload loader block
The way we were handling the antivirus upload loader
was not optimal, it didn't work well with the pdf
embed block. We created a dedicated upload loader
block, it will replace the previous implementation,
it is more Blocknote idiomatic and will work
better with any type of upload files.
2025-09-26 17:15:22 +02:00
dakshesh14
d8f90c04bd (frontend) add pdf blocks to the editor
Added pdf block in the editor.

Signed-off-by: dakshesh14 <65905942+dakshesh14@users.noreply.github.com>
2025-09-26 17:15:22 +02:00
Cyril
1fdf70bdcf (frontend) remove redundant aria-label on hidden icons and update tests
remove aria-label from aria-hidden icons and update tests with data-testid

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-24 13:19:08 +02:00
Cyril
8ab21ef00d (frontend) improve semantic structure and aria roles of leftpanel
use nav and appropriate aria attributes to enhance accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-24 12:19:47 +02:00
Cyril
f337a2a8f2 (frontend) add default background to left panel for better a11y
defined a default background color to prevent issues with user stylesheets

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-24 11:40:49 +02:00
Cyril
3607faa475 (frontend) remove redundant aria-label to avoid over-accessibility
aria-label was removed because the visible span already provides the text

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-23 11:26:28 +02:00
Manuel Raynaud
0ea7dd727f 🔒️(frontend) update alpine packages in production image (#1425)
Force an update of installed package in the image used for the frontend
in production.
2025-09-23 09:21:01 +00:00
Anthony LC
6aca40a034 ⬆️(dependencies) Bump vite from 7.1.0 to 7.1.5
Bumps vite from 7.1.0 to 7.1.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md
- https://github.com/vitejs/vite/commits/v7.1.5/packages/vite

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-22 15:56:23 +02:00
Cyril
ee3b05cb55 (frontend) improve NVDA navigation in DocShareModal
fix NVDA focus and announcement issues in search modal combobox

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-22 14:53:34 +02:00
Anthony LC
c23ff546d8 🐛(frontend) scroll back to top when navigate to a document
When navigating to a new document, the scroll
position was preserved. This commit changes this
behavior to scroll back to the top of
the page when navigating to a new document.
2025-09-22 10:52:34 +02:00
Anthony LC
a751f1255a ♻️(frontend) replace Arial font-family with token font
In some components, the Arial font was still used
because of a centering problem.
We removed all instances of Arial and replaced them
with the current font token, the centering problems
were fixed by adding "contain: content;" to the css.
2025-09-22 10:09:15 +02:00
Anthony LC
8ee50631f3 🍱(frontend) replace Marianne font
Some improvements has been made to the Marianne
fonts. We replace the previous one with the
newer version.
2025-09-22 10:09:14 +02:00
Cyril
e5e5fba0b3 (frontend) hide decorative icons from assistive tech with aria-hidden
improves accessibility by reducing screen reader noise from icon elements

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-22 08:31:51 +02:00
Cyril
0894bcdca5 (docs) add title metadata to exported docx/pdf for accessibility
ensures document title is preserved in exports to meet accessibility needs

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-18 14:55:02 +02:00
Anthony LC
75da342058 🏷️(frontend) adapt types to link-configuration endpoint
The link-configuration endpoint has now a strict
validation schema about the combination of
link_reach and link_role.
We need to adapt our types
frontend side to reflect that.
2025-09-18 13:16:37 +02:00
Anthony LC
1ed01fd64b 🥅(backend) link role could be updated when restricted document
When a document was restricted, the link role could
be updated from "link-configuration" and gives a
200 response, but the change did not
have any effect because of a restriction in
LinkReachChoices.
We added a validation step to ensure that the
link role can only be updated if the document
is not restricted.
2025-09-18 12:17:08 +02:00
Anthony LC
e4aa85be83 (e2e) fix flakiness
Some tests were getting very flaky due to previous
tests updates. This should fix it.
2025-09-18 11:28:20 +02:00
Anthony LC
2dc1e07b42 ⚗️(service-worker) remove index from cache first strategy
Some users reported that the app was giving a
blank page, it seems to happens often after a
release. It could be due to the fact that
the service worker is caching the index.html
file and not updating it properly after a new release.
We remove the index from the cache first strategy
to avoid this kind of issue. We set as well
the default handler with the "StaleWhileRevalidate"
strategy to force the cache to be updated in
the background.
2025-09-18 10:40:47 +02:00
Anthony LC
fbdeb90113 🛂(frontend) invalidate doc query when lost connection
When the provider reports a lost connection,
we invalidate the doc query to refetch the document
data.
This ensures that if a user has lost is rights
to access a document, he will be redirected
to a 403 page without needing to refresh the page.
2025-09-17 17:45:26 +02:00
Anthony LC
b773f09792 🥅(frontend) improve meta 401 page
Add better meta for the 401 page.
2025-09-17 17:45:26 +02:00
Anthony LC
d8c9283dd1 🐛(frontend) fix 404 page when reload 403 page
When users were reloading a 403 page, they were
redirected to the 404 page because of Nextjs
routing mechanism. This commit fixes this issue by
removing the 403 page from the pages directory
and creating a component that is used directly
in the layout when a 403 error is detected.
2025-09-17 17:45:26 +02:00
Cyril
1e39d17914 (frontend) improve accessibility by adding landmark roles to layout
landmark roles help assistive tech users navigate quickly across the page

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-17 08:24:21 +02:00
Anthony LC
ecd2f97cf5 🐛(frontend) fix legacy role computation
Before the subpages feature, the user_role was
computed thanks to the abilities.
This is not the correct way to do it anymore,
the abilities are now different.
We now have "user_role" in the doc response
which is the correct way to get the user role
for the current document.
2025-09-16 17:23:26 +02:00
Anthony LC
90624e83f5 🩹(demo) update the email in realm.json
We updated the email addresses for the demo users
but forgot to change them in the realm.json file.
This commit fixes that oversight.
2025-09-16 17:23:26 +02:00
Cyril
5fc002658c (frontend) add pdf outline property to enable bookmarks display
allows pdf viewers like adobe reader to display bookmarks in the sidebar

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 14:29:53 +02:00
Cyril
dfd5dc1545 (frontend) document visible in list are now openable via enter key
the document now appears in the list and can be opened using the enter key

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 12:51:23 +02:00
Cyril
69e7235f75 (frontend) refine focus outline with shadow for visual consistency
aligns focus state with app style by adding background shadow to outline

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 10:56:34 +02:00
Cyril
942c90c29f (frontend) enable enter key to open documents and subdocuments
added keyboard support to open docs and subdocs using the enter key

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 10:26:49 +02:00
virgile-dev
c5f0142671 📝 (doc) add mosa.cloud docs instance (#1334)
## Purpose

So that users have more options to choose from


## Proposal
Add mosa.cloud docs instance url

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

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-09-16 07:01:10 +00:00
Manuel Raynaud
7f37d3bda4 🐛(backend) duplicate sub docs as root for reader user
Reader user should be able to duplicate a doc in the doc tree. It should
be created a new doc at the root level.
2025-09-15 20:44:58 +00:00
Manuel Raynaud
7033d0ecf7 🐛(backend) cast DOCUMENT_IMAGE_MAX_SIZE in integer
The expected type for the settings DOCUMENT_IMAGE_MAX_SIZE is an
integer. By not using django configurations IntegerValue, the value is
used as it and most of the time will be a string. We must use the
IntegerValue in order to cast the value in string.
2025-09-15 17:47:43 +02:00
Fabre Florian
0dd6818e91 (frontend) Adapt e2e test utils to the Keycloak 26.3 login page
Fix the keyCloakSignIn() function for the new login page.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-09-15 11:19:42 +02:00
Fabre Florian
eb225fc86f 🔧(keycloak) Fix https required issue in dev mode
On some environments keycloak returns a 'HTTPS required' message on login.
The same issue was fixed in drive by changing the 'sslRequired' value
from 'external' to 'none'.
Also upgrade keycloak up to 26.3.2

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-09-15 11:19:41 +02:00
117 changed files with 3498 additions and 667 deletions

View File

@@ -79,6 +79,7 @@ jobs:
--check-filenames \
--ignore-words-list "Dokument,afterAll,excpt,statics" \
--skip "./git/" \
--skip "**/*.pdf" \
--skip "**/*.po" \
--skip "**/*.pot" \
--skip "**/*.json" \

4
.gitignore vendored
View File

@@ -43,6 +43,10 @@ venv.bak/
env.d/development/*.local
env.d/terraform
# Docker
compose.override.yml
docker/auth/*.local
# npm
node_modules

View File

@@ -1,5 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
@@ -8,6 +6,44 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) add pdf block to the editor #1293
### Changed
- ♻️(frontend) replace Arial font-family with token font #1411
- ♿(frontend) improve accessibility:
- #1354
- #1349
- ♿ improve accessibility by adding landmark roles to layout #1394
- ♿ add document visible in list and openable via enter key #1365
- ♿ add pdf outline property to enable bookmarks display #1368
- ♿ hide decorative icons from assistive tech with aria-hidden #1404
- ♿ fix rgaa 1.9.1: convert to figure/figcaption structure #1426
- ♿ remove redundant aria-label to avoid over-accessibility #1420
- ♿ remove redundant aria-label on hidden icons and update tests #1432
- ♿ improve semantic structure and aria roles of leftpanel #1431
- ♿ add default background to left panel for better accessibility #1423
- ♿ restyle checked checkboxes: removing strikethrough #1439
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
- ♿ update labels and shared document icon accessibility #1442
- ✨(backend) add async indexation of documents on save (or access save) #1276
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
- ✨(api) add API route to search for indexed documents in Find #1276
### Fixed
- 🐛(backend) duplicate sub docs as root for reader users
- ⚗️(service-worker) remove index from cache first strategy #1395
- 🐛(frontend) fix 404 page when reload 403 page #1402
- 🐛(frontend) fix legacy role computation #1376
- 🐛(frontend) scroll back to top when navigate to a document #1406
### Changed
- ♿(frontend) improve accessibility:
- ♿improve NVDA navigation in DocShareModal #1396
## [3.7.0] - 2025-09-12
@@ -71,6 +107,7 @@ and this project adheres to
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
- 🔧(keycloak) Fix https required issue in dev mode #1286
## [3.5.0] - 2025-07-31

View File

@@ -247,6 +247,10 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
index: ## index all documents to remote search
@$(MANAGE) index
.PHONY: index
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \

View File

@@ -54,16 +54,16 @@ Docs is a collaborative text editor designed to address common challenges in kno
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
#### 🌍 Known instances
We hope to see many more, here is an incomplete list of public Docs instances (urls listed in alphabetical order). Feel free to make a PR to add ones that are not listed below🙏
| | | |
| --- | --- | ------- |
We hope to see many more, here is an incomplete list of public Docs instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Public |
| docs.numerique.gouv.fr | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| docs.suite.anct.gouv.fr | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| notes.demo.opendesk.eu | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| notes.liiib.re | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| docs.federated.nexus | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
| --- | --- | ------- |
| [docs.numerique.gouv.fr](https://docs.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [docs.suite.anct.gouv.fr](https://docs.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [notes.demo.opendesk.eu](https://notes.demo.opendesk.eu) | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| [notes.liiib.re](https://notes.liiib.re/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [docs.federated.nexus](https://docs.federated.nexus/) | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
| [docs.demo.mosacloud.eu](https://docs.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
#### ⚠️ Advanced features
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.

6
bin/fernetkey Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'

View File

@@ -72,6 +72,11 @@ services:
- env.d/development/postgresql.local
ports:
- "8071:8000"
networks:
default: {}
lasuite-net:
aliases:
- impress
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -92,6 +97,9 @@ services:
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
networks:
- default
- lasuite-net
env_file:
- env.d/development/common
- env.d/development/common.local
@@ -107,6 +115,11 @@ services:
image: nginx:1.25
ports:
- "8083:8083"
networks:
default: {}
lasuite-net:
aliases:
- nginx
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -184,22 +197,20 @@ services:
- env.d/development/kc_postgresql.local
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
image: quay.io/keycloak/keycloak:26.3
volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
command:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --hostname=http://localhost:8083
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
healthcheck:
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
start_period: 5s
interval: 1s
timeout: 2s
retries: 300
@@ -219,3 +230,8 @@ services:
kc_postgresql:
condition: service_healthy
restart: true
networks:
lasuite-net:
name: lasuite-net
driver: bridge

View File

@@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"sslRequired": "none",
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": true,
@@ -60,7 +60,7 @@
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.test",
"email": "user.test@chromium.test",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": true,
@@ -74,7 +74,7 @@
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.test",
"email": "user.test@webkit.test",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
@@ -88,7 +88,7 @@
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.test",
"email": "user.test@firefox.test",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,
@@ -2270,7 +2270,7 @@
"cibaInterval": "5",
"realmReusableOtpCode": "false"
},
"keycloakVersion": "20.0.1",
"keycloakVersion": "26.3.2",
"userManagedAccessAllowed": false,
"clientProfiles": {
"profiles": []

View File

@@ -49,6 +49,14 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
@@ -66,3 +74,9 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
# impress
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"

View File

@@ -506,6 +506,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
We expose it separately from document in order to simplify and secure access control.
"""
link_reach = serializers.ChoiceField(
choices=models.LinkReachChoices.choices, required=True
)
class Meta:
model = models.Document
fields = [
@@ -513,6 +517,58 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
"link_reach",
]
def validate(self, attrs):
"""Validate that link_role and link_reach are compatible using get_select_options."""
link_reach = attrs.get("link_reach")
link_role = attrs.get("link_role")
if not link_reach:
raise serializers.ValidationError(
{"link_reach": _("This field is required.")}
)
# Get available options based on ancestors' link definition
available_options = models.LinkReachChoices.get_select_options(
**self.instance.ancestors_link_definition
)
# Validate link_reach is allowed
if link_reach not in available_options:
msg = _(
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
)
raise serializers.ValidationError(
{"link_reach": msg % {"link_reach": link_reach}}
)
# Validate link_role is compatible with link_reach
allowed_roles = available_options[link_reach]
# Restricted reach: link_role must be None
if link_reach == models.LinkReachChoices.RESTRICTED:
if link_role is not None:
raise serializers.ValidationError(
{
"link_role": (
"Cannot set link_role when link_reach is 'restricted'. "
"Link role must be null for restricted reach."
)
}
)
return attrs
# Non-restricted: link_role must be in allowed roles
if link_role not in allowed_roles:
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
raise serializers.ValidationError(
{
"link_role": (
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
f"Allowed roles: {allowed_roles_str}"
)
}
)
return attrs
class DocumentDuplicationSerializer(serializers.Serializer):
"""
@@ -821,3 +877,13 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)
class FindDocumentSerializer(serializers.Serializer):
"""Serializer for Find search requests"""
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
page = serializers.IntegerField(required=False, min_value=1, default=1)

View File

@@ -21,6 +21,7 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -31,6 +32,7 @@ from botocore.exceptions import ClientError
from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
@@ -47,6 +49,10 @@ from core.services.converter_services import (
from core.services.converter_services import (
YdocConverter,
)
from core.services.search_indexers import (
get_document_indexer,
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import extract_attachments, filter_descendants
@@ -373,6 +379,7 @@ class DocumentViewSet(
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
search_serializer_class = serializers.ListDocumentSerializer
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
@@ -941,37 +948,64 @@ class DocumentViewSet(
in the payload.
"""
# Get document while checking permissions
document = self.get_object()
document_to_duplicate = self.get_object()
serializer = serializers.DocumentDuplicationSerializer(
data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
with_accesses = serializer.validated_data.get("with_accesses", False)
is_owner_or_admin = document.get_role(request.user) in models.PRIVILEGED_ROLES
user_role = document_to_duplicate.get_role(request.user)
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
base64_yjs_content = document.content
base64_yjs_content = document_to_duplicate.content
# Duplicate the document instance
link_kwargs = (
{"link_reach": document.link_reach, "link_role": document.link_role}
{
"link_reach": document_to_duplicate.link_reach,
"link_role": document_to_duplicate.link_role,
}
if with_accesses
else {}
)
extracted_attachments = set(extract_attachments(document.content))
attachments = list(extracted_attachments & set(document.attachments))
duplicated_document = document.add_sibling(
extracted_attachments = set(extract_attachments(document_to_duplicate.content))
attachments = list(
extracted_attachments & set(document_to_duplicate.attachments)
)
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
user_role
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
duplicated_document = models.Document.add_root(
creator=self.request.user,
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
**link_kwargs,
)
models.DocumentAccess.objects.create(
document=duplicated_document,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
duplicated_document = document_to_duplicate.add_sibling(
"right",
title=capfirst(_("copy of {title}").format(title=document.title)),
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document,
duplicated_from=document_to_duplicate,
creator=request.user,
**link_kwargs,
)
# Always add the logged-in user as OWNER for root documents
if document.is_root():
if document_to_duplicate.is_root():
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
@@ -983,7 +1017,7 @@ class DocumentViewSet(
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document
document=document_to_duplicate
).exclude(user=request.user)
accesses_to_create.extend(
@@ -1003,6 +1037,68 @@ class DocumentViewSet(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token)
def search(self, request, *args, **kwargs):
"""
Returns a DRF response containing the filtered, annotated and ordered document list.
Applies filtering based on request parameter 'q' from `FindDocumentSerializer`.
Depending of the configuration it can be:
- A fulltext search through the opensearch indexation app "find" if the backend is
enabled (see SEARCH_BACKEND_CLASS)
- A filtering by the model field 'title'.
The ordering is always by the most recent first.
"""
access_token = request.session.get("oidc_access_token")
user = request.user
serializer = serializers.FindDocumentSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
indexer = get_document_indexer()
text = serializer.validated_data["q"]
# The indexer is not configured, so we fallback on a simple filter on the
# model field 'title'.
if not indexer:
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
queryset = self.get_queryset()
filterset = DocumentFilter({"title": text}, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.filter_queryset(queryset).order_by("-updated_at")
return self.get_response_for_queryset(
queryset,
context={
"request": request,
},
)
queryset = models.Document.objects.all()
# Retrieve the documents ids from Find.
results = indexer.search(
text=text,
token=access_token,
visited=get_visited_document_ids_of(queryset, user),
page=serializer.validated_data.get("page", 1),
page_size=serializer.validated_data.get("page_size", 20),
)
queryset = queryset.filter(pk__in=results).order_by("-updated_at")
return self.get_response_for_queryset(
queryset,
context={
"request": request,
},
)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""

View File

@@ -1,11 +1,19 @@
"""Impress Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
# class CoreConfig(AppConfig):
# """Configuration class for the impress core app."""
class CoreConfig(AppConfig):
"""Configuration class for the impress core app."""
# name = "core"
# app_label = "core"
# verbose_name = _("impress core application")
name = "core"
app_label = "core"
verbose_name = _("Impress core application")
def ready(self):
"""
Import signals when the app is ready.
"""
# pylint: disable=import-outside-toplevel, unused-import
from . import signals # noqa: PLC0415

View File

@@ -0,0 +1,40 @@
"""
Handle search setup that needs to be done at bootstrap time.
"""
import logging
import time
from django.core.management.base import BaseCommand, CommandError
from core.services.search_indexers import get_document_indexer
logger = logging.getLogger("docs.search.bootstrap_search")
class Command(BaseCommand):
"""Index all documents to remote search service"""
help = __doc__
def handle(self, *args, **options):
"""Launch and log search index generation."""
indexer = get_document_indexer()
if not indexer:
raise CommandError("The indexer is not enabled or properly configured.")
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
try:
count = indexer.index()
except Exception as err:
raise CommandError("Unable to regenerate index") from err
duration = time.perf_counter() - start
logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
)

View File

@@ -430,32 +430,35 @@ class Document(MP_Node, BaseModel):
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
self.save_content(self._content)
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
raise
def save_content(self, content):
"""Save content to object storage."""
file_key = self.file_key
bytes_content = content.encode("utf-8")
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
raise
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def is_leaf(self):
"""

View File

@@ -0,0 +1,303 @@
"""Document search index management utilities and indexers"""
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
from functools import cache
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Subquery
from django.utils.module_loading import import_string
import requests
from core import models, utils
logger = logging.getLogger(__name__)
@cache
def get_document_indexer():
"""Returns an instance of indexer service if enabled and properly configured."""
classpath = settings.SEARCH_INDEXER_CLASS
# For this usecase an empty indexer class is not an issue but a feature.
if not classpath:
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
return None
try:
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
return indexer_class()
except ImportError as err:
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
except ImproperlyConfigured as err:
logger.error("Document indexer is not properly configured : %s", err)
return None
def get_batch_accesses_by_users_and_teams(paths):
"""
Get accesses related to a list of document paths,
grouped by users and teams, including all ancestor paths.
"""
ancestor_map = utils.get_ancestor_to_descendants_map(
paths, steplen=models.Document.steplen
)
ancestor_paths = list(ancestor_map.keys())
access_qs = models.DocumentAccess.objects.filter(
document__path__in=ancestor_paths
).values("document__path", "user__sub", "team")
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
for access in access_qs:
ancestor_path = access["document__path"]
user_sub = access["user__sub"]
team = access["team"]
for descendant_path in ancestor_map.get(ancestor_path, []):
if user_sub:
access_by_document_path[descendant_path]["users"].add(str(user_sub))
if team:
access_by_document_path[descendant_path]["teams"].add(team)
return dict(access_by_document_path)
def get_visited_document_ids_of(queryset, user):
"""
Returns the ids of the documents that have a linktrace to the user and NOT owned.
It will be use to limit the opensearch responses to the public documents already
"visited" by the user.
"""
if isinstance(user, AnonymousUser):
return []
qs = models.LinkTrace.objects.filter(user=user)
docs = (
queryset.exclude(accesses__user=user)
.filter(
deleted_at__isnull=True,
ancestors_deleted_at__isnull=True,
)
.filter(pk__in=Subquery(qs.values("document_id")))
.order_by("pk")
.distinct("pk")
)
return [str(id) for id in docs.values_list("pk", flat=True)]
class BaseDocumentIndexer(ABC):
"""
Base class for document indexers.
Handles batching and access resolution. Subclasses must implement both
`serialize_document()` and `push()` to define backend-specific behavior.
"""
def __init__(self, batch_size=None):
"""
Initialize the indexer.
Args:
batch_size (int, optional): Number of documents per batch.
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
"""
self.batch_size = batch_size or settings.SEARCH_INDEXER_BATCH_SIZE
self.indexer_url = settings.SEARCH_INDEXER_URL
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
if not self.indexer_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_URL must be set in Django settings."
)
if not self.indexer_secret:
raise ImproperlyConfigured(
"SEARCH_INDEXER_SECRET must be set in Django settings."
)
if not self.search_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
)
def index(self):
"""
Fetch documents in batches, serialize them, and push to the search backend.
"""
last_id = 0
count = 0
while True:
documents_batch = list(
models.Document.objects.filter(
id__gt=last_id,
).order_by("id")[: self.batch_size]
)
if not documents_batch:
break
doc_paths = [doc.path for doc in documents_batch]
last_id = documents_batch[-1].id
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
serialized_batch = [
self.serialize_document(document, accesses_by_document_path)
for document in documents_batch
if document.content or document.title
]
self.push(serialized_batch)
count += len(serialized_batch)
return count
@abstractmethod
def serialize_document(self, document, accesses):
"""
Convert a Document instance to a JSON-serializable format for indexing.
Must be implemented by subclasses.
"""
@abstractmethod
def push(self, data):
"""
Push a batch of serialized documents to the backend.
Must be implemented by subclasses.
"""
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
def search(self, text, token, visited=(), page=1, page_size=50):
"""
Search for documents in Find app.
Ensure the same default ordering as "Docs" list : -updated_at
Returns ids of the documents
Args:
text (str): Text search content.
token (str): OIDC Authentication token.
visited (list, optional):
List of ids of active public documents with LinkTrace
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
page (int, optional):
The page number to retrieve.
Defaults to 1 if not specified.
page_size (int, optional):
The number of results to return per page.
Defaults to 50 if not specified.
"""
response = self.search_query(
data={
"q": text,
"visited": visited,
"services": ["docs"],
"page_number": page,
"page_size": page_size,
"order_by": "updated_at",
"order_direction": "desc",
},
token=token,
)
return [d["_id"] for d in response]
@abstractmethod
def search_query(self, data, token) -> dict:
"""
Retrieve documents from the Find app API.
Must be implemented by subclasses.
"""
class FindDocumentIndexer(BaseDocumentIndexer):
"""
Document indexer that pushes documents to La Suite Find app.
"""
def serialize_document(self, document, accesses):
"""
Convert a Document to the JSON format expected by La Suite Find.
Args:
document (Document): The document instance.
accesses (dict): Mapping of document ID to user/team access.
Returns:
dict: A JSON-serializable dictionary.
"""
doc_path = document.path
doc_content = document.content
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
return {
"id": str(document.id),
"title": document.title or "",
"content": text_content,
"depth": document.depth,
"path": document.path,
"numchild": document.numchild,
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"users": list(accesses.get(doc_path, {}).get("users", set())),
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
"reach": document.computed_link_reach,
"size": len(text_content.encode("utf-8")),
"is_active": not bool(document.ancestors_deleted_at),
}
def search_query(self, data, token) -> requests.Response:
"""
Retrieve documents from the Find app API.
Args:
data (dict): search data
token (str): OICD token
Returns:
dict: A JSON-serializable dictionary.
"""
try:
response = requests.post(
self.search_url,
json=data,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
logger.error("HTTPError: %s", e)
raise
def push(self, data):
"""
Push a batch of documents to the Find backend.
Args:
data (list): List of document dictionaries.
"""
try:
response = requests.post(
self.indexer_url,
json=data,
headers={"Authorization": f"Bearer {self.indexer_secret}"},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.error("HTTPError: %s", e)
raise

View File

@@ -0,0 +1,31 @@
"""
Declare and configure the signals for the impress core application
"""
from functools import partial
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
from . import models
from .tasks.find import trigger_document_indexer
@receiver(signals.post_save, sender=models.Document)
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Note : Within the transaction we can have an empty content and a serialization
error.
"""
transaction.on_commit(partial(trigger_document_indexer, instance))
@receiver(signals.post_save, sender=models.DocumentAccess)
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
"""
if not created:
transaction.on_commit(partial(trigger_document_indexer, instance.document))

View File

@@ -0,0 +1,89 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from impress.celery_app import app
logger = getLogger(__file__)
def indexer_debounce_lock(document_id):
"""Increase or reset counter"""
key = f"doc-indexer-debounce-{document_id}"
try:
return cache.incr(key)
except ValueError:
cache.set(key, 1)
return 1
def indexer_debounce_release(document_id):
"""Decrease or reset counter"""
key = f"doc-indexer-debounce-{document_id}"
try:
return cache.decr(key)
except ValueError:
cache.set(key, 0)
return 0
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
# Prevents some circular imports
# pylint: disable=import-outside-toplevel
from core import models # noqa : PLC0415
from core.services.search_indexers import ( # noqa : PLC0415
get_batch_accesses_by_users_and_teams,
get_document_indexer,
)
# check if the counter : if still up, skip the task. only the last one
# within the countdown delay will do the query.
if indexer_debounce_release(document_id) > 0:
logger.info("Skip document %s indexation", document_id)
return
indexer = get_document_indexer()
if indexer is None:
return
doc = models.Document.objects.get(pk=document_id)
accesses = get_batch_accesses_by_users_and_teams((doc.path,))
data = indexer.serialize_document(document=doc, accesses=accesses)
logger.info("Start document %s indexation", document_id)
indexer.push(data)
def trigger_document_indexer(document):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
Args:
document (Document): The document instance.
"""
countdown = settings.SEARCH_INDEXER_COUNTDOWN
# DO NOT create a task if indexation if disabled
if not settings.SEARCH_INDEXER_CLASS:
return
logger.info(
"Add task for document %s indexation in %.2f seconds",
document.pk,
countdown,
)
# Each time this method is called during the countdown, we increment the
# counter and each task decrease it, so the index be run only once.
indexer_debounce_lock(document.pk)
document_indexer_task.apply_async(args=[document.pk], countdown=countdown)

View File

@@ -0,0 +1,65 @@
"""
Unit test for `index` command.
"""
from operator import itemgetter
from unittest import mock
from django.core.management import CommandError, call_command
from django.db import transaction
import pytest
from core import factories
from core.services.search_indexers import FindDocumentIndexer
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index():
"""Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory()
indexer = FindDocumentIndexer()
with transaction.atomic():
doc = factories.DocumentFactory()
empty_doc = factories.DocumentFactory(title=None, content="")
no_title_doc = factories.DocumentFactory(title=None)
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
str(empty_doc.path): {"users": [user.sub]},
str(no_title_doc.path): {"users": [user.sub]},
}
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command("index")
push_call_args = [call.args[0] for call in mock_push.call_args_list]
# called once but with a batch of docs
mock_push.assert_called_once()
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_improperly_configured(indexer_settings):
"""The command should raise an exception if the indexer is not configured"""
indexer_settings.SEARCH_INDEXER_CLASS = None
with pytest.raises(CommandError) as err:
call_command("index")
assert str(err.value) == "The indexer is not enabled or properly configured."

View File

@@ -24,3 +24,29 @@ def mock_user_teams():
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams
@pytest.fixture(name="indexer_settings")
def indexer_settings_fixture(settings):
"""
Setup valid settings for the document indexer. Clear the indexer cache.
"""
# pylint: disable-next=import-outside-toplevel
from core.services.search_indexers import ( # noqa: PLC0415
get_document_indexer,
)
get_document_indexer.cache_clear()
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.FindDocumentIndexer"
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
settings.SEARCH_INDEXER_QUERY_URL = (
"http://localhost:8081/api/v1.0/documents/search/"
)
yield settings
# clear cache to prevent issues with other tests
get_document_indexer.cache_clear()

View File

@@ -293,3 +293,28 @@ def test_api_documents_duplicate_non_root_document(role):
assert duplicated_accesses.count() == 0
assert duplicated_document.is_sibling_of(child)
assert duplicated_document.is_child_of(document)
def test_api_documents_duplicate_reader_non_root_document():
"""
Reader users should be able to duplicate non-root documents but will be
created as a root document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "reader")])
child = factories.DocumentFactory(parent=document)
assert child.get_role(user) == "reader"
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/", format="json"
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.is_root()
assert duplicated_document.accesses.count() == 1
assert duplicated_document.accesses.get(user=user).role == "owner"

View File

@@ -133,7 +133,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED,
link_role=models.LinkRoleChoices.READER,
)
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
@@ -143,7 +146,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
)
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
instance=factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.EDITOR,
)
).data
with mock_reset_connections(document.id):
@@ -158,3 +164,240 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]
def test_api_documents_link_configuration_update_role_restricted_forbidden():
"""
Test that trying to set link_role on a document with restricted link_reach
returns a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Try to set a meaningful role on a restricted document
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
"link_role": models.LinkRoleChoices.EDITOR,
}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_role" in response.json()
assert (
"Cannot set link_role when link_reach is 'restricted'"
in response.json()["link_role"][0]
)
def test_api_documents_link_configuration_update_link_reach_required():
"""
Test that link_reach is required when updating link configuration.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Try to update without providing link_reach
new_data = {"link_role": models.LinkRoleChoices.EDITOR}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_reach" in response.json()
assert "This field is required" in response.json()["link_reach"][0]
def test_api_documents_link_configuration_update_restricted_without_role_success(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Test that setting link_reach to restricted without specifying link_role succeeds.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Only specify link_reach, not link_role
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == models.LinkReachChoices.RESTRICTED
@pytest.mark.parametrize(
"reach", [models.LinkReachChoices.PUBLIC, models.LinkReachChoices.AUTHENTICATED]
)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_documents_link_configuration_update_non_restricted_with_valid_role_success(
reach,
role,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Test that setting non-restricted link_reach with valid link_role succeeds.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
new_data = {
"link_reach": reach,
"link_role": role,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == reach
assert document.link_role == role
def test_api_documents_link_configuration_update_with_ancestor_constraints():
"""
Test that link configuration respects ancestor constraints using get_select_options.
This test may need adjustment based on the actual get_select_options implementation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
child_document = factories.DocumentFactory(
parent=parent_document,
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=child_document, user=user, role=models.RoleChoices.OWNER
)
# Try to set child to PUBLIC when parent is RESTRICTED
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
"link_role": models.LinkRoleChoices.READER,
}
response = client.put(
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_reach" in response.json()
assert (
"Link reach 'restricted' is not allowed based on parent"
in response.json()["link_reach"][0]
)
def test_api_documents_link_configuration_update_invalid_role_for_reach_validation():
"""
Test the specific validation logic that checks if link_role is allowed for link_reach.
This tests the code section that validates allowed_roles from get_select_options.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED,
link_role=models.LinkRoleChoices.EDITOR,
)
child_document = factories.DocumentFactory(
parent=parent_document,
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=child_document, user=user, role=models.RoleChoices.OWNER
)
new_data = {
"link_reach": models.LinkReachChoices.AUTHENTICATED,
"link_role": models.LinkRoleChoices.READER, # This should be rejected
}
response = client.put(
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_role" in response.json()
error_message = response.json()["link_role"][0]
assert (
"Link role 'reader' is not allowed for link reach 'authenticated'"
in error_message
)
assert "Allowed roles: editor" in error_message

View File

@@ -0,0 +1,234 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
from json import loads as json_loads
import pytest
import responses
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
from core.services.search_indexers import get_document_indexer
fake = Faker()
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@responses.activate
def test_api_documents_search_anonymous(reach, role, indexer_settings):
"""
Anonymous users should not be allowed to search documents whatever the
link reach and link role
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
factories.DocumentFactory(link_reach=reach, link_role=role)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[],
status=200,
)
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_search_endpoint_is_none(indexer_settings):
"""
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
Should fallback on title filter
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
document = factories.DocumentFactory(title="alpha")
access = factories.UserDocumentAccessFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
}
@responses.activate
def test_api_documents_search_invalid_params(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/")
assert response.status_code == 400
assert response.json() == {"q": ["This field is required."]}
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
assert response.status_code == 400
assert response.json() == {"q": ["This field may not be blank."]}
response = client.get(
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
)
assert response.status_code == 400
assert response.json() == {"page": ["A valid integer is required."]}
@responses.activate
def test_api_documents_search_format(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
title="alpha",
users=(user_a, user_c),
link_traces=(user, user_b),
)
access = factories.UserDocumentAccessFactory(document=document, user=user)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[
{"_id": str(document.pk)},
],
status=200,
)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
}
@responses.activate
def test_api_documents_search_pagination(indexer_settings):
"""Documents should be ordered by descending "updated_at" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
docs = factories.DocumentFactory.create_batch(10)
# Find response
# pylint: disable-next=assignment-from-none
api_search = responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[{"_id": str(doc.pk)} for doc in docs],
status=200,
)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "alpha", "page": 2, "page_size": 5}
)
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert len(results) == 5
# Check the query parameters.
assert api_search.call_count == 1
assert api_search.calls[0].response.status_code == 200
assert json_loads(api_search.calls[0].request.body) == {
"q": "alpha",
"visited": [],
"services": ["docs"],
"page_number": 2,
"page_size": 5,
"order_by": "updated_at",
"order_direction": "desc",
}

View File

@@ -6,6 +6,7 @@ Unit tests for the Document model
import random
import smtplib
from logging import Logger
from operator import itemgetter
from unittest import mock
from django.contrib.auth.models import AnonymousUser
@@ -13,12 +14,14 @@ from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import transaction
from django.test.utils import override_settings
from django.utils import timezone
import pytest
from core import factories, models
from core.services.search_indexers import FindDocumentIndexer
pytestmark = pytest.mark.django_db
@@ -1411,3 +1414,285 @@ def test_models_documents_compute_ancestors_links_paths_mapping_structure(
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
],
}
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer(mock_push, indexer_settings):
"""Test indexation task on document creation"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
accesses = {}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The debounce counters should be reset
assert cache.get(f"doc-indexer-debounce-{doc1.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc2.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc3.pk}") == 0
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
"""Task should not start an indexation when disabled"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer_settings.SEARCH_INDEXER_CLASS = None
with transaction.atomic():
factories.DocumentFactory()
assert mock_push.call_args_list == []
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_with_accesses(mock_push, indexer_settings):
"""Test indexation task on document creation"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
factories.UserDocumentAccessFactory(document=doc1, user=user)
factories.UserDocumentAccessFactory(document=doc2, user=user)
factories.UserDocumentAccessFactory(document=doc3, user=user)
accesses = {
str(doc1.path): {"users": [user.sub]},
str(doc2.path): {"users": [user.sub]},
str(doc3.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The debounce counters should be reset
assert cache.get(f"doc-indexer-debounce-{doc1.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc2.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc3.pk}") == 0
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_deleted(mock_push, indexer_settings):
"""Indexation task on deleted or ancestor_deleted documents"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_deleted = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_ancestor_deleted = factories.DocumentFactory(
parent=doc_deleted,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
doc_deleted.soft_delete()
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
doc_deleted.refresh_from_db()
doc_ancestor_deleted.refresh_from_db()
assert doc_deleted.deleted_at is not None
assert doc_deleted.ancestors_deleted_at is not None
assert doc_ancestor_deleted.deleted_at is None
assert doc_ancestor_deleted.ancestors_deleted_at is not None
accesses = {
str(doc.path): {"users": [user.sub]},
str(doc_deleted.path): {"users": [user.sub]},
str(doc_ancestor_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
# Even deleted document are re-indexed : only update their status in the future ?
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(doc_deleted, accesses),
indexer.serialize_document(doc_ancestor_deleted, accesses),
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
],
key=itemgetter("id"),
)
# The debounce counters should be reset
assert cache.get(f"doc-indexer-debounce-{doc.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc_deleted.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc_ancestor_deleted.pk}") == 0
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_restored(mock_push, indexer_settings):
"""Restart indexation task on restored documents"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_deleted = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_ancestor_deleted = factories.DocumentFactory(
parent=doc_deleted,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
doc_deleted.soft_delete()
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
doc_deleted.refresh_from_db()
doc_ancestor_deleted.refresh_from_db()
assert doc_deleted.deleted_at is not None
assert doc_deleted.ancestors_deleted_at is not None
assert doc_ancestor_deleted.deleted_at is None
assert doc_ancestor_deleted.ancestors_deleted_at is not None
doc_restored = models.Document.objects.get(pk=doc_deleted.pk)
doc_restored.restore()
doc_ancestor_restored = models.Document.objects.get(pk=doc_ancestor_deleted.pk)
assert doc_restored.deleted_at is None
assert doc_restored.ancestors_deleted_at is None
assert doc_ancestor_restored.deleted_at is None
assert doc_ancestor_restored.ancestors_deleted_at is None
accesses = {
str(doc.path): {"users": [user.sub]},
str(doc_deleted.path): {"users": [user.sub]},
str(doc_ancestor_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
# All docs are re-indexed
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(doc_deleted, accesses),
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
indexer.serialize_document(doc_restored, accesses), # restore()
indexer.serialize_document(doc_ancestor_deleted, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_debounce(indexer_settings):
"""Test indexation task skipping on document update"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer = FindDocumentIndexer()
user = factories.UserFactory()
with mock.patch.object(FindDocumentIndexer, "push"):
with transaction.atomic():
doc = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
}
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
# Simulate 1 waiting task
cache.set(f"doc-indexer-debounce-{doc.pk}", 1)
# save doc to trigger the indexer, but nothing should be done since
# the counter is over 0
with transaction.atomic():
doc.save()
assert [call.args[0] for call in mock_push.call_args_list] == []
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
# No waiting task
cache.set(f"doc-indexer-debounce-{doc.pk}", 0)
with transaction.atomic():
doc = models.Document.objects.get(pk=doc.pk)
doc.save()
assert [call.args[0] for call in mock_push.call_args_list] == [
indexer.serialize_document(doc, accesses),
]
@pytest.mark.django_db(transaction=True)
def test_models_documents_access_post_save_indexer(indexer_settings):
"""Test indexation task on DocumentAccess update"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer = FindDocumentIndexer()
user = factories.UserFactory()
with mock.patch.object(FindDocumentIndexer, "push"):
with transaction.atomic():
doc = factories.DocumentFactory()
doc_access = factories.UserDocumentAccessFactory(document=doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
}
indexer = FindDocumentIndexer()
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with transaction.atomic():
doc_access.save()
assert [call.args[0] for call in mock_push.call_args_list] == [
indexer.serialize_document(doc, accesses),
]

View File

@@ -0,0 +1,540 @@
"""Tests for Documents search indexers"""
from functools import partial
from json import dumps as json_dumps
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import pytest
import responses
from requests import HTTPError
from core import factories, models, utils
from core.services.search_indexers import (
BaseDocumentIndexer,
FindDocumentIndexer,
get_document_indexer,
get_visited_document_ids_of,
)
pytestmark = pytest.mark.django_db
class FakeDocumentIndexer(BaseDocumentIndexer):
"""Fake indexer for test purpose"""
def serialize_document(self, document, accesses):
return {}
def push(self, data):
pass
def search_query(self, data, token):
return {}
def test_services_search_indexer_class_invalid(indexer_settings):
"""
Should raise RuntimeError if SEARCH_INDEXER_CLASS cannot be imported.
"""
indexer_settings.SEARCH_INDEXER_CLASS = "unknown.Unknown"
assert get_document_indexer() is None
def test_services_search_indexer_class(indexer_settings):
"""
Import indexer class defined in setting SEARCH_INDEXER_CLASS.
"""
indexer_settings.SEARCH_INDEXER_CLASS = (
"core.tests.test_services_search_indexers.FakeDocumentIndexer"
)
assert isinstance(
get_document_indexer(),
import_string("core.tests.test_services_search_indexers.FakeDocumentIndexer"),
)
def test_services_search_indexer_is_configured(indexer_settings):
"""
Should return true only when the indexer class and other configuration settings
are valid.
"""
indexer_settings.SEARCH_INDEXER_CLASS = None
# None
get_document_indexer.cache_clear()
assert not get_document_indexer()
# Empty
indexer_settings.SEARCH_INDEXER_CLASS = ""
get_document_indexer.cache_clear()
assert not get_document_indexer()
# Valid class
indexer_settings.SEARCH_INDEXER_CLASS = (
"core.services.search_indexers.FindDocumentIndexer"
)
get_document_indexer.cache_clear()
assert get_document_indexer() is not None
indexer_settings.SEARCH_INDEXER_URL = ""
# Invalid url
get_document_indexer.cache_clear()
assert not get_document_indexer()
def test_services_search_indexer_url_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
"""
indexer_settings.SEARCH_INDEXER_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_url_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
"""
indexer_settings.SEARCH_INDEXER_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_secret_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is None.
"""
indexer_settings.SEARCH_INDEXER_SECRET = None
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
)
def test_services_search_indexer_secret_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is empty string.
"""
indexer_settings.SEARCH_INDEXER_SECRET = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
)
def test_services_search_endpoint_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
def test_services_search_endpoint_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_returns_expected_json():
"""
It should serialize documents with correct metadata and access control.
"""
user_a, user_b = factories.UserFactory.create_batch(2)
document = factories.DocumentFactory()
factories.DocumentFactory(parent=document)
factories.UserDocumentAccessFactory(document=document, user=user_a)
factories.UserDocumentAccessFactory(document=document, user=user_b)
factories.TeamDocumentAccessFactory(document=document, team="team1")
factories.TeamDocumentAccessFactory(document=document, team="team2")
accesses = {
document.path: {
"users": {str(user_a.sub), str(user_b.sub)},
"teams": {"team1", "team2"},
}
}
indexer = FindDocumentIndexer()
result = indexer.serialize_document(document, accesses)
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
assert set(result.pop("groups")) == {"team1", "team2"}
assert result == {
"id": str(document.id),
"title": document.title,
"depth": 1,
"path": document.path,
"numchild": 1,
"content": utils.base64_yjs_to_text(document.content),
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,
"size": 13,
"is_active": True,
}
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_deleted():
"""Deleted documents are marked as just in the serialized json."""
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
parent.soft_delete()
document.refresh_from_db()
indexer = FindDocumentIndexer()
result = indexer.serialize_document(document, {})
assert result["is_active"] is False
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_empty():
"""Empty documents returns empty content in the serialized json."""
document = factories.DocumentFactory(content="", title=None)
indexer = FindDocumentIndexer()
result = indexer.serialize_document(document, {})
assert result["content"] == ""
assert result["title"] == ""
@responses.activate
def test_services_search_indexers_index_errors(indexer_settings):
"""
Documents indexing response handling on Find API HTTP errors.
"""
factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
responses.add(
responses.POST,
"http://app-find/api/v1.0/documents/index/",
status=401,
body=json_dumps({"message": "Authentication failed."}),
)
with pytest.raises(HTTPError):
FindDocumentIndexer().index()
@patch.object(FindDocumentIndexer, "push")
def test_services_search_indexers_batches_pass_only_batch_accesses(
mock_push, indexer_settings
):
"""
Documents indexing should be processed in batches,
and only the access data relevant to each batch should be used.
"""
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
documents = factories.DocumentFactory.create_batch(5)
# Attach a single user access to each document
expected_user_subs = {}
for document in documents:
access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub)
assert FindDocumentIndexer().index() == 5
# Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3
seen_doc_ids = set()
for call in mock_push.call_args_list:
batch = call.args[0]
assert isinstance(batch, list)
for doc_json in batch:
doc_id = doc_json["id"]
seen_doc_ids.add(doc_id)
# Only one user expected per document
assert doc_json["users"] == [expected_user_subs[doc_id]]
assert doc_json["groups"] == []
# Make sure all 5 documents were indexed
assert seen_doc_ids == {str(d.id) for d in documents}
@patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ignore_empty_documents(mock_push):
"""
Documents indexing should be processed in batches,
and only the access data relevant to each batch should be used.
"""
document = factories.DocumentFactory()
factories.DocumentFactory(content="", title="")
empty_title = factories.DocumentFactory(title="")
empty_content = factories.DocumentFactory(content="")
assert FindDocumentIndexer().index() == 3
assert mock_push.call_count == 1
# Make sure only not eempty documents are indexed
results = {doc["id"] for doc in mock_push.call_args[0][0]}
assert results == {
str(d.id)
for d in (
document,
empty_content,
empty_title,
)
}
@patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_link_reach(mock_push):
"""Document accesses and reach should take into account ancestors link reaches."""
great_grand_parent = factories.DocumentFactory(link_reach="restricted")
grand_parent = factories.DocumentFactory(
parent=great_grand_parent, link_reach="authenticated"
)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
assert FindDocumentIndexer().index() == 4
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 4
assert results[str(great_grand_parent.id)]["reach"] == "restricted"
assert results[str(grand_parent.id)]["reach"] == "authenticated"
assert results[str(parent.id)]["reach"] == "public"
assert results[str(document.id)]["reach"] == "public"
@patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_users(mock_push):
"""Document accesses and reach should include users from ancestors."""
user_gp, user_p, user_d = factories.UserFactory.create_batch(3)
grand_parent = factories.DocumentFactory(users=[user_gp])
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
document = factories.DocumentFactory(parent=parent, users=[user_d])
assert FindDocumentIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
assert results[str(grand_parent.id)]["users"] == [str(user_gp.sub)]
assert set(results[str(parent.id)]["users"]) == {str(user_gp.sub), str(user_p.sub)}
assert set(results[str(document.id)]["users"]) == {
str(user_gp.sub),
str(user_p.sub),
str(user_d.sub),
}
@patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_teams(mock_push):
"""Document accesses and reach should include teams from ancestors."""
grand_parent = factories.DocumentFactory(teams=["team_gp"])
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
assert FindDocumentIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
assert results[str(grand_parent.id)]["groups"] == ["team_gp"]
assert set(results[str(parent.id)]["groups"]) == {"team_gp", "team_p"}
assert set(results[str(document.id)]["groups"]) == {"team_gp", "team_p", "team_d"}
@patch("requests.post")
def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
"""
push() should call requests.post with the correct URL from settings
the timeout set to 10 seconds and the data as JSON.
"""
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
indexer = FindDocumentIndexer()
sample_data = [{"id": "123", "title": "Test"}]
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
indexer.push(sample_data)
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
assert kwargs.get("json") == sample_data
assert kwargs.get("timeout") == 10
def test_get_visited_document_ids_of():
"""
get_visited_document_ids_of() returns the ids of the documents viewed
by the user BUT without specific access configuration (like public ones)
"""
user = factories.UserFactory()
other = factories.UserFactory()
anonymous = AnonymousUser()
queryset = models.Document.objects.all()
assert not get_visited_document_ids_of(queryset, anonymous)
assert not get_visited_document_ids_of(queryset, user)
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc1)
create_link(document=doc2)
# The third document is not visited
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
[str(doc1.pk), str(doc2.pk)]
)
factories.UserDocumentAccessFactory(user=other, document=doc1)
factories.UserDocumentAccessFactory(user=user, document=doc2)
# The second document have an access for the user
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
@pytest.mark.usefixtures("indexer_settings")
def test_get_visited_document_ids_of_deleted():
"""
get_visited_document_ids_of() returns the ids of the documents viewed
by the user if they are not deleted.
"""
user = factories.UserFactory()
anonymous = AnonymousUser()
queryset = models.Document.objects.all()
assert not get_visited_document_ids_of(queryset, anonymous)
assert not get_visited_document_ids_of(queryset, user)
doc = factories.DocumentFactory()
doc_deleted = factories.DocumentFactory()
doc_ancestor_deleted = factories.DocumentFactory(parent=doc_deleted)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc)
create_link(document=doc_deleted)
create_link(document=doc_ancestor_deleted)
# The all documents are visited
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
[str(doc.pk), str(doc_deleted.pk), str(doc_ancestor_deleted.pk)]
)
doc_deleted.soft_delete()
# Only the first document is not deleted
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
@responses.activate
def test_services_search_indexers_search_errors(indexer_settings):
"""
Documents indexing response handling on Find API HTTP errors.
"""
factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
"http://app-find/api/v1.0/documents/search/"
)
responses.add(
responses.POST,
"http://app-find/api/v1.0/documents/search/",
status=401,
body=json_dumps({"message": "Authentication failed."}),
)
with pytest.raises(HTTPError):
FindDocumentIndexer().search("alpha", token="mytoken")
@patch("requests.post")
def test_services_search_indexers_search(mock_post, indexer_settings):
"""
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
document ids from linktraces.
"""
user = factories.UserFactory()
indexer = FindDocumentIndexer()
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc1)
create_link(document=doc2)
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
indexer.search("alpha", visited=visited, token="mytoken")
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
query_data = kwargs.get("json")
assert query_data["q"] == "alpha"
assert sorted(query_data["visited"]) == sorted([str(doc1.pk), str(doc2.pk)])
assert query_data["services"] == ["docs"]
assert query_data["page_number"] == 1
assert query_data["page_size"] == 50
assert query_data["order_by"] == "updated_at"
assert query_data["order_direction"] == "desc"
assert kwargs.get("headers") == {"Authorization": "Bearer mytoken"}
assert kwargs.get("timeout") == 10

View File

@@ -75,3 +75,28 @@ def test_utils_extract_attachments():
base64_string = base64.b64encode(update).decode("utf-8")
# image_key2 is missing the "/media/" part and shouldn't get extracted
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
def test_utils_get_ancestor_to_descendants_map_single_path():
"""Test ancestor mapping of a single path."""
paths = ["000100020005"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005"},
"00010002": {"000100020005"},
"000100020005": {"000100020005"},
}
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"""Test ancestor mapping of multiple paths with shared prefixes."""
paths = ["000100020005", "00010003"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005", "00010003"},
"00010002": {"000100020005"},
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}

View File

@@ -2,6 +2,7 @@
import base64
import re
from collections import defaultdict
import pycrdt
from bs4 import BeautifulSoup
@@ -9,6 +10,27 @@ from bs4 import BeautifulSoup
from core import enums
def get_ancestor_to_descendants_map(paths, steplen):
"""
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
Each path is assumed to use materialized path format with fixed-length segments.
Args:
paths (list of str): List of full document paths.
steplen (int): Length of each path segment.
Returns:
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
"""
ancestor_map = defaultdict(set)
for path in paths:
for i in range(steplen, len(path) + 1, steplen):
ancestor = path[:i]
ancestor_map[ancestor].add(path)
return ancestor_map
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.

View File

@@ -1,16 +1,19 @@
# ruff: noqa: S311, S106
"""create_demo management command"""
import base64
import logging
import math
import random
import time
from collections import defaultdict
from uuid import uuid4
from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
import pycrdt
from faker import Faker
from core import models
@@ -27,6 +30,16 @@ def random_true_with_probability(probability):
return random.random() < probability
def get_ydoc_for_text(text):
"""Return a ydoc from plain text for demo purposes."""
ydoc = pycrdt.Doc()
paragraph = pycrdt.XmlElement("p", {}, [pycrdt.XmlText(text)])
fragment = pycrdt.XmlFragment([paragraph])
ydoc["document-store"] = fragment
update = ydoc.get_update()
return base64.b64encode(update).decode("utf-8")
class BulkQueue:
"""A utility class to create Django model instances in bulk by just pushing to a queue."""
@@ -48,7 +61,7 @@ class BulkQueue:
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
def push(self, obj):
"""Add a model instance to queue to that it gets created in bulk."""
"""Add a model instance to queue so that it gets created in bulk."""
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
objects.append(obj)
if len(objects) > self.BATCH_SIZE:
@@ -139,17 +152,19 @@ def create_demo(stdout):
# pylint: disable=protected-access
key = models.Document._int2str(i) # noqa: SLF001
padding = models.Document.alphabet[0] * (models.Document.steplen - len(key))
queue.push(
models.Document(
depth=1,
path=f"{padding}{key}",
creator_id=random.choice(users_ids),
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
title = fake.sentence(nb_words=4)
document = models.Document(
id=uuid4(),
depth=1,
path=f"{padding}{key}",
creator_id=random.choice(users_ids),
title=title,
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
queue.push(document)
queue.flush()

View File

@@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from cryptography.fernet import Fernet
from csp.constants import NONE
from lasuite.configuration.values import SecretFileValue
from sentry_sdk.integrations.django import DjangoIntegration
@@ -99,6 +100,28 @@ class Base(Configuration):
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Search
SEARCH_INDEXER_CLASS = values.Value(
default=None,
environ_name="SEARCH_INDEXER_CLASS",
environ_prefix=None,
)
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
)
SEARCH_INDEXER_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
)
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
)
SEARCH_INDEXER_SECRET = values.Value(
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
)
SEARCH_INDEXER_QUERY_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
)
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
@@ -142,7 +165,7 @@ class Base(Configuration):
)
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.Value(
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
10 * (2**20), # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
@@ -922,6 +945,14 @@ class Development(Base):
},
}
# There is no key for token storage in default configuration.
# In development environment we can create one if needed.
OIDC_STORE_REFRESH_TOKEN_KEY = values.Value(
default=Fernet.generate_key().decode(),
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
environ_prefix=None,
)
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]

View File

@@ -50,7 +50,13 @@ ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
FROM nginxinc/nginx-unprivileged:alpine3.22 AS frontend-production
# Upgrade system packages to install security updates
USER root
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
# Un-privileged user running the application
ARG DOCKER_USER

View File

@@ -89,8 +89,8 @@ test.describe('Doc Create: Not logged', () => {
const data = {
title,
content: markdown,
sub: `user@${browserName}.test`,
email: `user@${browserName}.test`,
sub: `user.test@${browserName}.test`,
email: `user.test@${browserName}.test`,
};
const newDoc = await request.post(

View File

@@ -11,7 +11,8 @@ import {
overrideConfig,
verifyDocName,
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -86,8 +87,7 @@ test.describe('Doc Editor', () => {
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
@@ -100,7 +100,7 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByLabel('Connected').click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
@@ -238,17 +238,7 @@ test.describe('Doc Editor', () => {
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
user_role: 'reader',
});
await goToGridDoc(page);
@@ -257,6 +247,9 @@ test.describe('Doc Editor', () => {
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {
@@ -512,10 +505,7 @@ test.describe('Doc Editor', () => {
await verifyDocName(page, randomDoc);
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.locator('.bn-block-outer').last().fill('/');
const editor = await openSuggestionMenu({ page });
await page.getByText('Embedded file').click();
await page.getByText('Upload file').click();
@@ -682,9 +672,7 @@ test.describe('Doc Editor', () => {
test('it checks if callout custom block', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
await openSuggestionMenu({ page });
await page.getByText('Add a callout block').click();
const calloutBlock = page
@@ -769,15 +757,21 @@ test.describe('Doc Editor', () => {
await expect(searchContainer.getByText(docChild2)).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeHidden();
// use keydown to select the second result
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
const interlink = page.getByRole('link', {
name: 'child-2',
// Wait for the search container to disappear, indicating selection was made
await expect(searchContainer).toBeHidden();
// Wait for the interlink to be created and rendered
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('link', {
name: docChild2,
});
await expect(interlink).toBeVisible();
await expect(interlink).toBeVisible({ timeout: 10000 });
await interlink.click();
await verifyDocName(page, docChild2);
@@ -798,4 +792,86 @@ test.describe('Doc Editor', () => {
),
).toBeVisible();
});
test('it checks multiple big doc scroll to the top', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-scroll', browserName, 1);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Enter');
await writeInEditor({ page, text: 'Hello Parent ' + i });
}
const editor = await getEditor({ page });
await expect(
editor.getByText('Hello Parent 1', { exact: true }),
).not.toBeInViewport();
await expect(editor.getByText('Hello Parent 14')).toBeInViewport();
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-scroll-child',
);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Enter');
await writeInEditor({ page, text: 'Hello Child ' + i });
}
await expect(
editor.getByText('Hello Child 1', { exact: true }),
).not.toBeInViewport();
await expect(editor.getByText('Hello Child 14')).toBeInViewport();
await navigateToPageFromTree({ page, title: randomDoc });
await expect(
editor.getByText('Hello Parent 1', { exact: true }),
).toBeInViewport();
await expect(editor.getByText('Hello Parent 14')).not.toBeInViewport();
await navigateToPageFromTree({ page, title: docChild });
await expect(
editor.getByText('Hello Child 1', { exact: true }),
).toBeInViewport();
await expect(editor.getByText('Hello Child 14')).not.toBeInViewport();
});
test('it embeds PDF', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
const pdfBlock = page.locator('div[data-content-type="pdf"]').first();
await expect(pdfBlock).toBeVisible();
await page.getByText('Add PDF').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload file').click();
const fileChooser = await fileChooserPromise;
console.log(path.join(__dirname, 'assets/test-pdf.pdf'));
await fileChooser.setFiles(path.join(__dirname, 'assets/test-pdf.pdf'));
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
const pdfEmbed = page
.locator('.--docs--editor-container embed.bn-visual-media')
.first();
// Check src of pdf
expect(await pdfEmbed.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
);
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
});
});

View File

@@ -93,6 +93,7 @@ test.describe('Doc Export', () => {
expect(pdfData.numpages).toBe(2);
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
expect(pdfData.info.Title).toBe(randomDoc);
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -393,7 +394,7 @@ test.describe('Doc Export', () => {
})
.click();
const input = page.locator('.--docs--doc-title-input[role="textbox"]');
const input = page.getByRole('textbox', { name: 'Titre du document' });
await expect(input).toBeVisible();
await expect(input).toHaveText('', { timeout: 10000 });
await input.click();
@@ -410,6 +411,10 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDocFrench}.pdf`);
});

View File

@@ -28,7 +28,7 @@ test.describe('Documents Grid mobile', () => {
id: '8c1e047a-24e7-4a80-942b-8e9c7ab43e1f',
user: {
id: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
email: 'test@test.test',
email: 'test.test@test.test',
full_name: 'John Doe',
short_name: 'John',
},
@@ -117,7 +117,7 @@ test.describe('Document grid item options', () => {
await page.getByText('push_pin').click();
// Check is pinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
@@ -126,7 +126,7 @@ test.describe('Document grid item options', () => {
await page.getByText('Unpin').click();
// Check is unpinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});

View File

@@ -75,22 +75,22 @@ test.describe('Doc Header', () => {
// Check the tree
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
await expect(docTree.getByLabel('Document emoji icon')).toBeVisible();
await expect(docTree.getByLabel('Simple document icon')).toBeHidden();
await expect(docTree.getByTestId('doc-emoji-icon')).toBeVisible();
await expect(docTree.getByTestId('doc-simple-icon')).toBeHidden();
await page.getByTestId('home-button').click();
// Check the documents grid
const gridRow = await getGridRow(page, 'Hello Emoji World');
await expect(gridRow.getByLabel('Document emoji icon')).toBeVisible();
await expect(gridRow.getByLabel('Simple document icon')).toBeHidden();
await expect(gridRow.getByTestId('doc-emoji-icon')).toBeVisible();
await expect(gridRow.getByTestId('doc-simple-icon')).toBeHidden();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Delete document').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -148,7 +148,9 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -164,7 +166,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
await expect(invitationRole).toBeVisible();
@@ -178,7 +180,7 @@ test.describe('Doc Header', () => {
const roles = memberCard.getByLabel('doc-role-dropdown');
await expect(memberCard).toBeVisible();
await expect(
memberCard.getByText('test@accesses.test').first(),
memberCard.getByText('test.test@accesses.test').first(),
).toBeVisible();
await expect(roles).toBeVisible();
@@ -221,7 +223,9 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -239,7 +243,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
@@ -247,7 +251,7 @@ test.describe('Doc Header', () => {
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
@@ -287,7 +291,9 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -302,7 +308,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
@@ -310,7 +316,7 @@ test.describe('Doc Header', () => {
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
@@ -343,7 +349,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByLabel('Copy as Markdown').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -377,7 +383,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByLabel('Copy as HTML').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -434,11 +440,15 @@ test.describe('Doc Header', () => {
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Pin doc`, browserName);
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
// Pin
await page.getByText('push_pin').click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByText('Unpin')).toBeVisible();
await page.goto('/');
@@ -446,22 +456,26 @@ test.describe('Doc Header', () => {
const row = await getGridRow(page, docTitle);
// Check is pinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
await row.getByText(docTitle).click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
// Unpin
await page.getByText('Unpin').click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByText('push_pin')).toBeVisible();
await page.goto('/');
// Check is unpinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
@@ -560,7 +574,7 @@ test.describe('Documents Header mobile', () => {
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByLabel('Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
});
@@ -583,7 +597,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',

View File

@@ -18,7 +18,7 @@ test.describe('Inherited share accesses', () => {
).toBeVisible();
const user = page.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
`doc-share-member-row-user.test@${browserName}.test`,
);
await expect(user).toBeVisible();
await expect(user.getByText('E2E Chromium')).toBeVisible();

View File

@@ -7,6 +7,7 @@ import {
randomName,
verifyDocName,
} from './utils-common';
import { connectOtherUserToDoc, updateRoleUser } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Document create member', () => {
@@ -25,9 +26,8 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
await expect(inputSearch).toBeVisible();
// Select user 1 and verify tag
@@ -74,13 +74,15 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByLabel('Reader')).toBeVisible();
await expect(page.getByLabel('Editor')).toBeVisible();
await expect(page.getByLabel('Owner')).toBeVisible();
await expect(page.getByLabel('Administrator')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -117,9 +119,7 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
@@ -128,7 +128,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Owner').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -146,7 +146,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Owner').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -167,9 +167,7 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
@@ -178,7 +176,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -198,21 +196,17 @@ test.describe('Document create member', () => {
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Reader').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'Open invitation actions menu',
});
await moreActions.click();
await page.getByLabel('Delete').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
});
});
test.describe('Document create member: Multiple login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It creates a member from a request coming from a 403 page', async ({
page,
@@ -220,9 +214,6 @@ test.describe('Document create member: Multiple login', () => {
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Member access request',
@@ -232,67 +223,67 @@ test.describe('Document create member: Multiple login', () => {
await verifyDocName(page, docTitle);
await page
.locator('.ProseMirror')
.locator('.bn-block-outer')
.last()
.fill('Hello World');
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.goto(urlDoc);
// Other user will request access
const { otherPage, otherBrowserName, cleanup } =
await connectOtherUserToDoc(browserName, urlDoc);
await expect(
page.getByText('Insufficient access rights to view the document.'),
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
await page.getByRole('button', { name: 'Request access' }).click();
await otherPage.getByRole('button', { name: 'Request access' }).click();
await expect(
page.getByText('Your access request for this document is pending.'),
otherPage.getByText('Your access request for this document is pending.'),
).toBeVisible();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await page.goto('/');
await keyCloakSignIn(page, browserName);
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
// First user approves the request
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByText('Access Requests')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
const emailRequest = `user@${otherBrowser}.test`;
const emailRequest = `user.test@${otherBrowserName}.test`;
await expect(page.getByText(emailRequest)).toBeVisible();
const container = page.getByTestId(
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden();
await expect(page.getByText('Share with 2 users')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
// Other user verifies he has access
await otherPage.reload();
await verifyDocName(otherPage, docTitle);
await expect(otherPage.getByText('Hello World')).toBeVisible();
// Revoke access
await updateRoleUser(page, 'Remove access', emailRequest);
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible();
// Cleanup: other user logout
await cleanup();
});
});
test.describe('Document create member: Multiple login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It cannot request member access on child doc on a 403 page', async ({
page,

View File

@@ -139,7 +139,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
await expect(list).toBeVisible();
const currentUser = list.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await expect(currentUser).toBeVisible();
@@ -171,12 +171,12 @@ test.describe('Document list members', () => {
});
await currentUserRole.click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByLabel('Reader').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -190,7 +190,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
const emailMyself = `user@${browserName}.test`;
const emailMyself = `user.test@${browserName}.test`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfRole = mySelf.getByRole('button', {
name: 'doc-role-dropdown',

View File

@@ -93,6 +93,12 @@ test.describe('Doc Routing', () => {
await expect(page.getByText('Log in to access the document.')).toBeVisible({
timeout: 10000,
});
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex',
);
await expect(page).toHaveTitle(/401 Unauthorized - Docs/);
});
});

View File

@@ -8,8 +8,6 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => {
test.setTimeout(60000);
const [randomDoc] = await createDoc(
page,
'doc-table-content',

View File

@@ -220,11 +220,11 @@ test.describe('Doc Tree', () => {
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
@@ -235,6 +235,12 @@ test.describe('Doc Tree', () => {
'doc-tree-detach-child',
);
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Administrator ·'),
).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docChild)).toBeVisible();
await docTree.click();
@@ -252,6 +258,46 @@ test.describe('Doc Tree', () => {
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
});
test('keyboard navigation with Enter key opens documents', async ({
page,
browserName,
}) => {
// Create a parent document
const [docParent] = await createDoc(
page,
'doc-tree-keyboard-nav',
browserName,
1,
);
await verifyDocName(page, docParent);
// Create a sub-document
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-keyboard-child',
);
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible();
// Test keyboard navigation on root document
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
// Focus on the root item and press Enter
await rootItem.focus();
await expect(rootItem).toBeFocused();
await page.keyboard.press('Enter');
// Verify we navigated to the root document
await verifyDocName(page, docParent);
await expect(page).toHaveURL(/\/docs\/[^/]+\/?$/);
// Now test keyboard navigation on sub-document
await expect(docTree.getByText(docChild)).toBeVisible();
});
});
test.describe('Doc Tree: Inheritance', () => {

View File

@@ -18,7 +18,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, randomDoc);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal');
@@ -54,7 +54,7 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByLabel('Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
@@ -82,7 +82,9 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Version history')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -109,7 +111,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByLabel('Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');

View File

@@ -7,6 +7,7 @@ import {
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc Visibility', () => {
@@ -44,17 +45,21 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Read only' }),
).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Can read and edit' }),
).toBeHidden();
await selectVisibility.click();
await page.getByLabel('Connected').click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await page.getByLabel('Public', { exact: true }).click();
await page.getByRole('menuitem', { name: 'Public' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
@@ -146,47 +151,31 @@ test.describe('Doc Visibility: Restricted', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const otherBrowser = BROWSERS.find((b) => b !== browserName);
if (!otherBrowser) {
throw new Error('No alternative browser found');
}
const username = `user@${otherBrowser}.test`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Reader').click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await page
.locator('.ProseMirror')
.locator('.bn-block-outer')
.last()
.fill('Hello World');
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const { otherBrowserName, otherPage } = await connectOtherUserToDoc(
browserName,
urlDoc,
);
await keyCloakSignIn(page, otherBrowser);
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page.goto(urlDoc);
await addNewMember(page, 0, 'Reader', otherBrowserName);
await verifyDocName(page, docTitle);
await expect(page.getByLabel('Share button')).toBeVisible();
await otherPage.reload();
await expect(otherPage.getByText('Hello World')).toBeVisible();
});
});
@@ -308,7 +297,7 @@ test.describe('Doc Visibility: Public', () => {
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -531,7 +520,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),

View File

@@ -47,7 +47,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByLabel('Français').click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
page.locator('footer').getByText('Mentions légales'),
@@ -132,7 +132,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByLabel('Français').click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
page

View File

@@ -131,7 +131,7 @@ test.describe('Home page', () => {
// Keyclock login page
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
page.locator('.login-pf #kc-header-wrapper').getByText('impress'),
).toBeVisible();
});
});

View File

@@ -1,6 +1,7 @@
import { Page, expect } from '@playwright/test';
export const BROWSERS = ['chromium', 'webkit', 'firefox'];
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
export const CONFIG = {
AI_FEATURE_ENABLED: true,
@@ -56,7 +57,7 @@ export const keyCloakSignIn = async (
const password = `password-e2e-${browserName}`;
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
page.locator('.login-pf #kc-header-wrapper').getByText('impress'),
).toBeVisible();
if (await page.getByLabel('Restart login').isVisible()) {
@@ -65,7 +66,7 @@ export const keyCloakSignIn = async (
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
await page.click('button[type="submit"]', { force: true });
};
export const randomName = (name: string, browserName: string, length: number) =>
@@ -322,5 +323,5 @@ export async function waitForLanguageSwitch(
await languagePicker.click();
await page.getByLabel(lang.label).click();
await page.getByRole('menuitem', { name: lang.label }).click();
}

View File

@@ -0,0 +1,27 @@
import { Page } from '@playwright/test';
export const getEditor = async ({ page }: { page: Page }) => {
const editor = page.locator('.ProseMirror');
await editor.click();
return editor;
};
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
const editor = await getEditor({ page });
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
return editor;
};
export const writeInEditor = async ({
page,
text,
}: {
page: Page;
text: string;
}) => {
const editor = await getEditor({ page });
editor.locator('.bn-block-outer').last().fill(text);
return editor;
};

View File

@@ -1,4 +1,11 @@
import { Page, expect } from '@playwright/test';
import { Page, chromium, expect } from '@playwright/test';
import {
BROWSERS,
BrowserName,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
export type LinkReach = 'Private' | 'Connected' | 'Public';
@@ -16,9 +23,7 @@ export const addNewMember = async (
response.status() === 200,
);
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
// Select a new user
await inputSearch.fill(fillText);
@@ -34,7 +39,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByLabel(role).click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -61,6 +66,73 @@ export const updateShareLink = async (
}
};
export const updateRoleUser = async (
page: Page,
role: Role | 'Remove access',
email: string,
) => {
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: role }).click();
await list.click();
};
/**
* Connects another user to a document.
* Useful to test real-time collaboration features.
* @param browserName The name of the browser to use.
* @param docUrl The URL of the document to connect to.
* @param docTitle The title of the document (optional).
* @returns An object containing the other browser, context, and page.
*/
export const connectOtherUserToDoc = async (
browserName: BrowserName,
docUrl: string,
docTitle?: string,
) => {
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
if (!otherBrowserName) {
throw new Error('No alternative browser found');
}
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({
locale: 'en-US',
timezoneId: 'Europe/Paris',
permissions: [],
storageState: {
cookies: [],
origins: [],
},
});
const otherPage = await otherContext.newPage();
await otherPage.goto(docUrl);
await otherPage
.getByRole('main', { name: 'Main content' })
.getByLabel('Login')
.click({
timeout: 15000,
});
await keyCloakSignIn(otherPage, otherBrowserName, false);
if (docTitle) {
await verifyDocName(otherPage, docTitle);
}
const cleanup = async () => {
await otherPage.close();
await otherContext.close();
await otherBrowser.close();
};
return { otherBrowser, otherContext, otherPage, otherBrowserName, cleanup };
};
export const mockedInvitations = async (page: Page, json?: object) => {
let result = [
{
@@ -72,7 +144,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test@invitation.test',
email: 'test.test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
@@ -129,7 +201,7 @@ export const mockedAccesses = async (page: Page, json?: object) => {
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test@accesses.test',
email: 'test.test@accesses.test',
},
team: '',
max_ancestors_role: null,

View File

@@ -3,6 +3,7 @@ import { Page, expect } from '@playwright/test';
import {
randomName,
updateDocTitle,
verifyDocName,
waitForResponseCreateDoc,
} from './utils-common';
@@ -63,5 +64,17 @@ export const clickOnAddRootSubPage = async (page: Page) => {
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
await rootItem.hover();
await rootItem.getByRole('button', { name: 'add_box' }).click();
await rootItem.getByTestId('doc-tree-item-actions-add-child').click();
};
export const navigateToPageFromTree = async ({
page,
title,
}: {
page: Page;
title: string;
}) => {
const docTree = page.getByTestId('doc-tree');
await docTree.getByText(title).click();
await verifyDocName(page, title);
};

View File

@@ -12,9 +12,14 @@ export const Icon = ({
variant = 'outlined',
...textProps
}: IconProps) => {
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
const ariaHidden =
'aria-hidden' in textProps ? textProps['aria-hidden'] : !hasLabel;
return (
<Text
{...textProps}
aria-hidden={ariaHidden}
className={clsx('--docs--icon-bg', textProps.className, {
'material-icons-filled': variant === 'filled',
'material-icons': variant === 'outlined',

View File

@@ -13,6 +13,7 @@ export interface TextProps extends BoxProps {
$ellipsis?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$textTransform?: CSSProperties['textTransform'];
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
@@ -43,6 +44,8 @@ export type TextType = ComponentPropsWithRef<typeof Text>;
export const TextStyled = styled(Box)<TextProps>`
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
${({ $textTransform }) =>
$textTransform && `text-transform: ${$textTransform};`}
${({ $weight }) => $weight && `font-weight: ${$weight};`}
${({ $size }) =>
$size &&

View File

@@ -162,7 +162,6 @@ export const DropdownMenu = ({
menuItemRefs.current[index] = el;
}}
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}

View File

@@ -1,11 +1,5 @@
import { Command } from 'cmdk';
import {
PropsWithChildren,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { PropsWithChildren, ReactNode, useId, useRef, useState } from 'react';
import { hasChildrens } from '@/utils/children';
@@ -49,32 +43,23 @@ export const QuickSearch = ({
children,
}: PropsWithChildren<QuickSearchProps>) => {
const ref = useRef<HTMLDivElement | null>(null);
const [selectedValue, setSelectedValue] = useState<string>('');
const listId = useId();
const NO_SELECTION_VALUE = '__none__';
const [userInteracted, setUserInteracted] = useState(false);
const [selectedValue, setSelectedValue] = useState(NO_SELECTION_VALUE);
const isExpanded = userInteracted;
// Auto-select first item when children change
useEffect(() => {
if (!children) {
setSelectedValue('');
return;
const handleValueChange = (val: string) => {
if (userInteracted) {
setSelectedValue(val);
}
};
// Small delay for DOM to update
const timeoutId = setTimeout(() => {
const firstItem = ref.current?.querySelector('[cmdk-item]');
if (firstItem) {
const value =
firstItem.getAttribute('data-value') ||
firstItem.getAttribute('value') ||
firstItem.textContent?.trim() ||
'';
if (value) {
setSelectedValue(value);
}
}
}, 50);
return () => clearTimeout(timeoutId);
}, [children]);
const handleUserInteract = () => {
if (!userInteracted) {
setUserInteracted(true);
}
};
return (
<>
@@ -84,9 +69,9 @@ export const QuickSearch = ({
label={label}
shouldFilter={false}
ref={ref}
value={selectedValue}
onValueChange={setSelectedValue}
tabIndex={0}
value={selectedValue}
onValueChange={handleValueChange}
>
{showInput && (
<QuickSearchInput
@@ -95,11 +80,14 @@ export const QuickSearch = ({
inputValue={inputValue}
onFilter={onFilter}
placeholder={placeholder}
listId={listId}
isExpanded={isExpanded}
onUserInteract={handleUserInteract}
>
{inputContent}
</QuickSearchInput>
)}
<Command.List>
<Command.List id={listId} aria-label={label} role="listbox">
<Box>{children}</Box>
</Command.List>
</Command>

View File

@@ -16,6 +16,9 @@ type Props = {
placeholder?: string;
children?: ReactNode;
withSeparator?: boolean;
listId?: string;
onUserInteract?: () => void;
isExpanded?: boolean;
};
export const QuickSearchInput = ({
loading,
@@ -24,6 +27,9 @@ export const QuickSearchInput = ({
placeholder,
children,
withSeparator: separator = true,
listId,
onUserInteract,
isExpanded,
}: Props) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
@@ -57,14 +63,19 @@ export const QuickSearchInput = ({
<Command.Input
autoFocus={true}
aria-label={t('Quick search input')}
aria-expanded={isExpanded}
aria-controls={listId}
onClick={(e) => {
e.stopPropagation();
onUserInteract?.();
}}
onKeyDown={() => onUserInteract?.()}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
maxLength={254}
data-testid="quick-search-input"
/>
</Box>
{separator && <HorizontalSeparator $withPadding={false} />}

View File

@@ -38,6 +38,13 @@
);
}
/**
* Button
*/
.c__button {
contain: content;
}
/**
* Modal
*/

View File

@@ -37,6 +37,8 @@ import {
AccessibleImageBlock,
CalloutBlock,
DividerBlock,
PdfBlock,
UploadLoaderBlock,
} from './custom-blocks';
import {
InterlinkingLinkInlineContent,
@@ -54,6 +56,8 @@ const baseBlockNoteSchema = withPageBreak(
callout: CalloutBlock,
divider: DividerBlock,
image: AccessibleImageBlock,
pdf: PdfBlock,
uploadLoader: UploadLoaderBlock,
},
inlineContentSpecs: {
...defaultInlineContentSpecs,

View File

@@ -18,6 +18,7 @@ import {
import {
getCalloutReactSlashMenuItems,
getDividerReactSlashMenuItems,
getPdfReactSlashMenuItems,
} from './custom-blocks';
import { useGetInterlinkingMenuItems } from './custom-inline-content';
import XLMultiColumn from './xl-multi-column';
@@ -32,7 +33,10 @@ export const BlockNoteSuggestionMenu = () => {
DocsStyleSchema
>();
const { t } = useTranslation();
const basicBlocksName = useDictionary().slash_menu.page_break.group;
const dictionaryDate = useDictionary();
const basicBlocksName = dictionaryDate.slash_menu.page_break.group;
const fileBlocksName = dictionaryDate.slash_menu.file.group;
const getInterlinkingMenuItems = useGetInterlinkingMenuItems();
const getSlashMenuItems = useMemo(() => {
@@ -56,11 +60,12 @@ export const BlockNoteSuggestionMenu = () => {
getMultiColumnSlashMenuItems?.(editor) || [],
getPageBreakReactSlashMenuItems(editor),
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
),
query,
),
);
}, [basicBlocksName, editor, getInterlinkingMenuItems, t]);
}, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]);
return (
<SuggestionMenuController

View File

@@ -10,6 +10,7 @@ import {
imageRender,
imageToExternalHTML,
} from '@blocknote/core';
import { t } from 'i18next';
type ImageBlockConfig = typeof imageBlockConfig;
@@ -25,10 +26,73 @@ export const accessibleImageRender = (
const dom = imageRenderComputed.dom;
const imgSelector = dom.querySelector('img');
imgSelector?.setAttribute('alt', '');
imgSelector?.setAttribute('role', 'presentation');
imgSelector?.setAttribute('aria-hidden', 'true');
imgSelector?.setAttribute('tabindex', '-1');
const withCaption =
block.props.caption && dom.querySelector('.bn-file-caption');
const accessibleImageWithCaption = () => {
imgSelector?.setAttribute('alt', block.props.caption);
imgSelector?.removeAttribute('aria-hidden');
imgSelector?.setAttribute('tabindex', '0');
// Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists
const captionElement = dom.querySelector('.bn-file-caption');
if (captionElement) {
const figureElement = document.createElement('figure');
// Copy all attributes from the original div
figureElement.className = dom.className;
const styleAttr = dom.getAttribute('style');
if (styleAttr) {
figureElement.setAttribute('style', styleAttr);
}
figureElement.style.setProperty('margin', '0');
Array.from(dom.children).forEach((child) => {
figureElement.appendChild(child.cloneNode(true));
});
// Replace the <p> caption with <figcaption>
const figcaptionElement = document.createElement('figcaption');
const originalCaption = figureElement.querySelector('.bn-file-caption');
if (originalCaption) {
figcaptionElement.className = originalCaption.className;
figcaptionElement.textContent = originalCaption.textContent;
originalCaption.parentNode?.replaceChild(
figcaptionElement,
originalCaption,
);
// Add explicit role and aria-label for better screen reader support
figureElement.setAttribute('role', 'img');
figureElement.setAttribute(
'aria-label',
t(`Image: {{title}}`, { title: figcaptionElement.textContent }),
);
}
// Return the figure element as the new dom
return {
...imageRenderComputed,
dom: figureElement,
};
}
};
const accessibleImage = () => {
imgSelector?.setAttribute('alt', '');
imgSelector?.setAttribute('role', 'presentation');
imgSelector?.setAttribute('aria-hidden', 'true');
imgSelector?.setAttribute('tabindex', '-1');
};
// Set accessibility attributes for the image
const result = withCaption ? accessibleImageWithCaption() : accessibleImage();
// Return the result if accessibleImageWithCaption created a figure, otherwise return original
if (result) {
return result;
}
return {
...imageRenderComputed,

View File

@@ -0,0 +1,86 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { insertOrUpdateBlock } from '@blocknote/core';
import {
AddFileButton,
ResizableFileBlockWrapper,
createReactBlockSpec,
} from '@blocknote/react';
import { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
const PDFBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="pdf"] {
width: fit-content;
}
`;
type FileBlockEditor = Parameters<typeof AddFileButton>[0]['editor'];
export const PdfBlock = createReactBlockSpec(
{
type: 'pdf',
content: 'none',
propSchema: {
name: { default: '' as const },
url: { default: '' as const },
caption: { default: '' as const },
showPreview: { default: true },
previewWidth: { default: undefined, type: 'number' },
},
isFileBlock: true,
fileBlockAccept: ['application/pdf'],
},
{
render: ({ editor, block, contentRef }) => {
const { t } = useTranslation();
const pdfUrl = block.props.url;
return (
<Box ref={contentRef} className="bn-file-block-content-wrapper">
<PDFBlockStyle />
<ResizableFileBlockWrapper
buttonIcon={<Icon iconName="upload" />}
block={block}
editor={editor as unknown as FileBlockEditor}
buttonText={t('Add PDF')}
>
<Box
className="bn-visual-media"
role="presentation"
as="embed"
$width="100%"
$height="450px"
type="application/pdf"
src={pdfUrl}
contentEditable={false}
draggable={false}
onClick={() => editor.setTextCursorPosition(block)}
/>
</ResizableFileBlockWrapper>
</Box>
);
},
},
);
export const getPdfReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('PDF'),
onItemClick: () => {
insertOrUpdateBlock(editor, { type: 'pdf' });
},
aliases: [t('pdf'), t('document'), t('embed'), t('file')],
group,
icon: <Icon iconName="picture_as_pdf" $size="18px" />,
subtext: t('Embed a PDF file'),
},
];

View File

@@ -0,0 +1,34 @@
import { createReactBlockSpec } from '@blocknote/react';
import { Box, Text } from '@/components';
import Loader from '../../assets/loader.svg';
import Warning from '../../assets/warning.svg';
export const UploadLoaderBlock = createReactBlockSpec(
{
type: 'uploadLoader',
propSchema: {
information: { default: '' as const },
type: {
default: 'loading' as const,
values: ['loading', 'warning'] as const,
},
},
content: 'none',
},
{
render: ({ block }) => {
return (
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
{block.props.type === 'warning' ? (
<Warning />
) : (
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
)}
<Text>{block.props.information}</Text>
</Box>
);
},
},
);

View File

@@ -1,3 +1,5 @@
export * from './AccessibleImageBlock';
export * from './CalloutBlock';
export * from './DividerBlock';
export * from './PdfBlock';
export * from './UploadLoaderBlock';

View File

@@ -6,8 +6,6 @@ import { useMediaUrl } from '@/core/config';
import { sleep } from '@/utils';
import { checkDocMediaStatus, useCreateDocAttachment } from '../api';
import Loader from '../assets/loader.svg?url';
import Warning from '../assets/warning.svg?url';
import { DocsBlockNoteEditor } from '../types';
/**
@@ -33,52 +31,6 @@ const loopCheckDocMediaStatus = async (url: string) => {
}
};
const informationStatus = (src: string, text: string) => {
const loadingContainer = document.createElement('div');
loadingContainer.style.display = 'flex';
loadingContainer.style.alignItems = 'center';
loadingContainer.style.justifyContent = 'left';
loadingContainer.style.padding = '10px';
loadingContainer.style.color = '#666';
loadingContainer.className =
'bn-visual-media bn-audio bn-file-name-with-icon';
// Create an image element for the SVG
const imgElement = document.createElement('img');
imgElement.src = src;
// Create a text span
const textSpan = document.createElement('span');
textSpan.textContent = text;
textSpan.style.marginLeft = '8px';
textSpan.style.verticalAlign = 'middle';
imgElement.style.animation = 'spin 1.5s linear infinite';
// Add the spinner and text to the container
loadingContainer.appendChild(imgElement);
loadingContainer.appendChild(textSpan);
return loadingContainer;
};
const replaceUploadContent = (blockId: string, elementReplace: HTMLElement) => {
const blockEl = document.body.querySelector(
`.bn-block[data-id="${blockId}"]`,
);
blockEl
?.querySelector('.bn-visual-media-wrapper .bn-visual-media')
?.replaceWith(elementReplace);
blockEl
?.querySelector('.bn-file-block-content-wrapper .bn-audio')
?.replaceWith(elementReplace);
blockEl
?.querySelector('.bn-file-block-content-wrapper .bn-file-name-with-icon')
?.replaceWith(elementReplace);
};
export const useUploadFile = (docId: string) => {
const {
mutateAsync: createDocAttachment,
@@ -122,35 +74,55 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
// Delay to let the time to the dom to be rendered
const timoutId = setTimeout(() => {
replaceUploadContent(
blockId,
informationStatus(Loader.src, t('Analyzing file...')),
// Replace the resource block by a loading block
const { insertedBlocks, removedBlocks } = editor.replaceBlocks(
[blockId],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
},
},
],
);
loopCheckDocMediaStatus(url)
.then((response) => {
const block = editor.getBlock(blockId);
if (!block) {
if (insertedBlocks.length === 0 || removedBlocks.length === 0) {
return;
}
block.props = {
...block.props,
const loadingBlockId = insertedBlocks[0].id;
const removedBlock = removedBlocks[0];
removedBlock.props = {
...removedBlock.props,
url: `${mediaUrl}${response.file}`,
};
editor.updateBlock(blockId, block);
// Replace the loading block with the resource block (image, audio, video, pdf ...)
editor.replaceBlocks([loadingBlockId], [removedBlock]);
})
.catch((error) => {
console.error('Error analyzing file:', error);
replaceUploadContent(
blockId,
informationStatus(
Warning.src,
t('The antivirus has detected an anomaly in your file.'),
const loadingBlock = insertedBlocks[0];
if (!loadingBlock) {
return;
}
loadingBlock.props = {
...loadingBlock.props,
type: 'warning',
information: t(
'The antivirus has detected an anomaly in your file.',
),
);
};
editor.updateBlock(loadingBlock.id, loadingBlock);
});
}, 250);

View File

@@ -91,6 +91,11 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
text-decoration: none;
}
h1 {
font-size: 1.875rem;
}

View File

@@ -2,6 +2,26 @@ import { Text } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
// Helper function to extract plain text from block content
function extractTextFromBlockContent(content: unknown[]): string {
return content
.map((item) => {
if (
typeof item === 'object' &&
item !== null &&
'type' in item &&
'text' in item
) {
if (item.type === 'text' && typeof item.text === 'string') {
return item.text;
}
}
return '';
})
.join('')
.trim();
}
export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']['heading'] =
(block, exporter) => {
const PIXELS_PER_POINT = 0.75;
@@ -9,9 +29,18 @@ export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']
const FONT_SIZE = 16;
const fontSizeEM =
block.props.level === 1 ? 2 : block.props.level === 2 ? 1.5 : 1.17;
// Extract plain text for bookmark title
const bookmarkTitle =
extractTextFromBlockContent(block.content) || 'Untitled';
return (
<Text
key={block.id}
// @ts-expect-error: bookmark is supported by react-pdf but not typed
bookmark={{
title: bookmarkTitle,
}}
style={{
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
fontWeight: 700,

View File

@@ -9,3 +9,5 @@ export * from './paragraphPDF';
export * from './quoteDocx';
export * from './quotePDF';
export * from './tablePDF';
export * from './uploadLoaderPDF';
export * from './uploadLoaderDocx';

View File

@@ -0,0 +1,14 @@
import { Paragraph, TextRun } from 'docx';
import { DocsExporterDocx } from '../types';
export const blockMappingUploadLoaderDocx: DocsExporterDocx['mappings']['blockMapping']['uploadLoader'] =
(block) => {
return new Paragraph({
children: [
new TextRun(block.props.type === 'loading' ? '⏳' : '⚠️'),
new TextRun(' '),
new TextRun(block.props.information),
],
});
};

View File

@@ -0,0 +1,13 @@
import { Text, View } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
export const blockMappingUploadLoaderPDF: DocsExporterPDF['mappings']['blockMapping']['uploadLoader'] =
(block) => {
return (
<View wrap={false} style={{ flexDirection: 'row', gap: 4 }}>
<Text>{block.props.type === 'loading' ? '⏳' : '⚠️'}</Text>
<Text>{block.props.information}</Text>
</View>
);
};

View File

@@ -76,12 +76,14 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
setIsExporting(true);
const title = (doc.title || untitledDocument)
const filename = (doc.title || untitledDocument)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s/g, '-');
const documentTitle = doc.title || untitledDocument;
const html = templateSelected;
let exportDocument = editor.document;
if (html) {
@@ -98,9 +100,13 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
exportDocument,
)) as React.ReactElement<DocumentProps>;
// Inject language for screen reader support
// Add language, title and outline properties to improve PDF accessibility and navigation
const pdfDocument = isValidElement(rawPdfDocument)
? cloneElement(rawPdfDocument, { language: i18next.language })
? cloneElement(rawPdfDocument, {
language: i18next.language,
title: documentTitle,
pageMode: 'useOutlines',
})
: rawPdfDocument;
blobExport = await pdf(pdfDocument).toBlob();
@@ -109,10 +115,13 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
blobExport = await exporter.toBlob(exportDocument);
blobExport = await exporter.toBlob(exportDocument, {
documentOptions: { title: documentTitle },
sectionOptions: {},
});
}
downloadFile(blobExport, `${title}.${format}`);
downloadFile(blobExport, `${filename}.${format}`);
toast(
t('Your {{format}} was downloaded succesfully', {

View File

@@ -6,6 +6,7 @@ import {
blockMappingDividerDocx,
blockMappingImageDocx,
blockMappingQuoteDocx,
blockMappingUploadLoaderDocx,
} from './blocks-mapping';
import { inlineContentMappingInterlinkingLinkDocx } from './inline-content-mapping';
import { DocsExporterDocx } from './types';
@@ -16,8 +17,13 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
...docxDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutDocx,
divider: blockMappingDividerDocx,
// We're using the file block mapping for PDF blocks
// The types don't match exactly but the implementation is compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: docxDefaultSchemaMappings.blockMapping.file as any,
quote: blockMappingQuoteDocx,
image: blockMappingImageDocx,
uploadLoader: blockMappingUploadLoaderDocx,
},
inlineContentMapping: {
...docxDefaultSchemaMappings.inlineContentMapping,

View File

@@ -8,6 +8,7 @@ import {
blockMappingParagraphPDF,
blockMappingQuotePDF,
blockMappingTablePDF,
blockMappingUploadLoaderPDF,
} from './blocks-mapping';
import { inlineContentMappingInterlinkingLinkPDF } from './inline-content-mapping';
import { DocsExporterPDF } from './types';
@@ -23,6 +24,11 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
divider: blockMappingDividerPDF,
quote: blockMappingQuotePDF,
table: blockMappingTablePDF,
// We're using the file block mapping for PDF blocks
// The types don't match exactly but the implementation is compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: pdfDefaultSchemaMappings.blockMapping.file as any,
uploadLoader: blockMappingUploadLoaderPDF,
},
inlineContentMapping: {
...pdfDefaultSchemaMappings.inlineContentMapping,

View File

@@ -7,7 +7,6 @@ import {
Doc,
LinkReach,
Role,
currentDocRole,
getDocLinkReach,
useIsCollaborativeEditable,
useTrans,
@@ -73,7 +72,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
>
{transRole(
isEditable
? currentDocRole(doc.abilities)
? doc.user_role || doc.link_role
: Role.READER,
)}
&nbsp;·&nbsp;

View File

@@ -1,11 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { LinkReach, LinkRole, Role } from '../types';
import { LinkReach, LinkRole } from '../types';
import {
base64ToBlocknoteXmlFragment,
base64ToYDoc,
currentDocRole,
getDocLinkReach,
getDocLinkRole,
getEmojiAndTitle,
@@ -24,56 +23,6 @@ describe('doc-management utils', () => {
vi.clearAllMocks();
});
describe('currentDocRole', () => {
it('should return OWNER when destroy ability is true', () => {
const abilities = {
destroy: true,
accesses_manage: false,
partial_update: false,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.OWNER);
});
it('should return ADMIN when accesses_manage ability is true and destroy is false', () => {
const abilities = {
destroy: false,
accesses_manage: true,
partial_update: false,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.ADMIN);
});
it('should return EDITOR when partial_update ability is true and higher abilities are false', () => {
const abilities = {
destroy: false,
accesses_manage: false,
partial_update: true,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.EDITOR);
});
it('should return READER when no higher abilities are true', () => {
const abilities = {
destroy: false,
accesses_manage: false,
partial_update: false,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.READER);
});
});
describe('base64ToYDoc', () => {
it('should convert base64 string to Y.Doc', () => {
const base64String = 'dGVzdA=='; // "test" in base64

View File

@@ -1,5 +1,3 @@
import { useTranslation } from 'react-i18next';
import { Text, TextType } from '@/components';
type DocIconProps = TextType & {
@@ -15,8 +13,6 @@ export const DocIcon = ({
$weight = '400',
...textProps
}: DocIconProps) => {
const { t } = useTranslation();
if (!emoji) {
return <>{defaultIcon}</>;
}
@@ -28,7 +24,7 @@ export const DocIcon = ({
$variation={$variation}
$weight={$weight}
aria-hidden="true"
aria-label={t('Document emoji icon')}
data-testid="doc-emoji-icon"
>
{emoji}
</Text>

View File

@@ -1,50 +1,23 @@
import { Button } from '@openfun/cunningham-react';
import Head from 'next/head';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import img403 from '@/assets/icons/icon-403.png';
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
import { DEFAULT_QUERY_RETRY } from '@/core';
import { KEY_DOC, useDoc } from '@/docs/doc-management';
import { ButtonAccessRequest } from '@/docs/doc-share';
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
width: fit-content;
`;
export function DocLayout() {
const {
query: { id },
} = useRouter();
if (typeof id !== 'string') {
return null;
}
return (
<>
<Head>
<meta name="robots" content="noindex" />
</Head>
<MainLayout>
<DocPage403 id={id} />
</MainLayout>
</>
);
}
interface DocProps {
id: string;
}
const DocPage403 = ({ id }: DocProps) => {
export const DocPage403 = ({ id }: DocProps) => {
const { t } = useTranslation();
const {
data: requests,
@@ -54,39 +27,19 @@ const DocPage403 = ({ id }: DocProps) => {
docId: id,
page: 1,
});
const { replace } = useRouter();
const hasRequested = !!requests?.results.find(
(request) => request.document === id,
);
const { error: docError, isLoading: isLoadingDoc } = useDoc(
{ id },
{
staleTime: 0,
queryKey: [KEY_DOC, { id }],
retry: (failureCount, error) => {
if (error.status == 403) {
return false;
} else {
return failureCount < DEFAULT_QUERY_RETRY;
}
},
},
);
if (!isLoadingDoc && docError?.status !== 403) {
void replace(`/docs/${id}`);
return <Loading />;
}
if (isLoadingDoc || isLoadingRequest) {
if (isLoadingRequest) {
return <Loading />;
}
return (
<>
<Head>
<meta name="robots" content="noindex" />
<title>
{t('Access Denied - Error 403')} - {t('Docs')}
</title>
@@ -152,13 +105,3 @@ const DocPage403 = ({ id }: DocProps) => {
</>
);
};
const Page: NextPageWithLayout = () => {
return null;
};
Page.getLayout = function getLayout() {
return <DocLayout />;
};
export default Page;

View File

@@ -49,6 +49,8 @@ export const SimpleDocItem = ({
$overflow="auto"
$width="100%"
className="--docs--simple-doc-item"
role="presentation"
aria-label={`${t('Open document {{title}}', { title: doc.title || untitledDocument })}`}
>
<Box
$direction="row"
@@ -59,11 +61,12 @@ export const SimpleDocItem = ({
`}
$padding={`${spacingsTokens['3xs']} 0`}
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
aria-hidden="true"
>
{isPinned ? (
<PinnedDocumentIcon
aria-hidden="true"
aria-label={t('Pin document icon')}
data-testid="doc-pinned-icon"
color={colorsTokens['primary-500']}
/>
) : (
@@ -72,7 +75,7 @@ export const SimpleDocItem = ({
defaultIcon={
<SimpleFileIcon
aria-hidden="true"
aria-label={t('Simple document icon')}
data-testid="doc-simple-icon"
color={colorsTokens['primary-500']}
/>
}
@@ -96,6 +99,7 @@ export const SimpleDocItem = ({
$align="center"
$gap={spacingsTokens['3xs']}
$margin={{ top: '-2px' }}
aria-hidden="true"
>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}

View File

@@ -1,2 +1,3 @@
export * from './DocPage403';
export * from './ModalRemoveDoc';
export * from './SimpleDocItem';

View File

@@ -13,11 +13,14 @@ export interface UseCollaborationStore {
destroyProvider: () => void;
provider: HocuspocusProvider | undefined;
isConnected: boolean;
hasLostConnection: boolean;
resetLostConnection: () => void;
}
const defaultValues = {
provider: undefined,
isConnected: false,
hasLostConnection: false,
};
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
@@ -36,8 +39,15 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
name: storeId,
document: doc,
onStatus: ({ status }) => {
set({
isConnected: status === WebSocketStatus.Connected,
set((state) => {
const nextConnected = status === WebSocketStatus.Connected;
return {
isConnected: nextConnected,
hasLostConnection:
state.isConnected && !nextConnected
? true
: state.hasLostConnection,
};
});
},
});
@@ -56,4 +66,5 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
set(defaultValues);
},
resetLostConnection: () => set({ hasLostConnection: false }),
}));

View File

@@ -60,7 +60,7 @@ export interface Doc {
path: string;
is_favorite: boolean;
link_reach: LinkReach;
link_role: LinkRole;
link_role?: LinkRole;
nb_accesses_direct: number;
nb_accesses_ancestors: number;
computed_link_reach: LinkReach;

View File

@@ -1,17 +1,7 @@
import emojiRegex from 'emoji-regex';
import * as Y from 'yjs';
import { Doc, LinkReach, LinkRole, Role } from './types';
export const currentDocRole = (abilities: Doc['abilities']): Role => {
return abilities.destroy
? Role.OWNER
: abilities.accesses_manage
? Role.ADMIN
: abilities.partial_update
? Role.EDITOR
: Role.READER;
};
import { Doc, LinkReach } from './types';
export const base64ToYDoc = (base64: string) => {
const uint8Array = Buffer.from(base64, 'base64');
@@ -28,7 +18,7 @@ export const getDocLinkReach = (doc: Doc): LinkReach => {
return doc.computed_link_reach ?? doc.link_reach;
};
export const getDocLinkRole = (doc: Doc): LinkRole => {
export const getDocLinkRole = (doc: Doc): Doc['link_role'] => {
return doc.computed_link_role ?? doc.link_role;
};

View File

@@ -125,13 +125,7 @@ export const DocRoleDropdown = ({
},
]}
>
<Text
$theme="primary"
$variation="800"
$css={css`
font-family: Arial, Helvetica, sans-serif;
`}
>
<Text $theme="primary" $variation="800">
{transRole(currentRole)}
</Text>
</DropdownMenu>

View File

@@ -151,12 +151,12 @@ export const DocShareModalInviteUserRow = ({
$direction="row"
$align="center"
$css={css`
font-family: Arial, Helvetica, sans-serif;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-400);
contain: content;
`}
$color="var(--c--theme--colors--greyscale-400)"
$cursor="pointer"
>
<Text $theme="primary" $variation="800">
<Text $theme="primary" $variation="800" $size="sm">
{t('Add')}
</Text>
<Icon

View File

@@ -135,8 +135,9 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
isOpen
closeOnClickOutside
data-testid="doc-share-modal"
aria-describedby="doc-share-modal-title"
aria-labelledby="doc-share-modal-title"
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
aria-modal="true"
onClose={onClose}
title={
<Box $direction="row" $justify="space-between" $align="center">
@@ -160,13 +161,13 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
>
<ShareModalStyle />
<Box
role="dialog"
aria-label={t('Share modal content')}
$height="auto"
$maxHeight={canViewAccesses ? modalContentHeight : 'none'}
$overflow="hidden"
className="--docs--doc-share-modal noPadding "
$justify="space-between"
role="dialog"
aria-label={t('Share modal content')}
>
<Box
$flex={1}
@@ -223,6 +224,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
)}
{canViewAccesses && (
<QuickSearch
label={t('Search results')}
onFilter={(str) => {
setInputValue(str);
onFilter(str);

View File

@@ -54,6 +54,10 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const linkReachOptions: DropdownMenuOption[] = useMemo(() => {
return Object.values(LinkReach).map((key) => {
const isDisabled = doc.abilities.link_select_options[key] === undefined;
let linkRole = undefined;
if (key !== LinkReach.RESTRICTED) {
linkRole = docLinkRole;
}
return {
label: linkReachTranslations[key],
@@ -61,6 +65,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
updateDocLink({
id: doc.id,
link_reach: key,
link_role: linkRole,
}),
isSelected: docLinkReach === key,
disabled: isDisabled,
@@ -70,6 +75,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
doc.abilities.link_select_options,
doc.id,
docLinkReach,
docLinkRole,
linkReachTranslations,
updateDocLink,
]);
@@ -78,7 +84,8 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
(option) => option.disabled,
);
const showLinkRoleOptions = doc.computed_link_reach !== LinkReach.RESTRICTED;
const showLinkRoleOptions =
docLinkReach !== LinkReach.RESTRICTED && docLinkRole;
const linkRoleOptions: DropdownMenuOption[] = useMemo(() => {
const options = doc.abilities.link_select_options[docLinkReach] ?? [];
@@ -175,26 +182,24 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
</Box>
{showLinkRoleOptions && (
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
{docLinkReach !== LinkReach.RESTRICTED && (
<DropdownMenu
testId="doc-access-mode"
disabled={!canManage}
showArrow={true}
options={linkRoleOptions}
topMessage={
haveDisabledLinkRoleOptions
? t(
'You cannot restrict access to a subpage relative to its parent page.',
)
: undefined
}
label={t('Document access mode')}
>
<Text $weight="initial" $variation="600">
{linkModeTranslations[docLinkRole]}
</Text>
</DropdownMenu>
)}
<DropdownMenu
testId="doc-access-mode"
disabled={!canManage}
showArrow={true}
options={linkRoleOptions}
topMessage={
haveDisabledLinkRoleOptions
? t(
'You cannot restrict access to a subpage relative to its parent page.',
)
: undefined
}
label={t('Document access mode')}
>
<Text $weight="initial" $variation="600">
{linkModeTranslations[docLinkRole]}
</Text>
</DropdownMenu>
</Box>
)}
</Box>

View File

@@ -1,6 +1,6 @@
import { css } from 'styled-components';
import { Box } from '@/components';
import { Text } from '@/components';
import { tokens } from '@/cunningham';
import { User } from '@/features/auth';
@@ -37,35 +37,26 @@ export const UserAvatar = ({ user, background }: Props) => {
const splitName = name?.split(' ');
return (
<Box
<Text
className="--docs--user-avatar"
$align="center"
$color="rgba(255, 255, 255, 0.9)"
$justify="center"
$background={background || getColorFromName(name)}
$width="24px"
$height="24px"
$direction="row"
$align="center"
$justify="center"
$radius="50%"
$size="10px"
$textAlign="center"
$textTransform="uppercase"
$weight={600}
$css={css`
color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.5);
contain: content;
`}
className="--docs--user-avatar"
>
<Box
$direction="row"
$css={css`
text-align: center;
font-style: normal;
font-weight: 600;
font-family:
Arial, Helvetica, sans-serif; // Can't use marianne font because it's impossible to center with this font
font-size: 10px;
text-transform: uppercase;
`}
>
{splitName[0]?.charAt(0)}
{splitName?.[1]?.charAt(0)}
</Box>
</Box>
{splitName[0]?.charAt(0)}
{splitName?.[1]?.charAt(0)}
</Text>
);
};

View File

@@ -5,9 +5,10 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
@@ -18,6 +19,8 @@ import { DocIcon } from '@/features/docs/doc-management/components/DocIcon';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
import SubPageIcon from './../assets/sub-page-logo.svg';
import { DocTreeItemActions } from './DocTreeItemActions';
@@ -38,7 +41,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { node } = props;
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const [actionsOpen, setActionsOpen] = useState(false);
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
const isActive = node.isFocused || menuOpen || isSelectedNow;
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
@@ -46,6 +53,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title || '');
const displayTitle = titleWithoutEmoji || untitledDocument;
const handleActivate = () => {
treeContext?.treeData.setSelectedNode(doc);
router.push(`/docs/${doc.id}`);
};
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
@@ -76,62 +88,80 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
};
useKeyboardActivation(
['Enter'],
isActive && !menuOpen,
handleActivate,
true,
'.c__tree-view',
);
const docTitle = doc.title || untitledDocument;
const hasChildren = (doc.children?.length || 0) > 0;
const isExpanded = node.isOpen;
const isSelected = isSelectedNow;
const ariaLabel = docTitle;
return (
<Box
className="--docs-sub-page-item"
draggable={doc.abilities.move && isDesktop}
$position="relative"
role="treeitem"
aria-label={ariaLabel}
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
$css={css`
background-color: ${actionsOpen
background-color: ${menuOpen
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
.light-doc-item-actions {
display: ${actionsOpen || !isDesktop ? 'flex' : 'none'};
display: ${menuOpen || !isDesktop ? 'flex' : 'none'};
position: absolute;
right: 0;
background: ${isDesktop
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
}
.c__tree-view--node.isSelected {
.light-doc-item-actions {
background: var(--c--theme--colors--greyscale-100);
}
}
.c__tree-view--node.isFocused {
outline: none !important;
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-500) !important;
border-radius: 4px;
}
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
border-radius: 4px;
.light-doc-item-actions {
display: flex;
background: var(--c--theme--colors--greyscale-100);
}
}
.row.preview & {
background-color: inherit;
}
`}
>
<TreeViewItem
{...props}
onClick={() => {
treeContext?.treeData.setSelectedNode(props.node.data.value as Doc);
router.push(`/docs/${props.node.data.value.id}`);
}}
>
<Box
data-testid={`doc-sub-page-item-${props.node.data.value.id}`}
<TreeViewItem {...props} onClick={handleActivate}>
<BoxButton
onClick={(e) => {
e.stopPropagation();
handleActivate();
}}
$width="100%"
$direction="row"
$gap={spacingsTokens['xs']}
role="button"
tabIndex={0}
$align="center"
$minHeight="24px"
data-testid={`doc-sub-page-item-${doc.id}`}
aria-label={`${t('Open document {{title}}', { title: docTitle })}`}
$css={css`
text-align: left;
`}
>
<Box $width="16px" $height="16px">
<DocIcon emoji={emoji} defaultIcon={<SubPageIcon />} $size="sm" />
@@ -157,23 +187,25 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
iconName="group"
$size="16px"
$variation="400"
aria-hidden="true"
/>
)}
</Box>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
>
<DocTreeItemActions
doc={doc}
isOpen={actionsOpen}
onOpenChange={setActionsOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</BoxButton>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
role="toolbar"
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
>
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={setMenuOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</TreeViewItem>
</Box>

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