Compare commits

...

80 Commits

Author SHA1 Message Date
Nathan Panchout 37e78b542f wip 2024-12-12 14:48:31 +01:00
Nathan Panchout e9a66dd1b3 🐛(frontend) fix document editor height and update translations
- Adjusted the document editor height in the DocEditor component
- Updated translations for various terms to ensure consistency
cross the application.
- Improved layout and spacing in the DocsGridItem
component for a cleaner presentation.
2024-12-06 13:12:10 +01:00
Nathan Panchout 572a26d1bb 🔥(frontend) remove unused PanelEditor and TableContent components
- Deleted the PanelEditor and TableContent components as
they are no longer needed in the application.
2024-12-05 17:41:55 +01:00
Nathan Panchout 1260310199 💄(frontend) update document summary UI
- Enhanced the document summary UI for better visibility
and interaction.
- Refactored the DocHeader and DocEditor components to
 improve layout and responsiveness.
- Updated tests for the DocTableContent to reflect changes
in heading interactions and visibility checks.
2024-12-05 17:41:55 +01:00
Nathan Panchout 9e900f5b44 🐛(frontend) fix button width nano icon only
- fix for icon-only buttons to have auto width.
2024-12-05 17:20:48 +01:00
Nathan Panchout 5363a1328e (frontend) implement document filtering
- Introduced a new enum for default document filters
to improve code clarity.
- Updated the API call to support filtering documents
based on the creator.
- Enhanced the DocsGrid component to accept a target
filter, allowing dynamic content rendering based on user selection.
- Modified the main layout to include a left panel for improved
navigation and user experience.
- Added a new test suite for document filters, verifying the visibility
and selection states of 'All docs', 'My docs', and 'Shared with me'.
2024-12-05 17:01:20 +01:00
Samuel Paccoud - DINUM 33986a6bef ⬆️(backend) bump openai library version as it breaks tests
This looks like an instability in the openai library's definition
of dependencies.
2024-12-04 10:08:14 +01:00
Nathan Panchout b8c0c40693 🔧(frontend) remove deprecated routes and update service worker
- Removed the versioning route from the default configuration to
streamline the documentation structure.
- Updated the service worker to eliminate references to the deprecated
 versioning fallback, enhancing the offline experience for users.
2024-12-04 10:08:14 +01:00
Nathan Panchout bdcb337d0d (frontend) enhance document versioning and loading experience
- Updated tests for document member list and versioning to utilize
'Load more' button instead of mouse wheel scrolling.
- Improved UI for document versioning, including visibility
checks and modal interactions.
- Refactored InfiniteScroll component to include a button for
loading more items, enhancing user experience.
- Adjusted DocEditor and DocHeader components to handle
version IDs more effectively.
- Removed deprecated versioning pages to streamline the codebase.
2024-12-04 10:08:14 +01:00
Nathan Panchout fb22e24a1a 💄(frontend) update doc export modal
In the new interface, the export modal changes a little.

- We put the buttons on the right
- We remove the alert
- We transform the radio into select
2024-12-03 09:38:44 +01:00
Nathan Panchout c21ef3e85c (frontend) adapt all tests related to the new header
Since we no longer use an editable div but an input, we must
modify the tests accordingly
2024-12-03 09:38:43 +01:00
Nathan Panchout 194cf2b90f (frontend) update doc header ui
Modification of the header style to be consistent with the new UI :
- We replace the option menu with the DropdownMenu component
- We add a dowload button
- We put an input in place of an editable div.
2024-12-03 09:38:21 +01:00
Nathan Panchout 8524f59deb 🔥(frontend) remove files from bad rebase
We had already deleted this file but it must have reappeared
with a bad rebase
2024-12-03 09:37:26 +01:00
Nathan Panchout d92a8c6260 💄(frontend) add dropdown option for DocGridItem
Implement dropdown menu with functionality to delete a document
from the list
2024-12-03 09:37:25 +01:00
Nathan Panchout 94a65da8ca (frontend) update tests to align with the new interface changes
- Adjust selectors and assertions to reflect updates in the UI layout and
design.
- Ensure all modified tests maintain compatibility with the updated structure.
- Fix any broken test cases caused by the redesign.
2024-12-03 09:35:50 +01:00
Nathan Panchout c782b05594 💄(frontend) update DocsGrid component
Implement the new version of  the DocsGrid  component
2024-12-03 09:35:49 +01:00
Nathan Panchout 25e76a76b7 🔧(frontend) update cunningham configuration
- update primary colors,and spacing.
- update tertiary button
2024-12-03 09:35:24 +01:00
Nathan Panchout 2ab911426c (frontend) add react-intersection-observer package
- Install `react-intersection-observer` to manage element visibility detection.
- Enables features like lazy loading, animations on scroll, and triggering
events when elements appear in the viewport.
2024-12-03 09:35:24 +01:00
Nathan Panchout 1049033917 💄(frontend) updating the header and leftpanel for responsive
Previously we added a left panel. We now need to adapt the layout
so that it becomesresponsive.

We therefore add a burger menu on the left on mobile which,
when clicked, deploys the left-panel over all the content.
2024-12-03 09:35:24 +01:00
Nathan Panchout de7a601d8b 🔥(frontend) remove unused components due to new interface
Deleted two components that were no longer needed following the
implementation of the new interface. This cleanup helps streamline
he codebase and avoid unnecessary maintenance.
2024-12-03 09:35:24 +01:00
Nathan Panchout b361700638 💄(frontend) updating the header and leftpanel for responsive
Previously we added a left panel. We now need to adapt the layout
so that it becomesresponsive.

We therefore add a burger menu on the left on mobile which,
when clicked, deploys the left-panel over all the content.
2024-12-03 09:35:24 +01:00
Anthony LC 5295abade8 build image with main-new-ui branch 2024-12-03 09:35:24 +01:00
Nathan Panchout 4fa7a367a9 (frontend) update tests
Some minor changes have been integrated into the list of documents.
The tests must therefore be adapted accordingly.
2024-12-03 09:35:24 +01:00
Nathan Panchout b00dedf74e 💄(frontend) add left panel
In the new interface there is a new left panel. We implement it and add it
to the MainLayout
2024-12-03 09:35:24 +01:00
Nathan Panchout 2bed0cab93 💄(frontend) add cunningham tokens
In order to use the spaces and grays of the DSFR,
we update the cunningham.ts file
2024-12-03 09:35:24 +01:00
Nathan Panchout b8d8f1830e (frontend) implement new UI
This branch is a transition branch to gradually merge the new UI.
2024-12-03 09:35:19 +01:00
Anthony LC eec8b4d2c3 ♻️(frontend) adapt frontend with new access types
We don't get the accesses anymore from the backeend,
instead we get the number of accesses.
We remove the list of owners in the doc header because
we don't have easily this informations anymore and
we will have to do a bigger refacto.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM 1af7b797bc (backend) test interference btw documents permissions and filtering
We want to make sure that applying filters on the document view list
does not interfere with permissions.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM b5c159bf63 (backend) allow filtering on document titles
This is the minimal and fast search feature, while we are working on
a full text search based on opensearch. For the moment we only search
on the title of the document.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM bfbdfb2b5c (backend) allow filtering documents by link reach
We want to be able to limit document list views to only public documents,
or only restricted or authenticated documents.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM 08bb64ddc1 (backend) allow filtering by documents marked as favorite
We recently allowed authenticated users to mark a document as favorite.
We were lacking the possibility for users to see only the documents
they marked as favorite.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM 23f90156bf (backend) add creator field on document and allow filtering on it
We want to be able to limit the documents displayed on a logged-in user's
list view by the documents they created or by the documents that other
users created.

This is different from having the "owner" role on a document because this
can be acquired and even lost. What we want here is to be able to
identify documents by the user who created them so we add a new field.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM 1899cff572 🐛(backend) fix flaky test by clarifying user ordering
On the user search API by similarity, we had a flaky test because
2 users had the same similarity score. Adding a secondary ordering
field makes ordering deterministic between users who share the same
similarity score.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM 774c2ce248 (backend) annotate number of accesses on documents
The new UI will display the number of accesses on each document.

/!\ Once team accesses will be used, this will not represent the number
    of people with access anymore and will have to be improved by
    computing the number of people in each team.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM 89d9075850 (backend) allow users to mark/unmark documents as favorite
A user can now mark/unmark documents as favorite.
This is done via a new action of the document API endpoint:
/api/v1.0/documents/{document_id}/favorite
POST to mark as favorite / DELETE to unmark
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM 2c915d53f4 ️(backend) optimize number of queries on document list view
I realized most of the database queries made when getting a document
list view were to include nested accesses. This detailed information
about accesses in only necessary for the document detail view.

I introduced a specific serializer for the document list view with
less fields. For a list of 20 documents with 5 accesses, we go down
from 3x5x20= 300 queries to just 3 queries.
2024-11-28 16:02:27 +01:00
Anthony LC 797d9442ac 🔖(patch) release 1.8.2
Changed:

- ♻️(SW) change strategy html caching
2024-11-28 15:31:03 +01:00
Anthony LC 573d054748 ♻️(SW) change strategy html caching
We will use the network first strategy for the html
files. This will allow us to always have the
latest version of the html files.
2024-11-28 09:30:06 +01:00
Anthony LC 2035a256f5 🔖(patch) release 1.8.1
Fixed:
🐛(frontend) link not clickable and flickering firefox
2024-11-27 17:17:35 +01:00
Anthony LC c94f26c8b9 ⬇️(SW) workbox-webpack-plugin to 7.1.0
A recent update to the workbox-webpack-plugin
package seems to introduce strange behavior.
Better to downgrade in waiting that it is more stable.
2024-11-27 16:50:11 +01:00
Anthony LC fc2f14b3f4 🐛(frontend) link not clickable and flickering firefox
The link in the read mode was not clickable anymore,
it was due to a attempt to not display the cursor
of anonymous users.
We changes the way to do it by rendering our own cursor,
when a user is anonymous we don't render the cursor.
By rendering our own cursor we fixed another problem,
the cursor was flickering when the user was typing
at the end of the line on the firefox browser.
2024-11-27 16:50:11 +01:00
Anthony LC 6dd1697915 🐛(frontend) use hook useTranslation
Sentry highlitghted a few errors about the
function "t" not being defined. Better to get
it from the hook useTranslation.
2024-11-27 16:50:11 +01:00
Anthony LC 79e899c301 ♻️(frontend) add hooks useUploadFile
Move upload file logic to hooks useUploadFile.
It will be more readable and easy to reuse.
2024-11-27 16:50:11 +01:00
Anthony LC 2194301716 🔖(minor) release 1.8.0
Added:
- 🌐(backend) add german translation
- 🌐(frontend) Add German translation
- (frontend) Add a broadcast store
- (backend) whitelist pod's IP address
- (backend) config endpoint
- (frontend) config endpoint
- (frontend) add sentry
- (frontend) add crisp chatbot

Changed:
- 🚸(backend) improve users similarity search and
sort results
- ♻️(frontend) simplify stores
- (frontend) update $css Box props type to add
styled components RuleSet
- (CI) trivy continue on error

Fixed:
- 🔧(backend) fix logging for docker and make it
configurable by envar
- 🦺(backend) add comma to sub regex
- 🐛(editor) collaborative user tag hidden when
read only
- 🐛(frontend) users have view access when revoked
- 🐛(frontend) fix placeholder editable when double clicks
2024-11-27 09:47:42 +01:00
Anthony LC 0348894ab8 🐛(frontend) fix rerender title with broadcasting
The title was not rerendering on other clients
when the title was updated by one client.
This commit fixes the issue.
We set a min width for the title as well, it
will fix the issue with strange behavior when
people were double clicking.
2024-11-26 18:15:18 +01:00
Anthony LC 9b17d8bea1 🚨(frontend) remove Crisp warning
Remove the Crisp warning that was being displayed
on the console in our environments.
2024-11-26 18:15:18 +01:00
Anthony LC 69d6b6f934 (CI) trivy continue on error
Trivy is extremly flaky,
we need to continue on error to avoid
blocking the pipeline.
We still keep the check, to see if there are any
vulnerabilities, but we don't want to block
the pipeline.
2024-11-26 11:53:11 +01:00
Anthony LC 6c106374fa (frontend) add crisp chatbot
Integrate Crisp chatbot for immediate user support access.

This enables real-time interaction, enhancing user experience
by providing quick assistance.
2024-11-25 17:06:02 +01:00
Anthony LC af039d045d 🔧(backend) add CRISP_WEBSITE_ID setting
Add setting CRISP_WEBSITE_ID. This setting is
used to configure the Crisp chat widget.
It will be available to the conf endpoint, to
be used by the frontend.
2024-11-25 17:06:02 +01:00
Anthony LC 4c9caf09ba ⬆️(CI) upgrade upload-artifact@v3 to v4
Upload artifact v3 is deprecated soon, so we need
to upgrade it to v4.
2024-11-25 13:16:06 +01:00
Anthony LC 3fd02adbec 💄(frontend) remove Blocknote fix
A recent upgrade of Blocknote to 0.19.2 fixed
a issue that we were solving. We removed our
fix as it is no longer needed.
2024-11-25 13:16:06 +01:00
Anthony LC 90dac3cd15 🏷️(frontend) update typescript types
We updated typescript to 5.7.2.
Some types were deprecated and we had to update them.
2024-11-25 13:16:06 +01:00
Anthony LC d0307ee6d9 ⬆️(dependencies) update js dependencies 2024-11-25 13:16:06 +01:00
Anthony LC 09d02b7ced 🚚(frontend) move conf api urls to api folder
Previous refacto let only the api urls in the conf
file, so better to move it to the api folder.
2024-11-25 09:46:14 +01:00
Anthony LC 56a26d9663 🧪(CI) pass trivy security
The trivy security blocked the deploiement.
It says that we have a vulnerability because
we are using the cross-spawn@7.0.3 package, but
we are not, we are using the cross-spawn@7.0.6 package.
We will bypass this security check in the docker-hub.yml
file in waiting for another solution.
2024-11-25 09:46:14 +01:00
Anthony LC 42f809f6d4 ♻️(frontend) get collaboration server url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the collaboration
server url, we refacto to remove the frontend env
occurences and to adapt with the new way to get the
collaboration server url.
2024-11-25 09:46:14 +01:00
Anthony LC 7d64c82987 ♻️(frontend) get media url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the media url,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the media url.
2024-11-25 09:46:14 +01:00
Anthony LC 6252227bb6 ♻️(frontend) get theme from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the theme,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the theme.
2024-11-25 09:46:14 +01:00
Anthony LC e9ac393a8f (frontend) add sentry
In order to monitor the frontend, we are adding
sentry.
2024-11-25 09:46:14 +01:00
Anthony LC 5b1745f991 (frontend) add config provider
Add a ConfigProvider to the frontend to provide
configuration to the app.
The configuration is loaded from the config
endpoint, we will use react-query cache capabilities
to store the configuration.
2024-11-25 09:46:14 +01:00
Anthony LC 0e55bf5c43 🔒️(helm) allow server host and whitelist pod IP for health checks
In a Kubernetes environment, we need to whitelist the pod's IP address
to allow health checks to pass. This ensures that Kubernetes liveness and
readiness probes can access the application to verify its health.
2024-11-22 13:01:55 +01:00
Samuel Paccoud - DINUM 9f66f73501 🔧(backend) fix logging for docker and make it configurable by envar
Logs were not made to the console so it was hard to debug in k8s.
We propose a ready made logging configuration that sends everything
to the console and allow adjusting log levels with environment
variables.
2024-11-20 11:51:20 +01:00
Samuel Paccoud - DINUM c3da28b07f ️(helm) bring back helm chart
This is a revert of 1da5a removing actual deployments and keeping
only the dev environment in Tilt.

The clean-up was a bit heavy handed. We should keep the Helm
chart to the development repository and move away only the
deployment configuration.
2024-11-20 11:51:20 +01:00
Anthony LC b035b96dec ⬆️(CI) bump python version in backend test
We were testing the backend with python 3.10.0, but
actually the backend was running with python 3.12.6.
We bump the python version in the backend test to match
the running version of the backend.
2024-11-20 09:51:08 +01:00
Anthony LC 9623ac4141 🩹(backend) get current release from pyproject.toml
"get_release" was returning NA, we fixed it by
getting the version from pyproject.toml, to do so we
use tomllib
Since tomllib is a native library from Python 3.11,
we bump the required version to 3.11 on the pyproject.toml.
2024-11-20 09:51:08 +01:00
Anthony LC c8edbd285b 🔧(backend) add FRONTEND_THEME setting
The frontend need to know the theme to be used,
so we need to add a new setting to the backend,
in order to expose this value to the frontend.
2024-11-20 09:51:08 +01:00
Anthony LC 016597d5a2 🔧(backend) add COLLABORATION_SERVER_URL setting
The frontend need to know the collab server url,
so we need to add a new setting to the backend,
in order to expose this value to the frontend.
If the setting is not defined, the frontend current
domain will be used as the base url.
In production this setting do not need to be defined
since we have nginx capturing the ws requests,
but in development we need to define it to target
the collaboration server.
2024-11-20 09:51:08 +01:00
Anthony LC 52dea8fa2f 🔧(backend) add MEDIA_BASE_URL setting
The frontend need to know the base url for the
media files, so we need to add a new setting
to the backend, in order to expose this value
to the frontend.
If the setting is not defined, the frontend current
domain will be used as the base url.
In production this setting do not need to be defined
since we have nginx capturing the media requests,
but in development we need to define it to target
the nginx server.
2024-11-20 09:51:08 +01:00
Anthony LC 0a37a8ea6d (backend) add public endpoint /api/v1.0/config/
Add public endpoint /api/v1.0/config/ to
share some public configuration values.
2024-11-20 09:51:08 +01:00
Anthony LC c1404ef904 ⬆️(dependencies) bump cross-spawn from 7.0.3 to 7.0.6
Bumps cross-spawn from 7.0.3 to 7.0.6.

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-20 09:24:34 +01:00
renovate[bot] 2c0fce61df ⬆️(dependencies) update js dependencies 2024-11-18 17:25:16 +01:00
Nathan Panchout bbe9b6b6cf (frontend) add styledCss props to Box component
In order to facilitate DX and not to use a string in the code for the css.
We add the $styledCss props to the Box component.
This object comes from Styled component
2024-11-15 10:33:56 +01:00
Anthony LC 23231563c9 💄(frontend) text color on Blocknote code block options
The options for the code block in the Blocknote
editor was not visible. We changed the text color
to make it visible.
A fix will be made to the code block options in the
next blocknote release.
2024-11-14 17:36:11 +01:00
Anthony LC d75c8668c5 🚨(frontend) blocknote cast to Dictionnary
The last Blocknote upgrade (0.19.0) gives us
a warning with the dictionnary typing.
We cast it to the correct type to remove the warning.
2024-11-14 17:36:11 +01:00
Anthony LC f266232b5a ♻️(frontend) use next/router instead of next/navigation
The last upgrade of next.js gives a warning
when we were using next/navigation with the
pages router.
This commit fixes this issue.
2024-11-14 17:36:11 +01:00
Anthony LC a8362e8e88 ⬆️(dependencies) update js dependencies 2024-11-14 17:36:11 +01:00
Anthony LC e4dfae1905 ♻️(frontend) simplify useDocStore
We moved the editor store to its own store in the previous
commit. This change allow us to simplify useDocStore.
2024-11-13 15:25:29 +01:00
Anthony LC a09e740648 ♻️(frontend) move editor store to useEditorStore
Previous changes migrated the editor store to
doc-management, we move it back doc-editor and
simplify it.
2024-11-13 15:25:29 +01:00
Anthony LC 5ee6a43f08 (frontend) add useBroadcastStore
Add the useBroadcastStore.
It will give us the ability to easily
broadcast actions to all connected clients.

In this case, we requery the doc to everyone
when a change relative to the doc rights is made.
2024-11-09 10:21:24 +01:00
Anthony LC 8bd83cbfcd 🚚(frontend) move useDocStore to doc-management
We want to make more accessible the doc store
to every feature, so we move it to the
doc-management folder.
2024-11-09 10:21:24 +01:00
201 changed files with 11689 additions and 6504 deletions
+7
View File
@@ -6,8 +6,12 @@ on:
push:
branches:
- 'main'
- 'main-new-ui'
tags:
- 'v*'
pull_request:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
@@ -52,6 +56,7 @@ jobs:
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -102,6 +107,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -153,6 +159,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
+22
View File
@@ -0,0 +1,22 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"
+2 -2
View File
@@ -99,7 +99,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-chromium-report
@@ -133,7 +133,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-other-report
+4 -2
View File
@@ -107,7 +107,9 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -199,7 +201,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- name: Install development dependencies
run: pip install --user .[dev]
+55 -4
View File
@@ -10,18 +10,66 @@ and this project adheres to
## [Unreleased]
## Added
- ✨(frontend) WIP: New ui
- 💄(frontend) Add left panel #420
- 💄(frontend) updating the header and leftpanel for responsive #421
- 💄(frontend) update DocsGrid component #431
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
- 💄(frontend) update doc versioning ui #463
- 💄(frontend) update doc summary ui #473
- 🐛(frontend) fix doc grid button #478
- 💄(frontend) update doc home left panel #475
- 🐛(frontend) update doc editor height #481
## [1.8.2] - 2024-11-28
- 🌐(frontend) Add German translation #255
## Changed
- 🚸(backend) improve users similarity search and sort results #391
- 🌐(backend) add german translation #259
- ♻️(SW) change strategy html caching #460
## [1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
- ✨(backend) annotate number of accesses on documents in list view #411
- ✨(backend) allow users to mark/unmark documents as favorite #411
- 🌐(backend) add German translation #259
- 🌐(frontend) add German translation #255
- ✨(frontend) add a broadcast store #387
- ✨(backend) config endpoint #425
- 💄(frontend) update DocsGrid component #431
- ✨(backend) whitelist pod's IP address #443
- ✨(backend) config endpoint #425
- ✨(frontend) config endpoint #424
- ✨(frontend) add sentry #424
- ✨(frontend) add crisp chatbot #450
## Changed
- ⚡️(backend) optimize number of queries on document list view #411
- 🚸(backend) improve users similarity search and sort results #391
- ♻️(frontend) simplify stores #402
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
- ✅(CI) trivy continue on error #453
## Fixed
- 🔧(backend) fix logging for docker and make it configurable by envar #427
- 🦺(backend) add comma to sub regex #408
- 🐛(editor) collaborative user tag hidden when read only #385
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
@@ -253,7 +301,10 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.8.2...main
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
+13
View File
@@ -4,6 +4,12 @@ DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=admin
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
@@ -21,6 +27,7 @@ STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStora
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=impress
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
@@ -44,3 +51,9 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_SERVER_URL=ws://localhost:4444
# Frontend
FRONTEND_THEME=dsfr
+69
View File
@@ -0,0 +1,69 @@
"""API filters for Impress' core application."""
from django.utils.translation import gettext_lazy as _
import django_filters
from core import models
class DocumentFilter(django_filters.FilterSet):
"""
Custom filter for filtering documents.
"""
is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me")
)
is_favorite = django_filters.BooleanFilter(
method="filter_is_favorite", label=_("Favorite")
)
title = django_filters.CharFilter(
field_name="title", lookup_expr="icontains", label=_("Title")
)
class Meta:
model = models.Document
fields = ["is_creator_me", "is_favorite", "link_reach", "title"]
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
"""
Filter documents based on the `creator` being the current user.
Example:
- /api/v1.0/documents/?is_creator_me=true
→ Filters documents created by the logged-in user
- /api/v1.0/documents/?is_creator_me=false
→ Filters documents created by other users
"""
user = self.request.user
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(creator=user)
return queryset.exclude(creator=user)
# pylint: disable=unused-argument
def filter_is_favorite(self, queryset, name, value):
"""
Filter documents based on whether they are marked as favorite by the current user.
Example:
- /api/v1.0/documents/?is_favorite=true
→ Filters documents marked as favorite by the logged-in user
- /api/v1.0/documents/?is_favorite=false
→ Filters documents not marked as favorite by the logged-in user
"""
user = self.request.user
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(favorited_by_users__user=user)
return queryset.exclude(favorited_by_users__user=user)
+46 -9
View File
@@ -137,32 +137,69 @@ class BaseResourceSerializer(serializers.ModelSerializer):
return {}
class DocumentSerializer(BaseResourceSerializer):
"""Serialize documents."""
class ListDocumentSerializer(BaseResourceSerializer):
"""Serialize documents with limited fields for display in lists."""
content = serializers.CharField(required=False)
accesses = DocumentAccessSerializer(many=True, read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"content",
"title",
"accesses",
"abilities",
"content",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"created_at",
"nb_accesses",
"title",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"updated_at",
]
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
content = serializers.CharField(required=False)
class Meta:
model = models.Document
fields = [
"id",
"abilities",
"content",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"title",
"updated_at",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"creator",
"is_avorite",
"link_role",
"link_reach",
"nb_accesses",
"updated_at",
]
+179 -48
View File
@@ -1,4 +1,5 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import re
import uuid
@@ -10,32 +11,41 @@ from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db.models import (
Count,
Exists,
Min,
OuterRef,
Q,
Subquery,
Value,
)
from django.http import Http404
from botocore.exceptions import ClientError
from django_filters import rest_framework as filters
from rest_framework import (
decorators,
exceptions,
filters,
metadata,
mixins,
pagination,
status,
views,
viewsets,
)
from rest_framework import (
filters as drf_filters,
)
from rest_framework import (
response as drf_response,
)
from rest_framework.permissions import AllowAny
from core import enums, models
from core.services.ai_services import AIService
from . import permissions, serializers, utils
from .filters import DocumentFilter
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
@@ -169,7 +179,7 @@ class UserViewSet(
threshold = 0.6 if "@" in query else 0.1
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity"
"-similarity", "email"
)
return queryset
@@ -191,42 +201,6 @@ class UserViewSet(
)
class ResourceViewsetMixin:
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "updated_at", "title"]
ordering = ["-created_at"]
def get_queryset(self):
"""Custom queryset to get user related resources."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
self.access_model_class.objects.filter(
Q(user=user) | Q(team__in=user.teams),
**{self.resource_field_name: OuterRef("pk")},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: obj},
)
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
@@ -336,28 +310,83 @@ class DocumentMetadata(metadata.SimpleMetadata):
class DocumentViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Document ViewSet"""
"""
Document ViewSet for managing documents.
Provides endpoints for creating, updating, and deleting documents,
along with filtering options.
Filtering:
- `is_creator_me=true`: Returns documents created by the current user.
- `is_creator_me=false`: Returns documents created by other users.
- `is_favorite=true`: Returns documents marked as favorite by the current user
- `is_favorite=false`: Returns documents not marked as favorite by the current user
- `title=hello`: Returns documents which title contains the "hello" string
Example Usage:
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
"""
filter_backends = [filters.DjangoFilterBackend, drf_filters.OrderingFilter]
filterset_class = DocumentFilter
metadata_class = DocumentMetadata
ordering = ["-updated_at"]
ordering_fields = ["created_at", "is_favorite", "updated_at", "title"]
permission_classes = [
permissions.AccessPermission,
]
serializer_class = serializers.DocumentSerializer
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
metadata_class = DocumentMetadata
serializer_class = serializers.DocumentSerializer
def get_serializer_class(self):
"""
Use ListDocumentSerializer for list actions, otherwise use DocumentSerializer.
"""
if self.action == "list":
return serializers.ListDocumentSerializer
return self.serializer_class
def get_queryset(self):
"""Optimize queryset to include favorite status for the current user."""
queryset = super().get_queryset()
user = self.request.user
# Annotate the number of accesses associated with each document
queryset = queryset.annotate(nb_accesses=Count("accesses", distinct=True))
if not user.is_authenticated:
# If the user is not authenticated, annotate `is_favorite` as False
return queryset.annotate(is_favorite=Value(False))
# Annotate the queryset to indicate if the document is favorited by the current user
favorite_exists = models.DocumentFavorite.objects.filter(
document_id=OuterRef("pk"), user=user
)
queryset = queryset.annotate(is_favorite=Exists(favorite_exists))
# Annotate the queryset with the logged-in user roles
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document_id=OuterRef("pk"),
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
@@ -401,6 +430,15 @@ class DocumentViewSet(
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
obj = serializer.save(creator=self.request.user)
models.DocumentAccess.objects.create(
document=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -494,6 +532,43 @@ class DocumentViewSet(
serializer.save()
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
def favorite(self, request, *args, **kwargs):
"""
Mark or unmark the document as a favorite for the logged-in user based on the HTTP method.
"""
# Check permissions first
document = self.get_object()
user = request.user
if request.method == "POST":
# Try to mark as favorite
try:
models.DocumentFavorite.objects.create(document=document, user=user)
except ValidationError:
return drf_response.Response(
{"detail": "Document already marked as favorite"},
status=status.HTTP_200_OK,
)
return drf_response.Response(
{"detail": "Document marked as favorite"},
status=status.HTTP_201_CREATED,
)
# Handle DELETE method to unmark as favorite
deleted, _ = models.DocumentFavorite.objects.filter(
document=document, user=user
).delete()
if deleted:
return drf_response.Response(
{"detail": "Document unmarked as favorite"},
status=status.HTTP_204_NO_CONTENT,
)
return drf_response.Response(
{"detail": "Document was already not marked as favorite"},
status=status.HTTP_200_OK,
)
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
@@ -677,7 +752,6 @@ class DocumentAccessViewSet(
class TemplateViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
@@ -686,15 +760,35 @@ class TemplateViewSet(
):
"""Template ViewSet"""
filter_backends = [drf_filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
]
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
serializer_class = serializers.TemplateSerializer
access_model_class = models.TemplateAccess
resource_field_name = "template"
queryset = models.Template.objects.all()
def get_queryset(self):
"""Custom queryset to get user related templates."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
models.TemplateAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
template_id=OuterRef("pk"),
)
.values("template")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
@@ -716,6 +810,15 @@ class TemplateViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
models.TemplateAccess.objects.create(
template=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@decorators.action(
detail=True,
methods=["post"],
@@ -886,3 +989,31 @@ class InvitationViewset(
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
)
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = [
"COLLABORATION_SERVER_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
]
dict_settings = {}
for setting in array_settings:
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
return drf_response.Response(dict_settings)
+8
View File
@@ -56,6 +56,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
title = factory.Sequence(lambda n: f"document{n}")
content = factory.Sequence(lambda n: f"content{n}")
creator = factory.SubFactory(UserFactory)
link_reach = factory.fuzzy.FuzzyChoice(
[a[0] for a in models.LinkReachChoices.choices]
)
@@ -80,6 +81,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
for item in extracted:
models.LinkTrace.objects.create(document=self, user=item)
@factory.post_generation
def favorited_by(self, create, extracted, **kwargs):
"""Mark document as favorited by a list of users."""
if create and extracted:
for item in extracted:
models.DocumentFavorite.objects.create(document=self, user=item)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""
@@ -0,0 +1,37 @@
# Generated by Django 5.1.2 on 2024-11-08 07:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_alter_document_link_reach'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='DocumentFavorite',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document favorite',
'verbose_name_plural': 'Document favorites',
'db_table': 'impress_document_favorite',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
},
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.1.2 on 2024-11-09 11:36
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_add_document_favorite'),
]
operations = [
migrations.AddField(
model_name='document',
name='creator',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.AlterField(
model_name='user',
name='sub',
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 5.1.2 on 2024-11-09 11:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
def set_creator_from_document_access(apps, schema_editor):
"""
Populate the `creator` field for existing Document records.
This function assigns the `creator` field using the existing
DocumentAccess entries. We can be sure that all documents have at
least one user with "owner" role. If the document has several roles,
it should take the entry with the oldest date of creation.
The update is performed using efficient bulk queries with Django's
Subquery and OuterRef to minimize database hits and ensure performance.
Note: After running this migration, we quickly modify the schema to make
the `creator` field required.
"""
Document = apps.get_model("core", "Document")
DocumentAccess = apps.get_model("core", "DocumentAccess")
# Update `creator` using the "owner" role
owner_subquery = DocumentAccess.objects.filter(
document=OuterRef('pk'),
user__isnull=False,
role='owner',
).order_by('created_at').values('user_id')[:1]
Document.objects.filter(
creator__isnull=True
).update(creator=Subquery(owner_subquery))
class Migration(migrations.Migration):
dependencies = [
('core', '0010_add_field_creator_to_document'),
]
operations = [
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='document',
name='creator',
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
]
+35
View File
@@ -341,6 +341,9 @@ class Document(BaseModel):
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
)
creator = models.ForeignKey(
User, on_delete=models.RESTRICT, related_name="documents_created"
)
_content = None
@@ -518,6 +521,7 @@ class Document(BaseModel):
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"destroy": RoleChoices.OWNER in roles,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": is_owner_or_admin or is_editor,
@@ -600,6 +604,37 @@ class LinkTrace(BaseModel):
return f"{self.user!s} trace on document {self.document!s}"
class DocumentFavorite(BaseModel):
"""Relation model to store a user's favorite documents."""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="favorited_by_users",
)
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="favorite_documents"
)
class Meta:
db_table = "impress_document_favorite"
verbose_name = _("Document favorite")
verbose_name_plural = _("Document favorites")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_document_favorite_user",
violation_error_message=_(
"This document is already targeted by a favorite relation instance "
"for the same user."
),
),
]
def __str__(self):
return f"{self.user!s} favorite on document {self.document!s}"
class DocumentAccess(BaseAccess):
"""Relation model to give access to a document for a user or a team with a role."""
@@ -0,0 +1,308 @@
"""Test favorite document API endpoint for users in impress's core app."""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach",
[
"restricted",
"authenticated",
"public",
],
)
@pytest.mark.parametrize("method", ["post", "delete"])
def test_api_document_favorite_anonymous_user(method, reach):
"""Anonymous users should not be able to mark/unmark documents as favorites."""
document = factories.DocumentFactory(link_reach=reach)
response = getattr(APIClient(), method)(
f"/api/v1.0/documents/{document.id!s}/favorite/"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
# Verify in database
assert models.DocumentFavorite.objects.exists() is False
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
"""Authenticated users should be able to mark a document as favorite using POST."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Mark as favorite
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 201
assert response.json() == {"detail": "Document marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
def test_api_document_favorite_authenticated_post_forbidden():
"""Authenticated users should be able to mark a document as favorite using POST."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
client = APIClient()
client.force_login(user)
# Try marking as favorite
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_already_favorited_allowed(
reach, has_role
):
"""POST should not create duplicate favorites if already marked."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try to mark as favorite again
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 200
assert response.json() == {"detail": "Document already marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
def test_api_document_favorite_authenticated_post_already_favorited_forbidden():
"""POST should not create duplicate favorites if already marked."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
client = APIClient()
client.force_login(user)
# Try to mark as favorite again
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_delete_allowed(reach, has_role):
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Unmark as favorite
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 204
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is False
def test_api_document_favorite_authenticated_delete_forbidden():
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
client = APIClient()
client.force_login(user)
# Unmark as favorite
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is True
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_delete_not_favorited_allowed(
reach, has_role
):
"""DELETE should be idempotent if the document is not marked as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try to unmark as favorite when no favorite entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already not marked as favorite"}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is False
def test_api_document_favorite_authenticated_delete_not_favorited_forbidden():
"""DELETE should be idempotent if the document is not marked as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
client = APIClient()
client.force_login(user)
# Try to unmark as favorite when no favorite entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_unmark_then_mark_again_allowed(
reach, has_role
):
"""A user should be able to mark, unmark, and mark a document again as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
url = f"/api/v1.0/documents/{document.id!s}/favorite/"
# Mark as favorite
response = client.post(url)
assert response.status_code == 201
# Unmark as favorite
response = client.delete(url)
assert response.status_code == 204
# Mark as favorite again
response = client.post(url)
assert response.status_code == 201
assert response.json() == {"detail": "Document marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
@@ -3,7 +3,9 @@ Tests for Documents API endpoint in impress's core app: list
"""
import operator
import random
from unittest import mock
from urllib.parse import urlencode
import pytest
from faker import Faker
@@ -32,7 +34,47 @@ def test_api_documents_list_anonymous(reach, role):
assert len(results) == 0
def test_api_documents_list_authenticated_direct():
def test_api_documents_list_format():
"""Validate the format of documents as returned by the list view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
users=[user, *factories.UserFactory.create_batch(2)],
favorited_by=[user, *other_users],
link_traces=other_users,
)
response = client.get("/api/v1.0/documents/")
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),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_documents_list_authenticated_direct(django_assert_num_queries):
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of or documents that have a link reach other
@@ -55,9 +97,8 @@ def test_api_documents_list_authenticated_direct():
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -66,7 +107,9 @@ def test_api_documents_list_authenticated_direct():
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(mock_user_teams):
def test_api_documents_list_authenticated_via_team(
django_assert_num_queries, mock_user_teams
):
"""
Authenticated users should be able to list documents they are a
owner/administrator/member of via a team.
@@ -89,7 +132,8 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
response = client.get("/api/v1.0/documents/")
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -98,7 +142,9 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
assert expected_ids == results_id
def test_api_documents_list_authenticated_link_reach_restricted():
def test_api_documents_list_authenticated_link_reach_restricted(
django_assert_num_queries,
):
"""
An authenticated user who has link traces to a document that is restricted should not
see it on the list view
@@ -115,9 +161,10 @@ def test_api_documents_list_authenticated_link_reach_restricted():
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -127,7 +174,9 @@ def test_api_documents_list_authenticated_link_reach_restricted():
assert results[0]["id"] == str(other_document.id)
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
django_assert_num_queries,
):
"""
An authenticated user who has link traces to a document with public or authenticated
link reach should see it on the list view.
@@ -144,9 +193,10 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
]
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -224,6 +274,143 @@ def test_api_documents_list_authenticated_distinct():
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries):
"""
Ensure that marking documents as favorite does not generate additional queries
when fetching the document list.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
special_documents = factories.DocumentFactory.create_batch(3, users=[user])
factories.DocumentFactory.create_batch(2, users=[user])
url = "/api/v1.0/documents/"
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
assert all(result["is_favorite"] is False for result in results)
# Mark documents as favorite and check results again
for document in special_documents:
models.DocumentFavorite.objects.create(document=document, user=user)
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check if the "is_favorite" annotation is correctly set for the favorited documents
favorited_ids = {str(doc.id) for doc in special_documents}
for result in results:
if result["id"] in favorited_ids:
assert result["is_favorite"] is True
else:
assert result["is_favorite"] is False
def test_api_documents_list_filter_and_access_rights():
"""Filtering on querystring parameters should respect access rights."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
def random_favorited_by():
return random.choice([[], [user], [other_user]])
# Documents that should be listed to this user
listed_documents = [
factories.DocumentFactory(
link_reach="public",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="authenticated",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="restricted",
users=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
]
listed_ids = [str(doc.id) for doc in listed_documents]
word_list = [word for doc in listed_documents for word in doc.title.split(" ")]
# Documents that should not be listed to this user
factories.DocumentFactory(
link_reach="public",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="authenticated",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
filters = {
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
"title": random.choice([None, *word_list]),
"favorite": random.choice([None, True, False]),
"creator": random.choice([None, user, other_user]),
"ordering": random.choice(
[
None,
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]
),
}
query_params = {key: value for key, value in filters.items() if value is not None}
querystring = urlencode(query_params)
response = client.get(f"/api/v1.0/documents/?{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all documents in results respect expected access rights
for result in results:
assert result["id"] in listed_ids
# Filters: ordering
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
@@ -254,10 +441,14 @@ def test_api_documents_list_ordering_by_fields():
for parameter in [
"created_at",
"-created_at",
"updated_at",
"-updated_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
@@ -272,3 +463,212 @@ def test_api_documents_list_ordering_by_fields():
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
# Filters: is_creator_me
def test_api_documents_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter documents they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == str(user.id)
def test_api_documents_list_filter_is_creator_me_false():
"""
Authenticated users should be able to filter documents created by others.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by other users
for result in results:
assert result["creator"] != str(user.id)
def test_api_documents_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: is_favorite
def test_api_documents_list_filter_is_favorite_true():
"""
Authenticated users should be able to filter documents they marked as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as favorite by the current user
for result in results:
assert result["is_favorite"] is True
def test_api_documents_list_filter_is_favorite_false():
"""
Authenticated users should be able to filter documents they didn't mark as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are not marked as favorite by the current user
for result in results:
assert result["is_favorite"] is False
def test_api_documents_list_filter_is_favorite_invalid():
"""Filtering with an invalid `is_favorite` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: link_reach
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_list_filter_link_reach(reach):
"""Authenticated users should be able to filter documents by link reach."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get(f"/api/v1.0/documents/?link_reach={reach:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all results have the chosen link reach
for result in results:
assert result["link_reach"] == reach
def test_api_documents_list_filter_link_reach_invalid():
"""Filtering with an invalid `link_reach` value should raise an error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user])
response = client.get("/api/v1.0/documents/?link_reach=invalid")
assert response.status_code == 400
assert response.json() == {
"link_reach": [
"Select a valid choice. invalid is not one of the available choices."
]
}
# Filters: title
@pytest.mark.parametrize(
"query,nb_results",
[
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 1), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("", 5), # Empty string
],
)
def test_api_documents_list_filter_title(query, nb_results):
"""Authenticated users should be able to search documents by their title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
]
for title in titles:
factories.DocumentFactory(title=title, users=[user])
# Perform the search query
response = client.get(f"/api/v1.0/documents/?title={query:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert query.lower().strip() in result["title"].lower()
@@ -27,6 +27,8 @@ def test_api_documents_retrieve_anonymous_public():
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"destroy": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"partial_update": document.link_role == "editor",
@@ -36,12 +38,14 @@ def test_api_documents_retrieve_anonymous_public():
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": "public",
"link_role": document.link_role,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "public",
"link_role": document.link_role,
"nb_accesses": 0,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -84,9 +88,10 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -94,12 +99,14 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses": 0,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
@@ -168,43 +175,26 @@ def test_api_documents_retrieve_authenticated_related_direct():
client.force_login(user)
document = factories.DocumentFactory()
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document, user=user)
access2 = factories.UserDocumentAccessFactory(document=document)
access1_user = serializers.UserSerializer(instance=user).data
access2_user = serializers.UserSerializer(instance=access2.user).data
serializers.UserSerializer(instance=user)
serializers.UserSerializer(instance=access2.user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access1.id),
"user": access1_user,
"team": "",
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 2,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -257,7 +247,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -268,81 +258,34 @@ def test_api_documents_retrieve_authenticated_related_team_members(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
expected_abilities = {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"abilities": expected_abilities,
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": access_administrator.role,
"abilities": expected_abilities,
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": access_owner.role,
"abilities": expected_abilities,
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": expected_abilities,
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 5,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -360,7 +303,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -371,98 +314,34 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 5,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -481,7 +360,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
they are related via a team whatever the role and see all its accesses.
they are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -492,100 +371,33 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 5,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -132,7 +132,14 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in [
"id",
"accesses",
"created_at",
"creator",
"link_reach",
"link_role",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -216,7 +223,14 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -255,7 +269,14 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
+45
View File
@@ -0,0 +1,45 @@
"""
Test config API endpoints in the Impress core app.
"""
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_200_OK,
)
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@override_settings(
COLLABORATION_SERVER_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
SENTRY_DSN="https://sentry.test/123",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"COLLABORATION_SERVER_URL": "http://testcollab/",
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"SENTRY_DSN": "https://sentry.test/123",
}
+28 -10
View File
@@ -32,15 +32,25 @@ def test_models_documents_id_unique():
factories.DocumentFactory(id=document.id)
def test_models_documents_creator_required():
"""The "creator" field should be required."""
with pytest.raises(ValidationError) as excinfo:
models.Document.objects.create()
assert excinfo.value.message_dict["creator"] == ["This field cannot be null."]
def test_models_documents_title_null():
"""The "title" field can be null."""
document = models.Document.objects.create(title=None)
document = models.Document.objects.create(
title=None, creator=factories.UserFactory()
)
assert document.title is None
def test_models_documents_title_empty():
"""The "title" field can be empty."""
document = models.Document.objects.create(title="")
document = models.Document.objects.create(title="", creator=factories.UserFactory())
assert document.title == ""
@@ -88,9 +98,10 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"link_configuration": False,
"destroy": False,
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"partial_update": False,
"retrieve": False,
"update": False,
@@ -123,8 +134,9 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -157,8 +169,9 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -180,8 +193,9 @@ def test_models_documents_get_abilities_owner():
"ai_translate": True,
"attachment_upload": True,
"destroy": True,
"link_configuration": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -202,8 +216,9 @@ def test_models_documents_get_abilities_administrator():
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": True,
"favorite": True,
"invite_owner": False,
"link_configuration": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -227,8 +242,9 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -254,8 +270,9 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -282,8 +299,9 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"partial_update": False,
"retrieve": True,
"update": False,
+1
View File
@@ -55,4 +55,5 @@ urlpatterns = [
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]
@@ -132,10 +132,13 @@ def create_demo(stdout):
)
queue.flush()
users_ids = list(models.User.objects.values_list("id", flat=True))
with Timeit(stdout, "Creating documents"):
for _ in range(defaults.NB_OBJECTS["docs"]):
queue.push(
models.Document(
creator_id=random.choice(users_ids),
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
@@ -147,7 +150,6 @@ def create_demo(stdout):
with Timeit(stdout, "Creating docs accesses"):
docs_ids = list(models.Document.objects.values_list("id", flat=True))
users_ids = list(models.User.objects.values_list("id", flat=True))
for doc_id in docs_ids:
for user_id in random.sample(
users_ids,
+69 -32
View File
@@ -10,8 +10,9 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
@@ -27,19 +28,12 @@ DATA_DIR = os.path.join("/", "data")
def get_release():
"""
Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
"""
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data["project"]["version"]
except (FileNotFoundError, KeyError):
return "NA" # Default: not available
@@ -56,7 +50,7 @@ class Base(Configuration):
You may also want to override default configuration by setting the following environment
variables:
* DJANGO_SENTRY_DSN
* SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
@@ -104,6 +98,9 @@ class Base(Configuration):
STATIC_ROOT = os.path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
MEDIA_BASE_URL = values.Value(
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
)
SITE_ID = 1
@@ -372,7 +369,22 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Collaboration
COLLABORATION_SERVER_URL = values.Value(
None, environ_name="COLLABORATION_SERVER_URL", environ_prefix=None
)
# Frontend
FRONTEND_THEME = values.Value(
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
# Crisp
CRISP_WEBSITE_ID = values.Value(
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -482,6 +494,42 @@ class Base(Configuration):
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": values.Value(
"ERROR",
environ_name="LOGGING_LEVEL_HANDLERS_CONSOLE",
environ_prefix=None,
),
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -577,23 +625,6 @@ class Development(Base):
class Test(Base):
"""Test environment settings"""
LOGGING = values.DictValue(
{
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"impress": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
@@ -624,7 +655,13 @@ class Production(Base):
"""
# Security
ALLOWED_HOSTS = values.ListValue(None)
# Add allowed host from environment variables.
# The machine hostname is added by default,
# it makes the application pingable by a load balancer on the same machine by example
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
+6 -4
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.7.0"
version = "1.8.2"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,13 +17,13 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.12",
]
description = "An application to print markdown to pdf from a set of managed templates."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.12"
dependencies = [
"boto3==1.35.44",
"Brotli==1.1.0",
@@ -31,6 +31,7 @@ dependencies = [
"django-configurations==2.5.1",
"django-cors-headers==4.5.0",
"django-countries==7.6.1",
"django-filter==24.3",
"django-parler==2.3",
"redis==5.1.1",
"django-redis==5.4.0",
@@ -46,7 +47,7 @@ dependencies = [
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.52.0",
"openai==1.55.3",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"pypandoc==1.14",
@@ -127,6 +128,7 @@ select = [
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
-9
View File
@@ -61,18 +61,9 @@ FROM impress AS impress-builder
WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG Y_PROVIDER_URL
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG MEDIA_URL
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
@@ -36,18 +36,25 @@ export const createDoc = async (
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await input.click();
await input.fill(randomDocs[i]);
await input.blur();
}
return randomDocs;
};
export const verifyDocName = async (page: Page, docName: string) => {
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toBeVisible();
await expect(input).toHaveText(docName);
};
export const addNewMember = async (
page: Page,
index: number,
@@ -97,24 +104,22 @@ export const goToGridDoc = async (
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = datagridTable.getByRole('row');
const rows = docsGrid.getByRole('row');
expect(await rows.count()).toEqual(20);
const row = title
? rows.filter({
hasText: title,
})
: rows.nth(nthRow);
const docTitleCell = row.getByRole('cell').nth(1);
const docTitle = await docTitleCell.textContent();
await expect(row).toBeVisible();
const docTitleContent = row.locator('[aria-describedby="doc-title"]').first();
const docTitle = await docTitleContent.textContent();
expect(docTitle).toBeDefined();
await row.getByRole('link').first().click();
@@ -0,0 +1,161 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
const config = {
CRISP_WEBSITE_ID: null,
COLLABORATION_SERVER_URL: 'ws://localhost:4444',
ENVIRONMENT: 'development',
FRONTEND_THEME: 'dsfr',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'French'],
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
SENTRY_DSN: null,
};
test.describe('Config', () => {
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
expect(await response.json()).toStrictEqual(config);
});
test('it checks that sentry is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
SENTRY_DSN: 'https://sentry.io/123',
},
});
} else {
await route.continue();
}
});
const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
const consoleMessage = page.waitForEvent('console', {
timeout: 5000,
predicate: (msg) => msg.text().includes(invalidMsg),
});
await page.goto('/');
expect((await consoleMessage).text()).toContain(invalidMsg);
});
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
test('it checks that media server is configured from config endpoint', async ({
page,
browserName,
}) => {
await page.goto('/');
await createDoc(page, 'doc-media', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
await expect(image).toBeVisible();
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
test('it checks that collaboration server is configured from config endpoint', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
});
await page.goto('/');
const randomDoc = await createDoc(
page,
'doc-collaboration',
browserName,
1,
);
await verifyDocName(page, randomDoc[0]);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
CRISP_WEBSITE_ID: '1234',
},
});
} else {
await route.continue();
}
});
await page.goto('/');
await expect(
page.locator('#crisp-chatbox').getByText('Invalid website'),
).toBeVisible();
});
});
@@ -18,14 +18,11 @@ test.describe('Doc Create', () => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible({
timeout: 5000,
});
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
await expect(docsGrid.getByText(docTitle)).toBeVisible();
});
});
@@ -2,7 +2,12 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
import {
createDoc,
goToGridDoc,
mockedDocument,
verifyDocName,
} from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -90,7 +95,7 @@ test.describe('Doc Editor', () => {
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await verifyDocName(page, randomDoc[0]);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
@@ -110,7 +115,7 @@ test.describe('Doc Editor', () => {
}) => {
const randomDoc = await createDoc(page, 'doc-markdown', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await verifyDocName(page, randomDoc[0]);
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -135,7 +140,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await verifyDocName(page, firstDoc);
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -144,7 +149,8 @@ test.describe('Doc Editor', () => {
// Check the second doc
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await verifyDocName(page, secondDoc);
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
await editor.fill('Hello World Doc 2');
@@ -154,7 +160,7 @@ test.describe('Doc Editor', () => {
await goToGridDoc(page, {
title: firstDoc,
});
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await verifyDocName(page, firstDoc);
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
});
@@ -165,7 +171,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await verifyDocName(page, doc);
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -176,7 +182,7 @@ test.describe('Doc Editor', () => {
nthRow: 2,
});
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await verifyDocName(page, secondDoc);
await goToGridDoc(page, {
title: doc,
@@ -191,7 +197,8 @@ test.describe('Doc Editor', () => {
// Check the first doc
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await verifyDocName(page, doc);
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -3,13 +3,44 @@ import cs from 'convert-stream';
import jsdom from 'jsdom';
import pdf from 'pdf-parse';
import { createDoc } from './common';
import { createDoc, verifyDocName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Export', () => {
test('it check if all elements are visible', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-editor', browserName, 1);
await page
.getByRole('button', {
name: 'download',
})
.click();
await expect(
page
.locator('div')
.filter({ hasText: /^Download$/ })
.first(),
).toBeVisible();
await expect(
page.getByText(
'Upload your docs to a Microsoft Word, Open Office or PDF document',
),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Close the modal' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
});
test('it converts the doc to pdf with a template integrated', async ({
page,
browserName,
@@ -20,15 +51,14 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
name: 'download',
})
.click();
@@ -57,19 +87,19 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
name: 'download',
})
.click();
await page.getByText('Docx').click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Word / Open Office' }).click();
await page
.getByRole('button', {
@@ -97,7 +127,7 @@ test.describe('Doc Export', () => {
await route.continue();
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await verifyDocName(page, randomDoc);
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.locator('.bn-block-outer').last().click();
@@ -190,10 +220,9 @@ test.describe('Doc Export', () => {
.click();
// Download
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
name: 'download',
})
.click();
@@ -1,264 +1,14 @@
import { expect, test } from '@playwright/test';
type SmallDoc = {
id: string;
title: string;
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
await expect(page.locator('h2').getByText('Documents')).toBeVisible();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
await expect(thead.getByText(/Document name/i)).toBeVisible();
await expect(thead.getByText(/Created at/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await expect(thead.getByText(/Your role/i)).toBeVisible();
await expect(thead.getByText(/Members/i)).toBeVisible();
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await row1.nth(1).textContent();
expect(docName).toBeDefined();
const docCreatedAt = await row1.nth(2).textContent();
expect(docCreatedAt).toBeDefined();
const docUpdatedAt = await row1.nth(3).textContent();
expect(docUpdatedAt).toBeDefined();
const docRole = await row1.nth(4).textContent();
expect(
docRole &&
['Administrator', 'Owner', 'Reader', 'Editor'].includes(docRole),
).toBeTruthy();
const docUserNumber = await row1.nth(5).textContent();
expect(docUserNumber).toBeDefined();
// Open the document
await row1.nth(1).click();
await expect(page.locator('h2').getByText(docName!)).toBeVisible();
});
[
{
nameColumn: 'Document name',
ordering: 'title',
cellNumber: 1,
orderDefault: '',
orderDesc: '&ordering=-title',
orderAsc: '&ordering=title',
defaultColumn: false,
},
{
nameColumn: 'Created at',
ordering: 'created_at',
cellNumber: 2,
orderDefault: '',
orderDesc: '&ordering=-created_at',
orderAsc: '&ordering=created_at',
defaultColumn: false,
},
{
nameColumn: 'Updated at',
ordering: 'updated_at',
cellNumber: 3,
orderDefault: '&ordering=-updated_at',
orderDesc: '&ordering=updated_at',
orderAsc: '',
defaultColumn: true,
},
].forEach(
({
nameColumn,
ordering,
cellNumber,
orderDefault,
orderDesc,
orderAsc,
defaultColumn,
}) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDefault}`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDesc}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderAsc}`) &&
response.status() === 200,
);
// Checks the initial state
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const thead = datagridTable.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagridTable
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagridTable
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
// Ordering ASC
await thead.getByText(nameColumn).click();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
const compare = (comp1: string, comp2: string) => {
const comparisonResult = comp1.localeCompare(comp2, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
});
// eslint-disable-next-line playwright/no-conditional-in-test
return defaultColumn ? comparisonResult >= 0 : comparisonResult <= 0;
};
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
compare(textDocNameRow1Asc, textDocNameRow2Asc),
).toBeTruthy();
// Ordering Desc
await thead.getByText(nameColumn).click();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
compare(textDocNameRow2Desc, textDocNameRow1Desc),
).toBeTruthy();
});
},
);
test('checks the pagination', async ({ page }) => {
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=2`) &&
response.status() === 200,
);
const datagridPage1 = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
await expect(
datagridPage1.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
await page.getByLabel('Go to page 2').click();
const datagridPage2 = page
.getByLabel('Datagrid of the documents page 2')
.getByRole('table');
const responsePage2 = await responsePromisePage2;
expect(responsePage2.ok()).toBeTruthy();
await expect(
datagridPage2.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
});
test('it deletes the document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow
.getByRole('button', {
name: 'Delete the document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docName}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(datagrid.getByText(docName!)).toBeHidden();
});
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
@@ -326,19 +76,257 @@ test.describe('Documents Grid mobile', () => {
await page.goto('/');
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const tableDatagrid = datagrid.getByRole('table');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = tableDatagrid.getByRole('row');
const rows = docsGrid.getByRole('row');
const row = rows.filter({
hasText: 'My mocked document',
});
await expect(row.getByRole('cell').nth(0)).toHaveText('My mocked document');
await expect(row.getByRole('cell').nth(1)).toHaveText('Public');
await expect(
row.locator('[aria-describedby="doc-title"]').nth(0),
).toHaveText('My mocked document');
});
});
test.describe('Document grid item options', () => {
test('it deletes the document', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
const button = page.getByTestId(`docs-grid-actions-button-${docs[0].id}`);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-${docs[0].id}`,
);
await expect(removeButton).toBeVisible();
await removeButton.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docs[0].title}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
const refetchResponse = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const resultRefetch = await refetchResponse.json();
expect(resultRefetch.count).toBe(result.count - 1);
await expect(page.getByTestId('main-layout-loader')).toBeHidden();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(button).toBeHidden();
});
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
page,
}) => {
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
await route.fulfill({
json: {
results: [
{
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
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,
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
},
],
},
});
});
await page.goto('/');
const button = page.getByTestId(
`docs-grid-actions-button-mocked-document-id`,
);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-mocked-document-id`,
);
await expect(removeButton).toBeVisible();
await removeButton.isDisabled();
});
});
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
// All Docs
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
const allCount = result.count as number;
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
const allDocs = page.getByLabel('All docs');
const myDocs = page.getByLabel('My docs');
const sharedWithMe = page.getByLabel('Shared with me');
// Initial state
await expect(allDocs).toBeVisible();
await expect(allDocs).toHaveCSS('background-color', 'rgb(238, 238, 238)');
await expect(allDocs).toHaveAttribute('aria-selected', 'true');
await expect(myDocs).toBeVisible();
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(myDocs).toHaveAttribute('aria-selected', 'false');
await expect(sharedWithMe).toBeVisible();
await expect(sharedWithMe).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await expect(sharedWithMe).toHaveAttribute('aria-selected', 'false');
await allDocs.click();
let url = new URL(page.url());
let target = url.searchParams.get('target');
expect(target).toBe('all_docs');
// My docs
await myDocs.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('my_docs');
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const responseMyDocs = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=true') &&
response.status() === 200,
);
const resultMyDocs = await responseMyDocs.json();
const countMyDocs = resultMyDocs.count as number;
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
expect(countMyDocs).toBeLessThanOrEqual(allCount);
// Shared with me
await sharedWithMe.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('shared_with_me');
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const responseSharedWithMe = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=false') &&
response.status() === 200,
);
const resultSharedWithMe = await responseSharedWithMe.json();
const countSharedWithMe = resultSharedWithMe.count as number;
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
});
});
test.describe('Documents Grid', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = [];
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
await expect(page.locator('h4').getByText('All docs')).toBeVisible();
const thead = page.getByTestId('docs-grid-header');
await expect(thead.getByText(/Name/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
});
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=2`) &&
response.status() === 200,
);
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
let result = await responsePage1.json();
docs = result.results as SmallDoc[];
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
await page.getByTestId('infinite-scroll-trigger').scrollIntoViewIfNeeded();
const responsePage2 = await responsePromisePage2;
result = await responsePage2.json();
docs = result.results as SmallDoc[];
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
});
});
@@ -6,6 +6,7 @@ import {
mockedAccesses,
mockedDocument,
mockedInvitations,
verifyDocName,
} from './common';
test.beforeEach(async ({ page }) => {
@@ -59,84 +60,31 @@ test.describe('Doc Header', () => {
const card = page.getByLabel(
'It is the card information about the document.',
);
await expect(card.locator('a').getByText('home')).toBeVisible();
await expect(card.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(card.getByText('Public')).toBeVisible();
await expect(
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(
card.getByText('Owners: Super Owner / super2@owner.com'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
const docTitle = card.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await expect(card.getByText('Public document')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Open the document options' }),
).toBeVisible();
});
test('it updates the title doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
await page.getByRole('heading', { name: randomDoc }).fill(' ');
await page.getByText('Created at').click();
await expect(
page.getByRole('heading', { name: 'Untitled document' }),
).toBeVisible();
});
test('it updates the title doc from editor heading', async ({ page }) => {
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
const docHeader = page.getByLabel(
'It is the card information about the document.',
);
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('h1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await expect(
docHeader.getByRole('heading', { name: 'Hello World', level: 2 }),
).toBeVisible();
await expect(
page.getByText('Document title updated successfully'),
).toBeVisible();
await docHeader
.getByRole('heading', { name: 'Hello World', level: 2 })
.fill('Top World');
await editor.locator('h1').fill('Super World');
await expect(
docHeader.getByRole('heading', { name: 'Top World', level: 2 }),
).toBeVisible();
await editor.locator('h1').fill('');
await docHeader
.getByRole('heading', { name: 'Top World', level: 2 })
.fill(' ');
await page.getByText('Created at').click();
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
await verifyDocName(page, 'Hello World');
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -159,9 +107,7 @@ test.describe('Doc Header', () => {
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'New do' })).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
@@ -195,16 +141,13 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -273,16 +216,12 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -351,16 +290,12 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).not.toHaveAttribute('contenteditable');
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -414,7 +349,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -449,7 +384,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -501,6 +436,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { addNewMember, createDoc, goToGridDoc } from './common';
import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -54,8 +54,10 @@ test.describe('Document list members', () => {
const list = page.getByLabel('List members card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
const loadMoreButton = page
.getByLabel('List members card')
.getByRole('button', { name: 'arrow_downward Load more' });
await loadMoreButton.scrollIntoViewIfNeeded();
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
@@ -109,9 +111,13 @@ test.describe('Document list members', () => {
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List invitation card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
const loadMoreButton = page
.getByLabel('List invitation card')
.getByRole('button', { name: 'arrow_downward Load more' });
await loadMoreButton.scrollIntoViewIfNeeded();
await waitForElementCount(list.locator('li'), 21, 10000);
@@ -127,7 +133,7 @@ test.describe('Document list members', () => {
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
@@ -188,7 +194,7 @@ test.describe('Document list members', () => {
test('it checks the delete members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
@@ -9,7 +9,7 @@ test.describe('Doc Routing', () => {
test('Check the presence of the meta tag noindex', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
});
await expect(buttonCreateHomepage).toBeVisible();
@@ -27,7 +27,7 @@ test.describe('Doc Routing', () => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
});
await expect(buttonCreateHomepage).toBeVisible();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc } from './common';
import { createDoc, verifyDocName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -17,123 +17,60 @@ test.describe('Doc Table Content', () => {
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await verifyDocName(page, randomDoc);
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Table of contents',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await editor.getByText('Hello').dblclick();
await page.keyboard.type('Level 1');
await editor.getByText('Level 1').dblclick();
await page.getByRole('button', { name: 'Strike' }).click();
await page.locator('.bn-block-outer').first().click();
await editor.click();
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 2; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await page.keyboard.type('Level 2');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 2; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
await page.keyboard.type('Level 3');
const hello = panel.getByText('Hello World');
const superW = panel.getByText('Super World');
const another = panel.getByText('Another World');
expect(true).toBe(true);
await expect(hello).toBeVisible();
await expect(hello).toHaveCSS('font-size', /17/);
await expect(hello).toHaveAttribute('aria-selected', 'true');
const summaryContainer = page.locator('#summaryContainer');
await summaryContainer.hover();
await expect(superW).toBeVisible();
await expect(superW).toHaveCSS('font-size', /14/);
await expect(superW).toHaveAttribute('aria-selected', 'false');
const level1 = summaryContainer.getByText('Level 1');
const level2 = summaryContainer.getByText('Level 2');
const level3 = summaryContainer.getByText('Level 3');
await expect(another).toBeVisible();
await expect(another).toHaveCSS('font-size', /12/);
await expect(another).toHaveAttribute('aria-selected', 'false');
await expect(level1).toBeVisible();
await expect(level1).toHaveCSS('padding', /4px 0px/);
await expect(level1).toHaveAttribute('aria-selected', 'true');
await hello.click();
await expect(level2).toBeVisible();
await expect(level2).toHaveCSS('padding-left', /14.4px/);
await expect(level2).toHaveAttribute('aria-selected', 'false');
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await another.click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'false');
await expect(superW).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(superW).toHaveAttribute('aria-selected', 'true');
});
test('it checks that table contents panel is opened automaticaly if more that 2 headings', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await expect(page.getByLabel('Open the panel')).toBeHidden();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByLabel('Close the panel')).toBeVisible();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await page.getByLabel('Close the panel').click();
await expect(panel).toHaveAttribute('aria-hidden', 'true');
await expect(level3).toBeVisible();
await expect(level3).toHaveCSS('padding-left', /24px/);
await expect(level3).toHaveAttribute('aria-selected', 'false');
});
});
@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
import {
createDoc,
goToGridDoc,
mockedDocument,
verifyDocName,
} from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -10,7 +15,7 @@ test.describe('Doc Version', () => {
test('it displays the doc versions', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await verifyDocName(page, randomDoc);
await page.getByLabel('Open the document options').click();
await page
@@ -18,24 +23,27 @@ test.describe('Doc Version', () => {
name: 'Version history',
})
.click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(1);
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
await expect(panel).toBeVisible();
await expect(modal.getByText('No versions')).toBeVisible();
await modal.getByRole('button', { name: 'close' }).click();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Hello World' }),
).toBeVisible();
await page
.locator('.ProseMirror .bn-block')
.getByText('Hello World')
.getByRole('heading', { name: 'Hello World' })
.fill('It will create a version');
await goToGridDoc(page, {
@@ -43,7 +51,9 @@ test.describe('Doc Version', () => {
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
await expect(
page.getByRole('heading', { name: 'It will create a version' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -52,19 +62,16 @@ test.describe('Doc Version', () => {
})
.click();
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(2);
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
await panel.locator('li').nth(1).click();
await expect(
page.getByText('Read only, you cannot edit document versions.'),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await expect(page.getByText('It will create a version')).toBeHidden();
await panel.getByText('Current version').click();
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
});
test('it does not display the doc versions if not allowed', async ({
@@ -79,24 +86,17 @@ test.describe('Doc Version', () => {
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Version history' }),
).toBeHidden();
await page.getByRole('button', { name: 'Table of content' }).click();
await expect(
page.getByLabel('Document panel').getByText('Versions'),
).toBeHidden();
).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await verifyDocName(page, randomDoc);
await page.locator('.bn-block-outer').last().click();
await page.locator('.bn-block-outer').last().fill('Hello');
@@ -124,84 +124,26 @@ test.describe('Doc Version', () => {
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(page.getByText('World')).toBeHidden();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
await expect(panel).toBeVisible();
await panel.getByLabel('Open the version options').click();
await page.getByText('Restore the version').click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
await expect(page.getByText('Restore this version?')).toBeVisible();
await expect(modal.getByText('World')).toBeHidden();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await page.getByRole('button', { name: 'Restore' }).click();
await expect(page.getByText('Your current document will')).toBeVisible();
await page.getByText('If a member is editing, his').click();
await expect(panel.locator('li')).toHaveCount(3);
await page.getByLabel('Restore', { exact: true }).click();
await panel.getByText('Current version').click();
await expect(page.getByText('Hello')).toBeVisible();
await expect(page.getByText('World')).toBeHidden();
});
test('it restores the doc version from button title', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().click();
await editor.locator('.bn-block-outer').last().fill('Hello');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('Hello')).toBeVisible();
await editor.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(editor.getByText('World')).toBeHidden();
await page
.getByRole('button', {
name: 'Restore this version',
})
.click();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(editor.getByText('Hello')).toBeVisible();
await expect(editor.getByText('World')).toBeHidden();
});
});
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn } from './common';
import { createDoc, keyCloakSignIn, verifyDocName } from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
@@ -85,7 +85,7 @@ test.describe('Doc Visibility: Restricted', () => {
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await verifyDocName(page, docTitle);
const urlDoc = page.url();
@@ -111,7 +111,7 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await verifyDocName(page, docTitle);
const urlDoc = page.url();
@@ -139,7 +139,7 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
@@ -176,7 +176,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await verifyDocName(page, docTitle);
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
@@ -198,7 +198,7 @@ test.describe('Doc Visibility: Public', () => {
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -227,10 +227,14 @@ test.describe('Doc Visibility: Public', () => {
position: { x: 0, y: 0 },
});
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -261,7 +265,7 @@ test.describe('Doc Visibility: Public', () => {
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -290,10 +294,14 @@ test.describe('Doc Visibility: Public', () => {
position: { x: 0, y: 0 },
});
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -308,7 +316,7 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await verifyDocName(page, docTitle);
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
@@ -333,7 +341,7 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -385,7 +393,7 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -451,7 +459,7 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -498,7 +506,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
@@ -1,77 +0,0 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Footer', () => {
test('checks all the elements are visible', async ({ page }) => {
const footer = page.locator('footer').first();
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(
footer.getByRole('link', { name: 'legifrance.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'info.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'service-public.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'data.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Legal Notice' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Personal data and cookies' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Accessibility' }),
).toBeVisible();
await expect(
footer.getByText(
'Unless otherwise stated, all content on this site is under licence',
),
).toBeVisible();
});
test('checks footer is not visible on doc editor', async ({ page }) => {
await expect(page.locator('footer')).toBeVisible();
await goToGridDoc(page);
await expect(page.locator('footer')).toBeHidden();
});
const legalPages = [
{ name: 'Legal Notice', url: '/legal-notice/' },
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' },
{ name: 'Accessibility', url: '/accessibility/' },
];
for (const { name, url } of legalPages) {
test(`checks ${name} page`, async ({ page }) => {
const footer = page.locator('footer').first();
await footer.getByRole('link', { name }).click();
await expect(
page
.getByRole('heading', {
name,
})
.first(),
).toBeVisible();
await expect(page).toHaveURL(url);
});
}
});
@@ -75,29 +75,15 @@ test.describe('Header mobile', () => {
test('it checks the header when mobile', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(
header.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeHidden();
await expect(page.getByText('English')).toBeHidden();
await header.getByLabel('Open the header menu').click();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(page.getByText('English')).toBeVisible();
});
});
@@ -6,11 +6,7 @@ test.beforeEach(async ({ page }) => {
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(
page.getByRole('button', {
name: 'Create a new document',
}),
).toBeVisible();
await expect(page.getByLabel('Logout')).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
@@ -19,11 +15,7 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Créer un nouveau document',
}),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
@@ -31,11 +23,7 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Neues Dokument erstellen',
}),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
});
test('checks that backend uses the same language as the frontend', async ({
@@ -0,0 +1,48 @@
import { expect, test } from '@playwright/test';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
await expect(page.getByRole('button', { name: 'house' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
});
});
test.describe('Left panel mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the desktop elements are hidden and all mobile elements are visible', async ({
page,
}) => {
await expect(page.getByTestId('left-panel-desktop')).toBeHidden();
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
const header = page.locator('header').first();
const homeButton = page.getByRole('button', { name: 'house' });
const newDocButton = page.getByRole('button', { name: 'New doc' });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
await expect(newDocButton).not.toBeInViewport();
await expect(languageButton).not.toBeInViewport();
await expect(logoutButton).not.toBeInViewport();
await header.getByLabel('Open the header menu').click();
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
await expect(homeButton).toBeInViewport();
await expect(newDocButton).toBeInViewport();
await expect(languageButton).toBeInViewport();
await expect(logoutButton).toBeInViewport();
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.7.0",
"version": "1.8.2",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.48.1",
"@playwright/test": "1.49.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
-3
View File
@@ -1,5 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SW_DEACTIVATED=
@@ -1,4 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_Y_PROVIDER_URL=ws://localhost:4444
NEXT_PUBLIC_MEDIA_URL=http://localhost:8083
NEXT_PUBLIC_SW_DEACTIVATED=true
-1
View File
@@ -1,2 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_THEME=test-theme
@@ -9,10 +9,6 @@ server {
try_files $uri index.html $uri/ =404;
}
location ~ ^/docs/(.*)/versions/(.*)/$ {
error_page 404 /docs/[id]/versions/[versionId]/;
}
location /docs/ {
error_page 404 /docs/[id]/;
}
+92 -57
View File
@@ -5,22 +5,30 @@ const config = {
colors: {
'card-border': '#ededed',
'primary-bg': '#FAFAFA',
'primary-050': '#F5F5FE',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'primary-950': '#1B1B35',
'info-150': '#E5EEFA',
'greyscale-000': '#fff',
'greyscale-1000': '#161616',
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.50rem',
xl: '1.25rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
},
weights: {
thin: 100,
@@ -34,6 +42,21 @@ const config = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
xxxl: '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
@@ -104,7 +127,7 @@ const config = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#ffffff',
'menu-background-color': '#fff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -126,7 +149,7 @@ const config = {
},
},
modal: {
'background-color': '#ffffff',
'background-color': '#fff',
},
button: {
'border-radius': {
@@ -147,8 +170,8 @@ const config = {
danger: {
'color-hover': 'white',
background: {
color: 'var(--c--theme--colors--danger-400)',
'color-hover': 'var(--c--theme--colors--danger-500)',
color: 'var(--c--theme--colors--danger-600)',
'color-hover': '#FF2725',
'color-disabled': 'var(--c--theme--colors--danger-100)',
},
},
@@ -178,7 +201,9 @@ const config = {
color: 'var(--c--theme--colors--primary-text)',
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
color: 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-300)',
'color-active': 'var(--c--theme--colors--primary-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
},
},
@@ -197,19 +222,19 @@ const config = {
dsfr: {
theme: {
colors: {
'card-border': '#ededed',
'card-border': '#E5E5E5',
'primary-text': '#000091',
'primary-100': '#f5f5fe',
'primary-100': '#ECECFE',
'primary-150': '#F4F4FD',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
'primary-500': '#6a6af4',
'primary-600': '#000091',
'primary-200': '#E3E3FD',
'primary-300': '#CACAFB',
'primary-400': '#8585F6',
'primary-500': '#6A6AF4',
'primary-600': '#313178',
'primary-700': '#272747',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'primary-800': '#000091',
'primary-900': '#21213F',
'secondary-text': '#fff',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -220,16 +245,22 @@ const config = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-350': '#ddd',
'greyscale-400': '#929292',
'greyscale-500': '#7C7C7C',
'greyscale-600': '#666666',
'greyscale-700': '#3A3A3A',
'greyscale-750': '#353535',
'greyscale-800': '#2A2A2A',
'greyscale-900': '#242424',
'greyscale-950': '#1E1E1E',
'greyscale-1000': '#161616',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -241,15 +272,15 @@ const config = {
'success-800': '#1e2e22',
'success-900': '#19281d',
'info-text': '#0078f3',
'info-100': '#f4f6ff',
'info-200': '#e8edff',
'info-300': '#dde5ff',
'info-400': '#bdcdff',
'info-500': '#0078f3',
'info-600': '#0063cb',
'info-700': '#f4f6ff',
'info-800': '#222a3f',
'info-900': '#1d2437',
'info-100': '#E8EDFF',
'info-200': '#DDE5FF',
'info-300': '#BCCDFF',
'info-400': '#518FFF',
'info-500': '#0078F3',
'info-600': '#0063CB',
'info-700': '#273961',
'info-800': '#222A3F',
'info-900': '#1D2437',
'warning-text': '#d64d00',
'warning-100': '#fff4f3',
'warning-200': '#ffe9e6',
@@ -260,16 +291,16 @@ const config = {
'warning-700': '#5e2c21',
'warning-800': '#3e241e',
'warning-900': '#361e19',
'danger-text': '#e1000f',
'danger-100': '#fef4f4',
'danger-200': '#fee9e9',
'danger-300': '#fddede',
'danger-400': '#fcbfbf',
'danger-500': '#e1000f',
'danger-600': '#c9191e',
'danger-700': '#642727',
'danger-text': '#FFF',
'danger-100': '#FFE9E9',
'danger-200': '#FFDDDD',
'danger-300': '#FFBDBD',
'danger-400': '#FF5655',
'danger-500': '#F60700',
'danger-600': '#CE0500',
'danger-700': '#642626',
'danger-800': '#412121',
'danger-900': '#3a1c1c',
'danger-900': '#391C1C',
},
font: {
families: {
@@ -288,8 +319,12 @@ const config = {
alert: {
'border-radius': '0',
},
modal: {
'width-small': '342px',
},
button: {
'medium-height': '48px',
'medium-height': '40px',
'medium-text-height': '40px',
'border-radius': '4px',
primary: {
background: {
@@ -297,9 +332,9 @@ const config = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
},
'primary-text': {
background: {
@@ -321,7 +356,7 @@ const config = {
},
'tertiary-text': {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--greyscale-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
@@ -363,7 +398,7 @@ const config = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
@@ -381,7 +416,7 @@ const config = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
+18 -15
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.7.0",
"version": "1.8.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,37 +19,40 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.7",
"@hocuspocus/provider": "2.14.0",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.59.15",
"i18next": "23.16.2",
"@sentry/nextjs": "8.40.0",
"@tanstack/react-query": "5.61.3",
"crisp-sdk-web": "1.0.25",
"i18next": "24.0.0",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.15",
"next": "15.0.3",
"react": "*",
"react-aria-components": "1.4.1",
"react-aria-components": "1.5.0",
"react-dom": "*",
"react-i18next": "15.0.3",
"react-select": "5.8.1",
"react-i18next": "15.1.1",
"react-intersection-observer": "9.13.1",
"react-select": "5.8.3",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.0"
"zustand": "5.0.1"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.59.15",
"@tanstack/react-query-devtools": "5.61.3",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.2",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.12",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.11",
"@types/react": "18.3.12",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
@@ -63,7 +66,7 @@
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.95.0",
"webpack": "5.96.1",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -1,26 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
};
},
}));
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', {
name: /Create a new document/i,
}),
).toBeInTheDocument();
});
});
@@ -0,0 +1,6 @@
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
@@ -1,5 +1,4 @@
import { baseApiUrl } from '@/core';
import { baseApiUrl } from './config';
import { getCSRFToken } from './utils';
interface FetchAPIInit extends RequestInit {
@@ -1,4 +1,5 @@
export * from './APIError';
export * from './config';
export * from './fetchApi';
export * from './helpers';
export * from './types';
@@ -1,6 +1,6 @@
import { ComponentPropsWithRef, ReactHTML } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
import { CSSProperties, RuleSet } from 'styled-components/dist/types';
import {
MarginPadding,
@@ -15,7 +15,7 @@ export interface BoxProps {
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$color?: CSSProperties['color'];
$css?: string;
$css?: string | RuleSet<object>;
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
@@ -73,7 +73,7 @@ export const Box = styled('div')<BoxProps>`
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
${({ $css }) => $css && `${$css};`}
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
${({ $zIndex }) => $zIndex && `z-index: ${$zIndex};`}
${({ $effect }) => {
let effect;
@@ -1,8 +1,13 @@
import { ComponentPropsWithRef, forwardRef } from 'react';
import { forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
export type BoxButtonType = BoxType & {
disabled?: boolean;
};
/**
/**
* Styleless button that extends the Box component.
@@ -17,7 +22,7 @@ export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, ...props }, ref) => {
return (
<Box
@@ -26,15 +31,25 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
$background="none"
$margin="none"
$padding="none"
$css={`
cursor: pointer;
$css={css`
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
border: none;
outline: none;
transition: all 0.2s ease-in-out;
font-family: inherit;
color: ${props.disabled
? 'var(--c--theme--colors--greyscale-400) !important'
: 'inherit'};
${$css || ''}
`}
{...props}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;
}
props.onClick?.(event);
}}
/>
);
},
@@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
@@ -15,9 +16,8 @@ export const Card = ({
<Box
$background="white"
$radius="4px"
$css={`
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
border: 1px solid ${colorsTokens()['card-border']};
$css={css`
border: 1px solid ${colorsTokens()['greyscale-200']};
${$css}
`}
{...props}
@@ -1,9 +1,4 @@
import React, {
PropsWithChildren,
ReactNode,
useEffect,
useState,
} from 'react';
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import { Button, DialogTrigger, Popover } from 'react-aria-components';
import styled from 'styled-components';
@@ -11,7 +6,7 @@ const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
padding: 0.5rem;
border: 1px solid #dddddd;
opacity: 0;
transition: opacity 0.2s ease-in-out;
@@ -29,7 +24,7 @@ const StyledButton = styled(Button)`
text-wrap: nowrap;
`;
interface DropButtonProps {
export interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
@@ -0,0 +1,117 @@
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
disabled?: boolean;
show?: boolean;
};
export type DropdownMenuProps = {
options: DropdownMenuOption[];
showArrow?: boolean;
arrowCss?: BoxProps['$css'];
};
export const DropdownMenu = ({
options,
children,
showArrow = false,
arrowCss,
}: PropsWithChildren<DropdownMenuProps>) => {
const theme = useCunninghamTheme();
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
};
return (
<DropButton
isOpen={isOpen}
onOpenChange={onOpenChange}
button={
showArrow ? (
<Box>
<div>{children}</div>
<Icon
$css={
arrowCss ??
css`
color: var(--c--theme--colors--primary-600);
`
}
iconName={isOpen ? 'arrow_drop_up' : 'arrow_drop_down'}
/>
</Box>
) : (
children
)
}
>
<Box>
{options.map((option, index) => {
if (option.show !== undefined && !option.show) {
return;
}
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
}}
key={option.label}
$align="center"
$background={colors['greyscale-000']}
$color={colors['primary-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
$width="100%"
$gap={spacings['base']}
$css={css`
border: none;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--primary-600);
font-weight: 500;
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
border-bottom: ${index !== options.length - 1
? `1px solid var(--c--theme--colors--greyscale-200)`
: 'none'};
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
`}
>
{option.icon && (
<Icon
$size="20px"
$theme={!isDisabled ? 'primary' : 'greyscale'}
$variation={!isDisabled ? '600' : '400'}
iconName={option.icon}
/>
)}
{option.label}
</BoxButton>
);
})}
</Box>
</DropButton>
);
};
@@ -1,6 +1,19 @@
import { css } from 'styled-components';
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type IconProps = TextType & {
iconName: string;
};
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text $isMaterialIcon {...textProps}>
{iconName}
</Text>
);
};
interface IconBGProps extends TextType {
iconName: string;
}
@@ -29,23 +42,21 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
);
};
interface IconOptionsProps {
isOpen: boolean;
'aria-label': string;
}
type IconOptionsProps = TextType & {
isHorizontal?: boolean;
};
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
return (
<Text
aria-label={props['aria-label']}
{...props}
$isMaterialIcon
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
$css={css`
user-select: none;
${props.$css}
`}
>
more_vert
{isHorizontal ? 'more_horiz' : 'more_vert'}
</Text>
);
};
@@ -1,12 +1,16 @@
import { PropsWithChildren, useEffect, useRef } from 'react';
import { Button } from '@openfun/cunningham-react';
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
import { Box, BoxType } from '@/components';
import { Box, BoxType, Icon } from '@/components';
interface InfiniteScrollProps extends BoxType {
hasMore: boolean;
isLoading: boolean;
next: () => void;
scrollContainer: HTMLElement | null;
scrollContainer?: HTMLElement | null;
buttonLabel?: string;
}
export const InfiniteScroll = ({
@@ -14,42 +18,31 @@ export const InfiniteScroll = ({
hasMore,
isLoading,
next,
scrollContainer,
buttonLabel,
...boxProps
}: PropsWithChildren<InfiniteScrollProps>) => {
const timeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!scrollContainer) {
const { t } = useTranslation();
const loadMore = (inView: boolean) => {
if (!inView || isLoading) {
return;
}
void next();
};
const nextHandle = () => {
if (!hasMore || isLoading) {
return;
}
// To not wait until the end of the scroll to load more data
const heightFromBottom = 150;
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
next();
}
};
const handleScroll = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(nextHandle, 50);
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [hasMore, isLoading, next, scrollContainer]);
return <Box {...boxProps}>{children}</Box>;
return (
<Box {...boxProps}>
{children}
<InView onChange={loadMore}>
{!isLoading && hasMore && (
<Button
onClick={() => void next()}
color="primary-text"
icon={<Icon iconName="arrow_downward" />}
>
{buttonLabel ?? t('Load more')}
</Button>
)}
</InView>
</Box>
);
};
@@ -33,6 +33,7 @@ export interface TextProps extends BoxProps {
| 'greyscale';
$variation?:
| 'text'
| '000'
| '100'
| '200'
| '300'
@@ -41,7 +42,8 @@ export interface TextProps extends BoxProps {
| '600'
| '700'
| '800'
| '900';
| '900'
| '1000';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
@@ -17,8 +17,8 @@ describe('<Box />', () => {
);
expect(screen.getByText('My Box')).toHaveStyle(`
padding-left: 4rem;
padding-right: 4rem;
padding-left: 2.5rem;
padding-right: 2.5rem;
padding-top: 3rem;
padding-bottom: 0.5rem;`);
});
@@ -2,9 +2,11 @@ export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './DropdownMenu';
export * from './Icon';
export * from './InfiniteScroll';
export * from './Link';
export * from './SideModal';
export * from './separators/SeparatedSection';
export * from './Text';
export * from './TextErrors';
@@ -0,0 +1,33 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
$withPadding?: boolean;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
$withPadding = true,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$height="1px"
$width="100%"
$margin={{ vertical: $withPadding ? 'base' : 'none' }}
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'
: colorsTokens()['greyscale-100']
}
/>
);
};
@@ -0,0 +1,32 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
type Props = {
showSeparator?: boolean;
};
export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
const spacings = theme.spacingsTokens();
return (
<Box
$css={css`
padding: ${spacings['base']} 0;
${showSeparator &&
css`
border-bottom: 1px solid ${colors?.['greyscale-200']};
`}
`}
>
{children}
</Box>
);
};
@@ -7,6 +7,7 @@ import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { Auth } from './auth/';
import { ConfigProvider } from './config/';
/**
* QueryClient:
@@ -39,7 +40,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<Auth>{children}</Auth>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
</CunninghamProvider>
</QueryClientProvider>
);
@@ -0,0 +1,40 @@
import { Crisp } from 'crisp-sdk-web';
import fetchMock from 'fetch-mock';
import { useAuthStore } from '../useAuthStore';
jest.mock('crisp-sdk-web', () => ({
...jest.requireActual('crisp-sdk-web'),
Crisp: {
isCrispInjected: jest.fn().mockReturnValue(true),
setTokenId: jest.fn(),
user: {
setEmail: jest.fn(),
},
session: {
reset: jest.fn(),
},
},
}));
describe('useAuthStore', () => {
afterEach(() => {
jest.clearAllMocks();
fetchMock.restore();
});
it('checks support session is terminated when logout', () => {
window.$crisp = true;
Object.defineProperty(window, 'location', {
value: {
...window.location,
replace: jest.fn(),
},
writable: true,
});
useAuthStore.getState().logout();
expect(Crisp.session.reset).toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { baseApiUrl } from '@/core/conf';
import { baseApiUrl } from '@/api';
import { terminateCrispSession } from '@/services';
import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
@@ -42,6 +43,7 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
window.location.replace(`${baseApiUrl()}authenticate/`);
},
logout: () => {
terminateCrispSession();
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page and we are not authenticated
@@ -1,18 +0,0 @@
export const mediaUrl = () =>
process.env.NEXT_PUBLIC_MEDIA_URL ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
export const providerUrl = (docId: string) => {
const base =
process.env.NEXT_PUBLIC_Y_PROVIDER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${docId}`;
};
@@ -0,0 +1,49 @@
import { Loader } from '@openfun/cunningham-react';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { configureCrispSession } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
return;
}
setSentry(conf.SENTRY_DSN, conf.ENVIRONMENT);
}, [conf?.SENTRY_DSN, conf?.ENVIRONMENT, setSentry]);
useEffect(() => {
if (!conf?.FRONTEND_THEME) {
return;
}
setTheme(conf.FRONTEND_THEME);
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
if (!conf?.CRISP_WEBSITE_ID) {
return;
}
configureCrispSession(conf.CRISP_WEBSITE_ID);
}, [conf?.CRISP_WEBSITE_ID]);
if (!conf) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
</Box>
);
}
return children;
};
@@ -0,0 +1 @@
export * from './useConfig';
@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
interface ConfigResponse {
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
ENVIRONMENT: string;
COLLABORATION_SERVER_URL?: string;
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
MEDIA_BASE_URL?: string;
SENTRY_DSN?: string;
}
export const getConfig = async (): Promise<ConfigResponse> => {
const response = await fetchAPI(`config/`);
if (!response.ok) {
throw new APIError('Failed to get the doc', await errorCauses(response));
}
return response.json() as Promise<ConfigResponse>;
};
export const KEY_CONFIG = 'config';
export function useConfig() {
return useQuery<ConfigResponse, APIError, ConfigResponse>({
queryKey: [KEY_CONFIG],
queryFn: () => getConfig(),
staleTime: Infinity,
});
}
@@ -0,0 +1,2 @@
export * from './useMediaUrl';
export * from './useCollaborationUrl';
@@ -0,0 +1,15 @@
import { useConfig } from '../api';
export const useCollaborationUrl = (room?: string) => {
const { data: conf } = useConfig();
if (!room) {
return;
}
const base =
conf?.COLLABORATION_SERVER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${room}`;
};
@@ -0,0 +1,10 @@
import { useConfig } from '../api';
export const useMediaUrl = () => {
const { data: conf } = useConfig();
return (
conf?.MEDIA_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
);
};
@@ -0,0 +1,3 @@
export * from './api/';
export * from './ConfigProvider';
export * from './hooks';
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './AppProvider';
export * from './auth';
export * from './conf';
export * from './config';
@@ -1,12 +1,6 @@
import useCunninghamTheme from '../useCunninghamTheme';
import { useCunninghamTheme } from '../useCunninghamTheme';
describe('<useCunninghamTheme />', () => {
it('has the theme from NEXT_PUBLIC_THEME', () => {
const { theme } = useCunninghamTheme.getState();
expect(theme).toBe('test-theme');
});
it('has the dsfr logo correctly set', () => {
const { themeTokens, setTheme } = useCunninghamTheme.getState();
setTheme('dsfr');
@@ -351,6 +351,24 @@ input:-webkit-autofill:focus {
background-color: transparent;
}
.c__button--nano {
padding: 0 var(--c--theme--spacings--3xs);
gap: var(--c--theme--spacings--3xs);
}
.c__button--nano {
padding: 0 var(--c--theme--spacings--3xs);
gap: var(--c--theme--spacings--3xs);
}
.c__button--nano.c__button--icon-only {
width: auto;
}
.c__button--nano.c__button--icon-only.c__button--full-width {
width: 100%;
}
.c__button--medium {
padding: 0.9rem var(--c--theme--spacings--s);
}
@@ -442,6 +460,7 @@ input:-webkit-autofill:focus {
}
.c__button--tertiary {
background-color: var(--c--components--button--tertiary--background--color);
color: var(--c--components--button--tertiary--color);
border: none;
}
@@ -454,6 +473,13 @@ input:-webkit-autofill:focus {
color: var(--c--components--button--tertiary--color);
}
.c__button--tertiary:active {
background-color: var(
--c--components--button--tertiary--background--color-active
);
color: var(--c--components--button--tertiary--color-active);
}
.c__button--tertiary:disabled {
background-color: var(
--c--components--button--tertiary--background--color-disabled
@@ -511,14 +537,32 @@ input:-webkit-autofill:focus {
box-shadow: none;
}
.c__modal__footer {
margin-top: -1.5rem;
}
.c__modal__close button {
padding: 1.5rem 1rem;
padding: 0px;
font-size: 88px;
width: 28px !important;
height: 28px;
}
.c__modal__close button .material-icons {
padding: 0px;
font-size: 24px;
}
.c__modal--full .c__modal__content {
overflow-y: auto;
}
.c__modal__title {
padding: 0;
font-size: 1.125rem;
margin-bottom: var(--c--theme--spacings--sm);
}
@media screen and (width <= 420px) {
.c__modal__scroller {
padding: 0.7rem;
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ export const tokens = {
'secondary-700': '#97A3AE',
'secondary-800': '#757E87',
'secondary-900': '#596067',
'info-text': '#FFFFFF',
'info-text': '#fff',
'info-100': '#EBF2FC',
'info-200': '#8CB5EA',
'info-300': '#5894E1',
@@ -32,7 +32,7 @@ export const tokens = {
'greyscale-700': '#555F6B',
'greyscale-800': '#303C4B',
'greyscale-900': '#0C1A2B',
'greyscale-000': '#FFFFFF',
'greyscale-000': '#fff',
'primary-100': '#EDF5FA',
'primary-200': '#8CB5EA',
'primary-300': '#5894E1',
@@ -69,28 +69,35 @@ export const tokens = {
'danger-700': '#9B0000',
'danger-800': '#780000',
'danger-900': '#5C0000',
'primary-text': '#FFFFFF',
'success-text': '#FFFFFF',
'warning-text': '#FFFFFF',
'danger-text': '#FFFFFF',
'primary-text': '#fff',
'success-text': '#fff',
'warning-text': '#fff',
'danger-text': '#fff',
'card-border': '#ededed',
'primary-bg': '#FAFAFA',
'primary-050': '#F5F5FE',
'primary-150': '#E5EEFA',
'primary-950': '#1B1B35',
'info-150': '#E5EEFA',
'greyscale-1000': '#161616',
},
font: {
sizes: {
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
l: '1rem',
m: '0.8125rem',
s: '0.75rem',
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.50rem',
xl: '1.25rem',
t: '0.6875rem',
},
weights: {
@@ -120,7 +127,7 @@ export const tokens = {
},
spacings: {
'0': '0',
xl: '4rem',
xl: '2.5rem',
l: '3rem',
b: '1.625rem',
s: '1rem',
@@ -130,6 +137,20 @@ export const tokens = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xxl: '3rem',
xxxl: '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
transitions: {
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
@@ -202,7 +223,7 @@ export const tokens = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#ffffff',
'menu-background-color': '#fff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -223,7 +244,7 @@ export const tokens = {
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
},
},
modal: { 'background-color': '#ffffff' },
modal: { 'background-color': '#fff' },
button: {
'border-radius': {
active: 'var(--c--components--button--border-radius)',
@@ -243,8 +264,8 @@ export const tokens = {
danger: {
'color-hover': 'white',
background: {
color: 'var(--c--theme--colors--danger-400)',
'color-hover': 'var(--c--theme--colors--danger-500)',
color: 'var(--c--theme--colors--danger-600)',
'color-hover': '#FF2725',
'color-disabled': 'var(--c--theme--colors--danger-100)',
},
},
@@ -270,7 +291,9 @@ export const tokens = {
color: 'var(--c--theme--colors--primary-text)',
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
color: 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-300)',
'color-active': 'var(--c--theme--colors--primary-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
},
},
@@ -334,19 +357,19 @@ export const tokens = {
dsfr: {
theme: {
colors: {
'card-border': '#ededed',
'card-border': '#E5E5E5',
'primary-text': '#000091',
'primary-100': '#f5f5fe',
'primary-100': '#ECECFE',
'primary-150': '#F4F4FD',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
'primary-500': '#6a6af4',
'primary-600': '#000091',
'primary-200': '#E3E3FD',
'primary-300': '#CACAFB',
'primary-400': '#8585F6',
'primary-500': '#6A6AF4',
'primary-600': '#313178',
'primary-700': '#272747',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'primary-800': '#000091',
'primary-900': '#21213F',
'secondary-text': '#fff',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -357,16 +380,22 @@ export const tokens = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-350': '#ddd',
'greyscale-400': '#929292',
'greyscale-500': '#7C7C7C',
'greyscale-600': '#666666',
'greyscale-700': '#3A3A3A',
'greyscale-750': '#353535',
'greyscale-800': '#2A2A2A',
'greyscale-900': '#242424',
'greyscale-950': '#1E1E1E',
'greyscale-1000': '#161616',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -378,15 +407,15 @@ export const tokens = {
'success-800': '#1e2e22',
'success-900': '#19281d',
'info-text': '#0078f3',
'info-100': '#f4f6ff',
'info-200': '#e8edff',
'info-300': '#dde5ff',
'info-400': '#bdcdff',
'info-500': '#0078f3',
'info-600': '#0063cb',
'info-700': '#f4f6ff',
'info-800': '#222a3f',
'info-900': '#1d2437',
'info-100': '#E8EDFF',
'info-200': '#DDE5FF',
'info-300': '#BCCDFF',
'info-400': '#518FFF',
'info-500': '#0078F3',
'info-600': '#0063CB',
'info-700': '#273961',
'info-800': '#222A3F',
'info-900': '#1D2437',
'warning-text': '#d64d00',
'warning-100': '#fff4f3',
'warning-200': '#ffe9e6',
@@ -397,16 +426,16 @@ export const tokens = {
'warning-700': '#5e2c21',
'warning-800': '#3e241e',
'warning-900': '#361e19',
'danger-text': '#e1000f',
'danger-100': '#fef4f4',
'danger-200': '#fee9e9',
'danger-300': '#fddede',
'danger-400': '#fcbfbf',
'danger-500': '#e1000f',
'danger-600': '#c9191e',
'danger-700': '#642727',
'danger-text': '#FFF',
'danger-100': '#FFE9E9',
'danger-200': '#FFDDDD',
'danger-300': '#FFBDBD',
'danger-400': '#FF5655',
'danger-500': '#F60700',
'danger-600': '#CE0500',
'danger-700': '#642626',
'danger-800': '#412121',
'danger-900': '#3a1c1c',
'danger-900': '#391C1C',
},
font: { families: { accent: 'Marianne', base: 'Marianne' } },
logo: {
@@ -418,8 +447,10 @@ export const tokens = {
},
components: {
alert: { 'border-radius': '0' },
modal: { 'width-small': '342px' },
button: {
'medium-height': '48px',
'medium-height': '40px',
'medium-text-height': '40px',
'border-radius': '4px',
primary: {
background: {
@@ -427,9 +458,9 @@ export const tokens = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
},
'primary-text': {
background: {
@@ -448,7 +479,7 @@ export const tokens = {
},
'tertiary-text': {
background: {
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--greyscale-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
@@ -486,7 +517,7 @@ export const tokens = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
@@ -502,7 +533,7 @@ export const tokens = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
@@ -1,4 +1,2 @@
import { tokens } from './cunningham-tokens';
import useCunninghamTheme from './useCunninghamTheme';
export { tokens, useCunninghamTheme };
export * from './cunningham-tokens';
export * from './useCunninghamTheme';
@@ -5,30 +5,37 @@ import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
type ColorsTokens = Tokens['theme']['colors'];
type FontSizesTokens = Tokens['theme']['font']['sizes'];
type SpacingsTokens = Tokens['theme']['spacings'];
type ComponentTokens = Tokens['components'];
type Theme = 'default' | 'dsfr';
export type Theme = keyof typeof tokens.themes;
interface AuthStore {
theme: Theme;
theme: string;
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
colorsTokens: () => Partial<ColorsTokens>;
fontSizesTokens: () => Partial<FontSizesTokens>;
spacingsTokens: () => Partial<SpacingsTokens>;
componentTokens: () => ComponentTokens;
}
const useCunninghamTheme = create<AuthStore>((set, get) => {
export const useCunninghamTheme = create<AuthStore>((set, get) => {
const currentTheme = () =>
merge(tokens.themes['default'], tokens.themes[get().theme]) as Tokens;
merge(
tokens.themes['default'],
tokens.themes[get().theme as keyof typeof tokens.themes],
) as Tokens;
return {
theme: (process.env.NEXT_PUBLIC_THEME as Theme) || 'dsfr',
theme: 'dsfr',
themeTokens: () => currentTheme().theme,
colorsTokens: () => currentTheme().theme.colors,
componentTokens: () => currentTheme().components,
spacingsTokens: () => currentTheme().theme.spacings,
fontSizesTokens: () => currentTheme().theme.font.sizes,
setTheme: (theme: Theme) => {
set({ theme });
},
};
});
export default useCunninghamTheme;
-3
View File
@@ -20,9 +20,6 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_MEDIA_URL?: string;
NEXT_PUBLIC_Y_PROVIDER_URL?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
NEXT_PUBLIC_THEME?: string;
}
}
@@ -14,14 +14,13 @@ import { useTranslation } from 'react-i18next';
import { isAPIError } from '@/api';
import { Box, Text } from '@/components';
import { useDocOptions } from '@/features/docs/doc-management/';
import { useDocOptions, useDocStore } from '@/features/docs/doc-management/';
import {
AITransformActions,
useDocAITransform,
useDocAITranslate,
} from '../api/';
import { useDocStore } from '../stores';
type LanguageTranslate = {
value: string;
@@ -1,43 +1,53 @@
import { locales } from '@blocknote/core';
import { Dictionary, locales } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { t } from 'i18next';
import React, { useCallback, useEffect } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, TextErrors } from '@/components';
import { mediaUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Version } from '@/features/docs/doc-versioning/';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import { useUploadFile } from '../hook';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore, useHeadingStore } from '../stores';
import { useEditorStore, useHeadingStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
const cssEditor = (readonly: boolean) => `
&, & > .bn-container, & .ProseMirror {
height:100%
};
& .bn-editor {
padding-right: 30px;
${
readonly &&
`
padding-left: 30px;
pointer-events: none;
`
const cssEditor = (readonly: boolean) => css`
color: red;
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
a {
color: var(--c--theme--colors--greyscale-500);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
}
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
}
&:has(h2) {
padding-top: 24px;
}
&:has(h3) {
padding-top: 16px;
}
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
@@ -48,14 +58,14 @@ const cssEditor = (readonly: boolean) => `
padding-left: 40px;
padding-right: 10px;
${readonly && `padding-left: 10px;`}
};
.bn-side-menu[data-block-type=heading][data-level="1"] {
height: 46px;
}
.bn-side-menu[data-block-type=heading][data-level="2"] {
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 40px;
}
.bn-side-menu[data-block-type=heading][data-level="3"] {
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 40px;
}
& .bn-editor h1 {
@@ -67,7 +77,7 @@ const cssEditor = (readonly: boolean) => `
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type="paragraph"]
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
@@ -75,70 +85,32 @@ const cssEditor = (readonly: boolean) => `
`;
interface BlockNoteEditorProps {
doc: Doc;
version?: Version;
}
export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
const { createProvider, docsStore } = useDocStore();
const storeId = version?.id || doc.id;
const initialContent = version?.content || doc.content;
const provider = docsStore?.[storeId]?.provider;
useEffect(() => {
if (!provider || provider.document.guid !== storeId) {
createProvider(storeId, initialContent);
}
}, [createProvider, initialContent, provider, storeId]);
if (!provider) {
return null;
}
return <BlockNoteContent doc={doc} provider={provider} storeId={storeId} />;
};
interface BlockNoteContentProps {
doc: Doc;
provider: HocuspocusProvider;
storeId: string;
}
export const BlockNoteContent = ({
export const BlockNoteEditor = ({
doc,
provider,
storeId,
}: BlockNoteContentProps) => {
}: BlockNoteEditorProps) => {
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const readOnly = !doc.abilities.partial_update || isVersion;
useSaveDoc(doc.id, provider.document, !readOnly);
const storedEditor = docsStore?.[storeId]?.editor;
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const { setHeadings, resetHeadings } = useHeadingStore();
const { i18n } = useTranslation();
const lang = i18n.language;
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const ret = await createDocAttachment({
docId: doc.id,
body,
});
return `${mediaUrl()}${ret.file}`;
},
[createDocAttachment, doc.id],
);
const collabName =
userData?.full_name ||
userData?.email ||
(readOnly ? 'Reader' : t('Anonymous'));
const editor = useCreateBlockNote(
{
@@ -146,19 +118,48 @@ export const BlockNoteContent = ({
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.full_name || userData?.email || t('Anonymous'),
name: collabName,
color: randomColor(),
},
/**
* We re-use the blocknote code to render the cursor but we:
* - fix rendering issue with Firefox
* - We don't want to show the cursor when anonymous users
*/
renderCursor: (user: { color: string; name: string }) => {
const cursor = document.createElement('span');
if (user.name === 'Reader') {
return cursor;
}
cursor.classList.add('collaboration-cursor__caret');
cursor.setAttribute('style', `border-color: ${user.color}`);
const label = document.createElement('span');
label.classList.add('collaboration-cursor__label');
label.setAttribute('style', `background-color: ${user.color}`);
label.insertBefore(document.createTextNode(user.name), null);
cursor.insertBefore(label, null);
return cursor;
},
},
dictionary: locales[lang as keyof typeof locales],
dictionary: locales[lang as keyof typeof locales] as Dictionary,
uploadFile,
},
[lang, provider, uploadFile, userData?.email, userData?.full_name],
[collabName, lang, provider, uploadFile],
);
useEffect(() => {
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
useEffect(() => {
setHeadings(editor);
@@ -174,7 +175,7 @@ export const BlockNoteContent = ({
return (
<Box $css={cssEditor(readOnly)}>
{isErrorAttachment && (
{errorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors
causes={errorAttachment.cause}
@@ -185,7 +186,7 @@ export const BlockNoteContent = ({
)}
<BlockNoteView
editor={storedEditor ?? editor}
editor={editor}
formattingToolbar={false}
editable={!readOnly}
theme="light"
@@ -1,76 +1,89 @@
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Card, Text, TextErrors } from '@/components';
import { Box, Text, TextErrors } from '@/components';
import { useCollaborationUrl } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content/';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
import { DocVersionHeader } from '../../doc-header/components/DocVersionHeader';
import { useHeadingStore } from '../stores';
import { BlockNoteEditor } from './BlockNoteEditor';
import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
interface DocEditorProps {
doc: Doc;
versionId?: Versions['version_id'];
}
export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
const { t } = useTranslation();
const { headings } = useHeadingStore();
const { isMobile } = useResponsiveStore();
const { isDesktop } = useResponsiveStore();
const isVersion = versionId && typeof versionId === 'string';
const isVersion = !!versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();
const { providers } = useDocStore();
const provider = providers?.[doc.id];
if (!provider) {
return null;
}
return (
<>
<DocHeader doc={doc} versionId={versionId as Versions['version_id']} />
{!doc.abilities.partial_update && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit this document.`)}
</Alert>
{isDesktop && !isVersion && (
<Box
$position="absolute"
$css={css`
top: 72px;
right: 20px;
`}
>
<TableContent headings={headings} />
</Box>
)}
{isVersion && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit document versions.`)}
</Alert>
<Box $maxWidth="868px" $width="100%" $height="100%">
<Box $padding={{ horizontal: '54px' }}>
{isVersion ? (
<DocVersionHeader title={doc.title} />
) : (
<DocHeader doc={doc} />
)}
</Box>
)}
<Box
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip;"
$position="relative"
>
<Card
$padding={isMobile ? 'small' : 'big'}
$css="flex:1;"
$overflow="auto"
{!doc.abilities.partial_update && (
<Box $width="100%" $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit this document.`)}
</Alert>
</Box>
)}
<Box
$background={colorsTokens()['primary-bg']}
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
>
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
)}
{!isMobile && <IconOpenPanelEditor headings={headings} />}
</Card>
<PanelEditor doc={doc} headings={headings} />
<Box $css="flex:1;" $overflow="auto" $position="relative">
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} storeId={doc.id} provider={provider} />
)}
</Box>
</Box>
</Box>
</>
);
@@ -91,12 +104,25 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
docId: doc.id,
versionId,
});
const { createProvider, providers } = useDocStore();
const collaborationUrl = useCollaborationUrl(versionId);
const navigate = useNavigate();
const { replace } = useRouter();
useEffect(() => {
if (!version?.id || !collaborationUrl) {
return;
}
const provider = providers?.[version.id];
if (!provider || provider.document.guid !== version.id) {
createProvider(collaborationUrl, version.id, version.content);
}
}, [createProvider, providers, version, collaborationUrl]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
void replace(`/404`);
return null;
}
@@ -124,5 +150,11 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
);
}
return <BlockNoteEditor doc={doc} version={version} />;
const provider = providers?.[version.id];
if (!provider) {
return null;
}
return <BlockNoteEditor doc={doc} storeId={version.id} provider={provider} />;
};
@@ -1,207 +0,0 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content';
import { VersionList } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { usePanelEditorStore } from '../stores';
import { HeadingBlock } from '../types';
interface PanelProps {
doc: Doc;
headings: HeadingBlock[];
}
export const PanelEditor = ({
doc,
headings,
}: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile } = useResponsiveStore();
const { isPanelTableContentOpen, setIsPanelTableContentOpen, isPanelOpen } =
usePanelEditorStore();
return (
<Card
$width="100%"
$maxWidth="20rem"
$position={isMobile ? 'absolute' : 'sticky'}
$height="100%"
$hasTransition="slow"
$css={`
top: 0vh;
right: 0;
transform: translateX(0%);
flex: 1;
margin-left: 1rem;
${
!isPanelOpen &&
`
transform: translateX(200%);
opacity: 0;
flex: 0;
margin-left: 0rem;
max-width: 0rem;
`
}
`}
aria-label={t('Document panel')}
aria-hidden={!isPanelOpen}
>
<Box
$overflow="inherit"
$position="sticky"
$hasTransition="slow"
$css={`
top: 0;
opacity: ${isPanelOpen ? '1' : '0'};
`}
$maxHeight="99vh"
>
{isMobile && <IconOpenPanelEditor headings={headings} />}
<Box
$direction="row"
$justify="space-between"
$align="center"
$position="relative"
$background={colorsTokens()['primary-400']}
$margin={{ bottom: 'tiny' }}
$radius="4px 4px 0 0"
>
<Box
$background="white"
$position="absolute"
$height="100%"
$width={doc.abilities.versions_list ? '50%' : '100%'}
$hasTransition="slow"
$css={`
border-top: 2px solid ${colorsTokens()['primary-600']};
border-radius: 0 4px 0 0;
${
isPanelTableContentOpen
? `
transform: translateX(0);
border-radius: 4px 0 0 0;
`
: `transform: translateX(100%);`
}
`}
/>
<BoxButton
$minWidth={doc.abilities.versions_list ? '50%' : '100%'}
onClick={() => setIsPanelTableContentOpen(true)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Table of content')}
</Text>
</BoxButton>
{doc.abilities.versions_list && (
<BoxButton
$minWidth="50%"
onClick={() => setIsPanelTableContentOpen(false)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Versions')}
</Text>
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && (
<TableContent doc={doc} headings={headings} />
)}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
</Box>
</Card>
);
};
interface IconOpenPanelEditorProps {
headings: HeadingBlock[];
}
export const IconOpenPanelEditor = ({ headings }: IconOpenPanelEditorProps) => {
const { t } = useTranslation();
const { setIsPanelOpen, isPanelOpen, setIsPanelTableContentOpen } =
usePanelEditorStore();
const [hasBeenOpen, setHasBeenOpen] = useState(isPanelOpen);
const { isMobile } = useResponsiveStore();
const setClosePanel = () => {
setHasBeenOpen(true);
setIsPanelOpen(!isPanelOpen);
};
// Open the panel if there are more than 1 heading
useEffect(() => {
if (headings?.length && headings.length > 1 && !hasBeenOpen && !isMobile) {
setIsPanelTableContentOpen(true);
setIsPanelOpen(true);
setHasBeenOpen(true);
}
}, [
headings,
setIsPanelTableContentOpen,
setIsPanelOpen,
hasBeenOpen,
isMobile,
]);
// If open from the doc header we set the state as well
useEffect(() => {
if (isPanelOpen && !hasBeenOpen) {
setHasBeenOpen(true);
}
}, [hasBeenOpen, isPanelOpen]);
// Close the panel unmount
useEffect(() => {
return () => {
setIsPanelOpen(false);
};
}, [setIsPanelOpen]);
return (
<IconBG
iconName="menu_open"
aria-label={isPanelOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={10}
$hasTransition="slow"
$css={`
cursor: pointer;
right: 0rem;
top: 0.1rem;
transform: rotate(${isPanelOpen ? '180deg' : '0deg'});
user-select: none;
${hasBeenOpen ? 'display:flex;' : 'display: none;'}
`}
$position="absolute"
onClick={setClosePanel}
$radius="2px"
/>
);
};
@@ -0,0 +1,2 @@
export * from './useSaveDoc';
export * from './useUploadFile';
@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { useMediaUrl } from '@/core/config';
import { useCreateDocAttachment } from '../api';
export const useUploadFile = (docId: string) => {
const mediaUrl = useMediaUrl();
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const ret = await createDocAttachment({
docId,
body,
});
return `${mediaUrl}${ret.file}`;
},
[createDocAttachment, docId, mediaUrl],
);
return {
uploadFile,
isErrorAttachment,
errorAttachment,
};
};
@@ -1,3 +1,3 @@
export * from './useDocStore';
export * from './useEditorStore';
export * from './useHeadingStore';
export * from './usePanelEditorStore';
@@ -1,74 +0,0 @@
import { BlockNoteEditor } from '@blocknote/core';
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
import { providerUrl } from '@/core';
import { Base64, Doc } from '@/features/docs/doc-management';
import { blocksToYDoc } from '../utils';
interface DocStore {
provider: HocuspocusProvider;
editor?: BlockNoteEditor;
}
export interface UseDocStore {
currentDoc?: Doc;
docsStore: {
[storeId: string]: DocStore;
};
createProvider: (storeId: string, initialDoc: Base64) => HocuspocusProvider;
setStore: (storeId: string, props: Partial<DocStore>) => void;
setCurrentDoc: (doc: Doc | undefined) => void;
}
export const useDocStore = create<UseDocStore>((set, get) => ({
currentDoc: undefined,
docsStore: {},
createProvider: (storeId: string, initialDoc: Base64) => {
const doc = new Y.Doc({
guid: storeId,
});
if (initialDoc) {
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
} else {
const initialDocContent = [
{
type: 'heading',
content: '',
},
];
blocksToYDoc(initialDocContent, doc);
}
const provider = new HocuspocusProvider({
url: providerUrl(storeId),
name: storeId,
document: doc,
});
get().setStore(storeId, { provider });
return provider;
},
setStore: (storeId, props) => {
set(({ docsStore }, ...store) => {
return {
...store,
docsStore: {
...docsStore,
[storeId]: {
...docsStore[storeId],
...props,
},
},
};
});
},
setCurrentDoc: (doc) => {
set({ currentDoc: doc });
},
}));
@@ -0,0 +1,14 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
export interface UseEditorstore {
editor?: BlockNoteEditor;
setEditor: (editor: BlockNoteEditor | undefined) => void;
}
export const useEditorStore = create<UseEditorstore>((set) => ({
editor: undefined,
setEditor: (editor) => {
set({ editor });
},
}));
@@ -1,5 +1,3 @@
import * as Y from 'yjs';
export const randomColor = () => {
const randomInt = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
@@ -25,23 +23,5 @@ function hslToHex(h: number, s: number, l: number) {
return `#${f(0)}${f(8)}${f(4)}`;
}
export const toBase64 = (
str: WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>,
) => Buffer.from(str).toString('base64');
type BasicBlock = {
type: string;
content: string;
};
export const blocksToYDoc = (blocks: BasicBlock[], doc: Y.Doc) => {
const xmlFragment = doc.getXmlFragment('document-store');
blocks.forEach((block) => {
const xmlElement = new Y.XmlElement(block.type);
if (block.content) {
xmlElement.insert(0, [new Y.XmlText(block.content)]);
}
xmlFragment.push([xmlElement]);
});
};
export const toBase64 = (str: Uint8Array) =>
Buffer.from(str).toString('base64');
@@ -1,122 +1,99 @@
import React, { Fragment } from 'react';
import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Card, StyledLink, Text } from '@/components';
import { Box, Icon, Text } from '@/components';
import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
Role,
LinkReach,
currentDocRole,
useTrans,
} from '@/features/docs/doc-management';
import { Versions } from '@/features/docs/doc-versioning';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { DocTagPublic } from './DocTagPublic';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
interface DocHeaderProps {
doc: Doc;
versionId?: Versions['version_id'];
}
export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
const { colorsTokens } = useCunninghamTheme();
export const DocHeader = ({ doc }: DocHeaderProps) => {
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const spacings = spacingsTokens();
const colors = colorsTokens();
const { t } = useTranslation();
const { formatDate } = useDate();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const { transRole } = useTrans();
const { isMobile, isSmallMobile } = useResponsiveStore();
return (
<>
<Card
$margin={isMobile ? 'tiny' : 'small'}
<Box
$width="100%"
$padding={{ top: 'base' }}
$gap={spacings['base']}
aria-label={t('It is the card information about the document.')}
>
<Box
$padding={isMobile ? 'tiny' : 'small'}
$direction="row"
$align="center"
>
<StyledLink href="/">
<Text
$isMaterialIcon
$theme="primary"
$variation="600"
$size="2rem"
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
$hasTransition
$radius="5px"
$padding="tiny"
>
home
</Text>
</StyledLink>
{docIsPublic && (
<Box
$width="1px"
$height="70%"
$background={colorsTokens()['greyscale-100']}
$margin={{ horizontal: 'tiny' }}
/>
aria-label={t('Public document')}
$color={colors['primary-600']}
$background={colors['primary-100']}
$radius={spacings['3xs']}
$direction="row"
$padding="xs"
$flex={1}
$align="center"
$gap={spacings['3xs']}
$css={css`
border: 1px solid var(--c--theme--colors--primary-300, #e3e3fd);
`}
>
<Icon data-testid="public-icon" iconName="public" />
<Text>{t('Public document')}</Text>
</Box>
)}
<Box $direction="row" $align="center" $width="100%">
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$wrap="wrap"
$align="center"
>
<DocTitle doc={doc} />
<DocToolBox doc={doc} versionId={versionId} />
<Box $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">
{isDesktop && (
<>
<Text $variation="600" $size="s" $weight="bold">
{transRole(currentDocRole(doc.abilities))}&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{t('Last update: {{update}}', {
update: DateTime.fromISO(doc.updated_at).toRelative(),
})}
</Text>
</>
)}
{!isDesktop && (
<Text $variation="400" $size="s">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
)}
</Box>
</Box>
<DocToolBox doc={doc} />
</Box>
</Box>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$css="border-top:1px solid #eee"
$padding={{
horizontal: isMobile ? 'tiny' : 'big',
vertical: 'tiny',
}}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
$position="relative"
>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$gap="0.5rem 2rem"
$wrap="wrap"
>
<DocTagPublic doc={doc} />
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
</Text>
<Text $size="s" $display="inline" $elipsis $maxWidth="60vw">
{t('Owners:')}{' '}
<strong>
{doc.accesses
.filter(
(access) => access.role === Role.OWNER && access.user.email,
)
.map((access, index, accesses) => (
<Fragment key={`access-${index}`}>
{access.user.full_name || access.user.email}{' '}
{index < accesses.length - 1 ? ' / ' : ''}
</Fragment>
))}
</strong>
</Text>
</Box>
<Text $size="s" $display="inline">
{t('Your role:')}{' '}
<strong>{transRole(currentDocRole(doc.abilities))}</strong>
</Text>
</Box>
</Card>
</Box>
<HorizontalSeparator $withPadding={true} />
</>
);
};
@@ -5,12 +5,12 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useHeadingStore } from '@/features/docs/doc-editor';
import {
Doc,
KEY_DOC,
@@ -18,42 +18,48 @@ import {
useTrans,
useUpdateDoc,
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { isFirefox } from '@/utils/userAgent';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
interface DocTitleProps {
doc: Doc;
}
export const DocTitle = ({ doc }: DocTitleProps) => {
const { isMobile } = useResponsiveStore();
if (!doc.abilities.partial_update) {
return (
<Text
as="h2"
$margin={{ all: 'none', left: 'tiny' }}
$size={isMobile ? 'h4' : 'h2'}
>
{doc.title}
</Text>
);
return <DocTitleText title={doc.title} />;
}
return <DocTitleInput doc={doc} />;
};
interface DocTitleTextProps {
title: string;
}
export const DocTitleText = ({ title }: DocTitleTextProps) => {
const { isMobile } = useResponsiveStore();
return (
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
$variation="1000"
>
{title}
</Text>
);
};
const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const { toast } = useToastProvider();
const { untitledDocument } = useTrans();
const isUntitled = titleDisplay === untitledDocument;
const { headings } = useHeadingStore();
const headingText = headings?.[0]?.contentText;
const debounceRef = useRef<NodeJS.Timeout>();
const { isMobile } = useResponsiveStore();
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
@@ -61,6 +67,9 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
}
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${data.id}`);
},
});
@@ -77,10 +86,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
// If mutation we update
if (sanitizedTitle !== doc.title) {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
}
setTitleDisplay(sanitizedTitle);
updateDoc({ id: doc.id, title: sanitizedTitle });
}
},
@@ -94,69 +100,38 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}
};
const handleOnClick = () => {
if (isUntitled) {
setTitleDisplay('');
}
};
useEffect(() => {
if ((!debounceRef.current && !isUntitled) || !headingText) {
return;
}
setTitleDisplay(headingText);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleTitleSubmit(headingText);
debounceRef.current = undefined;
}, 3000);
}, [isUntitled, handleTitleSubmit, headingText]);
return (
<>
<Tooltip content={t('Rename')} placement="top">
<Box
as="h2"
$radius="4px"
$padding={{ horizontal: 'tiny', vertical: '4px' }}
$margin="none"
contentEditable={isFirefox() ? 'true' : 'plaintext-only'}
onClick={handleOnClick}
onBlurCapture={(e) =>
handleTitleSubmit(e.currentTarget.textContent || '')
}
as="span"
role="textbox"
contentEditable
defaultValue={isUntitled ? undefined : titleDisplay}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
$color={
isUntitled
? colorsTokens()['greyscale-200']
: colorsTokens()['greyscale-text']
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
$css={`
${isUntitled && 'font-style: italic;'}
cursor: text;
font-size: ${isMobile ? '1.2rem' : '1.5rem'};
transition: box-shadow 0.5s, border-color 0.5s;
border: 1px dashed transparent;
&:hover {
border-color: rgba(0, 123, 255, 0.25);
border-style: dashed;
$color={colorsTokens()['greyscale-1000']}
$margin={{ left: '-2px', right: '10px' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
&:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
outline: none;
`}
>
{titleDisplay}
{isUntitled ? '' : titleDisplay}
</Box>
</Tooltip>
</>
@@ -1,14 +1,26 @@
import {
Button,
VariantType,
useModal,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, DropButton, IconOptions } from '@/components';
import {
Box,
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
} from '@/components';
import { useAuthStore } from '@/core';
import { useDocStore, usePanelEditorStore } from '@/features/docs/doc-editor/';
import { useCunninghamTheme } from '@/cunningham';
import {
useEditorStore,
usePanelEditorStore,
} from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
@@ -16,32 +28,95 @@ import {
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { ModalVersion, Versions } from '../../doc-versioning';
import { ModalSelectVersion } from '../../doc-versioning/components/ModalSelectVersion';
import { ModalPDF } from './ModalExport';
interface DocToolBoxProps {
doc: Doc;
versionId?: Versions['version_id'];
}
export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const colors = colorsTokens();
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
const selectHistoryModal = useModal();
const { setIsPanelOpen, setIsPanelTableContentOpen } = usePanelEditorStore();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { isSmallMobile } = useResponsiveStore();
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { authenticated } = useAuthStore();
const { docsStore } = useDocStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'upload',
callback: () => {
setIsModalShareOpen(true);
},
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalPDFOpen(true);
},
},
]
: []),
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
selectHistoryModal.open();
},
show: isDesktop,
},
{
label: t('Table of contents'),
icon: 'summarize',
callback: () => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(true);
},
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
const copyCurrentEditorToClipboard = async (
asFormat: 'html' | 'markdown',
) => {
const editor = docsStore[doc.id]?.editor;
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
return;
@@ -70,22 +145,10 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
$gap="0.5rem 1.5rem"
$wrap={isSmallMobile ? 'wrap' : 'nowrap'}
>
{versionId && (
<Box $margin={{ left: 'auto' }}>
<Button
onClick={() => {
setIsModalVersionOpen(true);
}}
color="secondary"
size={isSmallMobile ? 'small' : 'medium'}
>
{t('Restore this version')}
</Button>
</Box>
)}
<Box $direction="row" $margin={{ left: 'auto' }} $gap="1rem">
{authenticated && (
<Box $direction="row" $margin={{ left: 'auto' }} $gap={spacings['2xs']}>
{authenticated && !isSmallMobile && (
<Button
color="tertiary-text"
onClick={() => {
setIsModalShareOpen(true);
}}
@@ -94,91 +157,38 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
{t('Share')}
</Button>
)}
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the document options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
{doc.abilities.versions_list && (
<Button
onClick={() => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(false);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">history</span>}
size="small"
>
{t('Version history')}
</Button>
)}
<Button
onClick={() => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">summarize</span>}
size="small"
>
{t('Table of contents')}
</Button>
<Button
onClick={() => {
setIsModalPDFOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">file_download</span>}
size="small"
>
{t('Export')}
</Button>
{doc.abilities.destroy && (
<Button
onClick={() => {
setIsModalRemoveOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">delete</span>}
size="small"
>
{t('Delete document')}
</Button>
)}
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('markdown');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'Markdown' })}
</Button>
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('html');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'HTML' })}
</Button>
</Box>
</DropButton>
{!isSmallMobile && (
<Button
color="tertiary-text"
icon={
<Icon iconName="download" $theme="primary" $variation="800" />
}
onClick={() => {
setIsModalPDFOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
/>
)}
<DropdownMenu options={options}>
<IconOptions
isHorizontal
$theme="primary"
$radius={spacings['3xs']}
$padding={{ all: 'xs' }}
$css={css`
&:hover {
background-color: ${colors['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colors['greyscale-300']};
`
: ''}
`}
aria-label={t('Open the document options')}
/>
</DropdownMenu>
</Box>
{isModalShareOpen && (
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />
@@ -189,11 +199,10 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
{isModalVersionOpen && versionId && (
<ModalVersion
onClose={() => setIsModalVersionOpen(false)}
docId={doc.id}
versionId={versionId}
{selectHistoryModal.isOpen && (
<ModalSelectVersion
onClose={() => selectHistoryModal.close()}
doc={doc}
/>
)}
</Box>
@@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator';
import { useCunninghamTheme } from '@/cunningham';
import { DocTitleText } from './DocTitle';
interface DocVersionHeaderProps {
title: string;
}
export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => {
const { spacingsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const { t } = useTranslation();
return (
<>
<Box
$width="100%"
$padding={{ vertical: 'base' }}
$gap={spacings['base']}
aria-label={t('It is the document title')}
>
<DocTitleText title={title} />
<HorizontalSeparator />
</Box>
</>
);
};

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