Compare commits

..

44 Commits

Author SHA1 Message Date
Nathan Panchout ea1a42750f (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-11-25 14:11:41 +01:00
Nathan Panchout 03d1b9dd13 (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-11-25 14:11:41 +01:00
Nathan Panchout 65120133fb 💄(frontend) add dropdown option for DocGridItem
Implement dropdown menu with functionality to delete a document
from the list
2024-11-25 09:57:36 +01:00
Nathan Panchout 026299639b (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-11-25 09:39:29 +01:00
Nathan Panchout 893f686270 💄(frontend) update DocsGrid component
Implement the new version of  the DocsGrid  component
2024-11-25 09:39:27 +01:00
Nathan Panchout 0bbc023323 🔧(frontend) update cunningham configuration
- update primary colors,and spacing.
- update tertiary button
2024-11-25 09:37:53 +01:00
Nathan Panchout 179a156233 (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-11-25 09:37:53 +01:00
Nathan Panchout 91592308ed 💄(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-11-25 09:37:53 +01:00
Nathan Panchout 9eec58981d 🔥(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-11-25 09:37:53 +01:00
Nathan Panchout 072911e942 💄(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-11-25 09:37:53 +01:00
Anthony LC e89289ca9a build image with main-new-ui branch 2024-11-25 09:37:53 +01:00
Nathan Panchout bdf4a2ab17 (frontend) update tests
Some minor changes have been integrated into the list of documents.
The tests must therefore be adapted accordingly.
2024-11-25 09:37:53 +01:00
Nathan Panchout 9de357a596 💄(frontend) add left panel
In the new interface there is a new left panel. We implement it and add it
to the MainLayout
2024-11-25 09:37:53 +01:00
Nathan Panchout 5353520035 💄(frontend) add cunningham tokens
In order to use the spaces and grays of the DSFR,
we update the cunningham.ts file
2024-11-25 09:37:53 +01:00
Nathan Panchout da5882a8aa (frontend) implement new UI
This branch is a transition branch to gradually merge the new UI.
2024-11-25 09:37:50 +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
Anthony LC bc14d1d0f8 🐛(editor) collaborative user tag hidden when read only
When the user was in read-only mode, the user
tag could be displayed when they were touching the
doc. This commit fixes this issue.
We add the full name instead of the email in the
cursor tag.
2024-11-08 12:01:23 +01:00
Anthony LC 526e649f06 🦺(backend) add comma to sub regex
Some sub have comma, the regex was a bit too strict
and didn't allow it, this commit fixes that.
2024-11-08 10:53:53 +01:00
Anthony LC ac40eb8f7c 🌐(frontend) add German translation
- Add the german translation to Docs
- Add the german language to the frontend
language picker
2024-11-07 15:58:49 +01:00
lindenb1 c750cf10a8 🌐(backend) adding de_DE translation for the backend
This adds German translation to the backend and
adjusts the .po file sequence by priority.

Signed-off-by: lindenb1 <linden@b1-systems.de>
2024-11-07 11:49:41 +01:00
Samuel Paccoud - DINUM 4f4951cdcd 🚸(backend) improve users similarity search and sort results
In some edge cases, the domain part the email addresse is
longer than the name part. Users searches by email similarity
then return a lot of unsorted results.

We can improve this by being more demanding on similarity when
the query looks like an email. Sorting results by the similarity
score is also an obvious improvement.

At the moment, we still think it is good to propose results with
a weak similarity on the name part because we want to avoid
as much as possible creating duplicate users by inviting one of
is many emails, a user who is already in our database.

Fixes 399
2024-11-06 08:27:18 +01:00
Anthony LC 50891afd05 🔖(minor) release 1.7.0
Added:
- 📝Contributing.md
- 🌐(frontend) add localization to editor
- Public and restricted doc editable
- (frontend) Add full name if available
- (backend) Add view accesses ability

Changed:
- ♻️(frontend) avoid documents indexing in search engine
- ♻️(frontend) list accesses if user has abilities
- 👔(backend) doc restricted by default

Fixed:
- 🐛(backend) require right to manage document
  accesses to see invitations
- 🐛(i18n) same frontend and backend language using
  shared cookies
- 🐛(frontend) add default toolbar buttons
- 🐛(frontend) throttle error correctly display

Removed:
- 🔥(helm) remove infra related codes
2024-10-25 14:41:48 +02:00
Anthony LC cbb6fc740a 👔(backend) doc restricted by default
By default a created document was in "authenticated"
mode, we switch to "restricted" by default.
2024-10-25 14:25:48 +02:00
Anthony LC 31c3dd6119 🛂(frontend) show member list depend ability
We integrate the new ability "accesses_view" that
tells if a user can view the accesses of a document.
2024-10-24 17:31:34 +02:00
Samuel Paccoud - DINUM 15700ddd8d (backend) add new ability on document "accesses_view"
We need this ability in the frontend to know whether we should try
to display the list of users who have document accesses. If this
ability is False (e.g for anonymous users), we should only show
the link reach and link role when clicking on the "Share" button.
2024-10-24 17:31:34 +02:00
153 changed files with 7619 additions and 3585 deletions
+4
View File
@@ -6,8 +6,12 @@ on:
push:
branches:
- 'main'
- 'main-new-ui'
tags:
- 'v*'
pull_request:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
+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"
+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]
+32
View File
@@ -9,6 +9,35 @@ and this project adheres to
## [Unreleased]
## Added
- 🌐(backend) add german translation #259
- 🌐(frontend) Add German translation #255
- ✨(frontend) Add a broadcast store #387
- ✨(frontend) WIP: New ui
- 💄(frontend) Add left panel #420
- 💄(frontend) updating the header and leftpanel for responsive #421
- ✨(backend) config endpoint #425
- 💄(frontend) update DocsGrid component #431
- ✨(backend) whitelist pod's IP address #443
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
## Changed
- 🚸(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
## 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
## [1.7.0] - 2024-10-24
## Added
@@ -17,10 +46,13 @@ and this project adheres to
- 🌐(frontend) add localization to editor #368
- ✨Public and restricted doc editable #357
- ✨(frontend) Add full name if available #380
- ✨(backend) Add view accesses ability #376
## Changed
- ♻️(frontend) list accesses if user has abilities #376
- ♻️(frontend) avoid documents indexing in search engine #372
- 👔(backend) doc restricted by default #388
## Fixed
+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
+43
View File
@@ -6,6 +6,7 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
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 (
@@ -25,11 +26,13 @@ from rest_framework import (
mixins,
pagination,
status,
views,
viewsets,
)
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
@@ -156,8 +159,21 @@ class UserViewSet(
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
# For performance reasons we filter first by similarity, which relies on an index,
# then only calculate precise similarity scores for sorting purposes
queryset = queryset.filter(email__trigram_word_similar=query)
queryset = queryset.annotate(
similarity=TrigramSimilarity("email", query)
)
# When the query only is on the name part, we should try to make many proposals
# But when the query looks like an email we should only propose serious matches
threshold = 0.6 if "@" in query else 0.1
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity"
)
return queryset
@decorators.action(
@@ -872,3 +888,30 @@ 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",
"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)
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-10-25 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]
+11 -9
View File
@@ -130,17 +130,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
@@ -336,7 +336,7 @@ class Document(BaseModel):
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.AUTHENTICATED,
default=LinkReachChoices.RESTRICTED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
@@ -496,7 +496,8 @@ class Document(BaseModel):
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
can_get_versions = bool(roles)
# Anonymous users should also not see document accesses
has_role = bool(roles)
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
@@ -511,19 +512,20 @@ class Document(BaseModel):
can_get = bool(roles)
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"ai_transform": is_owner_or_admin or is_editor,
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"destroy": RoleChoices.OWNER in roles,
"link_configuration": is_owner_or_admin,
"manage_accesses": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, sender):
@@ -679,7 +681,7 @@ class Template(BaseModel):
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"manage_accesses": is_owner_or_admin,
"accesses_manage": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
@@ -47,6 +47,7 @@ def test_api_documents_create_authenticated_success():
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.link_reach == "restricted"
assert document.accesses.filter(role="owner", user=user).exists()
@@ -21,13 +21,14 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"destroy": False,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -78,13 +79,14 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"destroy": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -22,7 +22,7 @@ def test_api_templates_retrieve_anonymous_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -68,7 +68,7 @@ def test_api_templates_retrieve_authenticated_unrelated_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
+63
View File
@@ -0,0 +1,63 @@
"""
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/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
SENTRY_DSN="https://sentry.test/123",
)
def test_api_config_anonymous():
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"COLLABORATION_SERVER_URL": "http://testcollab/",
"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",
}
@override_settings(
COLLABORATION_SERVER_URL="http://testcollab/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
SENTRY_DSN="https://sentry.test/123",
)
def test_api_config_authenticated():
"""Authenticated users should be allowed to get the configuration."""
user = factories.UserFactory()
client = APIClient()
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/",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"MEDIA_BASE_URL": "http://testserver/",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"SENTRY_DSN": "https://sentry.test/123",
}
+42
View File
@@ -69,6 +69,48 @@ def test_api_users_list_query_email():
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by similarity"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="jane.smith@example.gouv.fr")
michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
factories.UserFactory(email="david.jones@example.gouv.fr")
michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
factories.UserFactory(email="sophia.taylor@example.gouv.fr")
response = client.get(
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
response = client.get(
"/api/v1.0/users/?q=ajohnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(alice.id)]
response = client.get(
"/api/v1.0/users/?q=michael.wilson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users
@@ -83,13 +83,14 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"link_configuration": False,
"destroy": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": False,
"update": False,
@@ -116,13 +117,14 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -149,13 +151,14 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -171,13 +174,14 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": True,
"link_configuration": True,
"invite_owner": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -192,13 +196,14 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": True,
"invite_owner": False,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -216,13 +221,14 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -242,13 +248,14 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -269,13 +276,14 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"invite_owner": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -62,7 +62,7 @@ def test_models_templates_get_abilities_anonymous_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -76,7 +76,7 @@ def test_models_templates_get_abilities_anonymous_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -90,7 +90,7 @@ def test_models_templates_get_abilities_authenticated_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -104,7 +104,7 @@ def test_models_templates_get_abilities_authenticated_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -119,7 +119,7 @@ def test_models_templates_get_abilities_owner():
"destroy": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -133,7 +133,7 @@ def test_models_templates_get_abilities_administrator():
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -150,7 +150,7 @@ def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": True,
"generate_document": True,
}
@@ -167,7 +167,7 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -185,7 +185,7 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
+1
View File
@@ -55,4 +55,5 @@ urlpatterns = [
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]
+65 -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
@@ -237,6 +234,7 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
)
)
@@ -371,7 +369,17 @@ 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
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -481,6 +489,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):
@@ -576,23 +620,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",
]
@@ -623,7 +650,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
Binary file not shown.
@@ -0,0 +1,349 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:21\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
msgid "Personal info"
msgstr "Persönliche Angaben"
#: core/admin.py:34
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:46
msgid "Important dates"
msgstr "Wichtige Termine"
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Leser"
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Bearbeiter"
#: core/models.py:71
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:72
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:80
msgid "Restricted"
msgstr "Eingeschränkt"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:86
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
msgid "identity email address"
msgstr ""
#: core/models.py:153
msgid "admin email address"
msgstr ""
#: core/models.py:160
msgid "language"
msgstr ""
#: core/models.py:161
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
msgid "device"
msgstr ""
#: core/models.py:172
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
msgid "staff status"
msgstr ""
#: core/models.py:177
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
msgid "active"
msgstr ""
#: core/models.py:183
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
msgid "user"
msgstr ""
#: core/models.py:196
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
msgid "title"
msgstr ""
#: core/models.py:343
msgid "Document"
msgstr ""
#: core/models.py:344
msgid "Documents"
msgstr ""
#: core/models.py:347
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt: %(document)s"
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
msgid "description"
msgstr ""
#: core/models.py:646
msgid "code"
msgstr ""
#: core/models.py:647
msgid "css"
msgstr ""
#: core/models.py:649
msgid "public"
msgstr ""
#: core/models.py:651
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
msgid "Template"
msgstr ""
#: core/models.py:658
msgid "Templates"
msgstr ""
#: core/models.py:797
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
msgid "email address"
msgstr ""
#: core/models.py:850
msgid "Document invitation"
msgstr ""
#: core/models.py:851
msgid "Document invitations"
msgstr ""
#: core/models.py:868
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s hat ein Dokument mit Ihnen geteilt! "
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgstr " %(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen: "
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr " Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und Zusammenarbeiten an Dokumenten im Team. "
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Bereitgestellt von La Suite Numérique"
#: core/templates/mail/html/invitation2.html:190
#, python-format
msgid "%(username)s shared a document with you"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt"
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr "%(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen:"
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr "Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und gemeinsamen Arbeiten an Dokumenten im Team."
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""
@@ -345,11 +345,14 @@ msgstr ""
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""
@@ -345,11 +345,14 @@ msgstr "Proposé par La Suite Numérique"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""
+3 -2
View File
@@ -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",
@@ -127,6 +127,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"]
@@ -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).toHaveValue(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();
@@ -144,7 +149,7 @@ export const mockedDocument = async (page: Page, json: object) => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -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();
@@ -215,7 +222,7 @@ test.describe('Doc Editor', () => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -3,7 +3,7 @@ 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('/');
@@ -20,7 +20,7 @@ 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');
@@ -57,7 +57,7 @@ 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');
@@ -97,7 +97,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();
@@ -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 } });
@@ -303,7 +53,7 @@ test.describe('Documents Grid mobile', () => {
attachment_upload: true,
destroy: true,
link_configuration: true,
manage_accesses: true,
accesses_manage: true,
partial_update: true,
retrieve: true,
update: true,
@@ -326,19 +76,183 @@ 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 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 }) => {
@@ -45,7 +46,7 @@ test.describe('Doc Header', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,
@@ -59,84 +60,30 @@ 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();
await expect(card.getByPlaceholder('Untitled document')).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 +106,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')
@@ -177,12 +122,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if administrator', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: true, // Means admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
@@ -194,16 +140,12 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
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 } });
@@ -247,12 +189,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if editor', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
@@ -271,16 +214,12 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
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 } });
@@ -324,12 +263,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if reader', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -348,16 +288,12 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).not.toHaveAttribute('contenteditable');
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 } });
@@ -411,7 +347,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -446,7 +382,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -489,7 +425,7 @@ test.describe('Documents Header mobile', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,
@@ -498,6 +434,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();
@@ -126,8 +126,9 @@ 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();
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(docTitle);
await page.getByRole('button', { name: 'Share' }).click();
@@ -188,7 +189,9 @@ 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();
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(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();
@@ -17,7 +17,9 @@ test.describe('Doc Table Content', () => {
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
await page.getByLabel('Open the document options').click();
await page
@@ -36,6 +38,7 @@ test.describe('Doc Table Content', () => {
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
@@ -107,7 +110,9 @@ test.describe('Doc Table Content', () => {
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
await expect(page.getByLabel('Open the panel')).toBeHidden();
const editor = page.locator('.ProseMirror');
@@ -10,7 +10,9 @@ 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();
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
await page.getByLabel('Open the document options').click();
await page
@@ -79,12 +81,14 @@ test.describe('Doc Version', () => {
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue('Mocked document');
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Version history' }),
).toBeHidden();
).toBeDisabled();
await page.getByRole('button', { name: 'Table of content' }).click();
@@ -95,12 +99,14 @@ test.describe('Doc Version', () => {
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();
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
await page.locator('.bn-block-outer').last().click();
await page.locator('.bn-block-outer').last().fill('Hello');
expect(true).toBe(true);
await goToGridDoc(page, {
title: randomDoc,
});
@@ -152,7 +158,9 @@ test.describe('Doc Version', () => {
}) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().click();
@@ -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'];
@@ -40,20 +40,20 @@ test.describe('Doc Visibility', () => {
name: 'Visibility',
});
await expect(selectVisibility.getByText('Authenticated')).toBeVisible();
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Restricted',
name: 'Authenticated',
})
.click();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
await selectVisibility.click();
@@ -85,27 +85,7 @@ test.describe('Doc Visibility: Restricted', () => {
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Restricted',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await verifyDocName(page, docTitle);
const urlDoc = page.url();
@@ -131,27 +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 page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Restricted',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await verifyDocName(page, docTitle);
const urlDoc = page.url();
@@ -179,23 +139,9 @@ 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();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Restricted',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
@@ -230,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();
});
});
@@ -252,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
@@ -281,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();
@@ -315,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
@@ -344,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();
@@ -362,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'),
@@ -387,7 +341,27 @@ 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
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
@@ -419,7 +393,27 @@ 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
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
@@ -435,10 +429,20 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
test('It checks a authenticated doc in editable mode', async ({
@@ -455,7 +459,27 @@ 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
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
@@ -482,10 +506,20 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
});
@@ -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,15 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
page.getByRole('button', {
name: 'Créer un nouveau document',
}),
header.getByRole('combobox').getByText('Deutsch'),
).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();
});
});
+1 -1
View File
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.48.1",
"@playwright/test": "1.48.2",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
+66 -35
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': {
@@ -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',
@@ -297,9 +328,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: {
@@ -363,7 +394,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 +412,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)',
+14 -13
View File
@@ -21,35 +21,36 @@
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.7",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.59.15",
"i18next": "23.16.2",
"@tanstack/react-query": "5.60.5",
"i18next": "23.16.5",
"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-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.60.5",
"@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 +64,7 @@
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.95.0",
"workbox-webpack-plugin": "7.1.0"
"webpack": "5.96.1",
"workbox-webpack-plugin": "7.3.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();
});
});
@@ -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,112 @@
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;
};
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) => {
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>
);
};
@@ -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,31 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$height="1px"
$width="100%"
$margin={{ vertical: 'base' }}
$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>
);
};
@@ -351,6 +351,11 @@ input:-webkit-autofill:focus {
background-color: transparent;
}
.c__button--nano {
padding: 0 var(--c--theme--spacings--2xs);
gap: var(--c--theme--spacings--2xs);
}
.c__button--medium {
padding: 0.9rem var(--c--theme--spacings--s);
}
@@ -442,6 +447,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 +460,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
@@ -71,19 +71,26 @@
--c--theme--colors--danger-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--card-border: #ededed;
--c--theme--colors--primary-bg: #fafafa;
--c--theme--colors--primary-050: #f5f5fe;
--c--theme--colors--primary-150: #e5eefa;
--c--theme--colors--primary-950: #1b1b35;
--c--theme--colors--info-150: #e5eefa;
--c--theme--font--sizes--h1: 2.2rem;
--c--theme--font--sizes--h2: 1.7rem;
--c--theme--font--sizes--h3: 1.37rem;
--c--theme--font--sizes--h4: 1.15rem;
--c--theme--font--sizes--h5: 1rem;
--c--theme--font--sizes--h6: 0.87rem;
--c--theme--colors--greyscale-1000: #161616;
--c--theme--font--sizes--h1: 2rem;
--c--theme--font--sizes--h2: 1.75rem;
--c--theme--font--sizes--h3: 1.5rem;
--c--theme--font--sizes--h4: 1.375rem;
--c--theme--font--sizes--h5: 1.25rem;
--c--theme--font--sizes--h6: 1.125rem;
--c--theme--font--sizes--l: 1rem;
--c--theme--font--sizes--m: 0.8125rem;
--c--theme--font--sizes--s: 0.75rem;
--c--theme--font--sizes--xs: 0.75rem;
--c--theme--font--sizes--sm: 0.875rem;
--c--theme--font--sizes--md: 1rem;
--c--theme--font--sizes--lg: 1.125rem;
--c--theme--font--sizes--ml: 0.938rem;
--c--theme--font--sizes--xl: 1.5rem;
--c--theme--font--sizes--xl: 1.25rem;
--c--theme--font--sizes--t: 0.6875rem;
--c--theme--font--weights--thin: 100;
--c--theme--font--weights--light: 300;
@@ -104,7 +111,7 @@
--c--theme--font--letterspacings--m: normal;
--c--theme--font--letterspacings--s: normal;
--c--theme--spacings--0: 0;
--c--theme--spacings--xl: 4rem;
--c--theme--spacings--xl: 2.5rem;
--c--theme--spacings--l: 3rem;
--c--theme--spacings--b: 1.625rem;
--c--theme--spacings--s: 1rem;
@@ -114,6 +121,20 @@
--c--theme--spacings--auto: auto;
--c--theme--spacings--bx: 2.2rem;
--c--theme--spacings--full: 100%;
--c--theme--spacings--4xs: 0.125rem;
--c--theme--spacings--3xs: 0.25rem;
--c--theme--spacings--2xs: 0.375rem;
--c--theme--spacings--xs: 0.5rem;
--c--theme--spacings--sm: 0.75rem;
--c--theme--spacings--base: 1rem;
--c--theme--spacings--md: 1.5rem;
--c--theme--spacings--lg: 2rem;
--c--theme--spacings--xxl: 3rem;
--c--theme--spacings--xxxl: 3.5rem;
--c--theme--spacings--4xl: 4rem;
--c--theme--spacings--5xl: 4.5rem;
--c--theme--spacings--6xl: 6rem;
--c--theme--spacings--7xl: 7.5rem;
--c--theme--transitions--ease-in: cubic-bezier(0.32, 0, 0.67, 0);
--c--theme--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--c--theme--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
@@ -275,7 +296,13 @@
--c--components--button--tertiary--color-disabled: var(
--c--theme--colors--greyscale-600
);
--c--components--button--tertiary--background--color: var(
--c--theme--colors--primary-100
);
--c--components--button--tertiary--background--color-hover: var(
--c--theme--colors--primary-300
);
--c--components--button--tertiary--background--color-active: var(
--c--theme--colors--primary-100
);
--c--components--button--tertiary--background--color-disabled: var(
@@ -336,18 +363,18 @@
}
.cunningham-theme--dsfr {
--c--theme--colors--card-border: #ededed;
--c--theme--colors--card-border: #e5e5e5;
--c--theme--colors--primary-text: #000091;
--c--theme--colors--primary-100: #f5f5fe;
--c--theme--colors--primary-100: #ececfe;
--c--theme--colors--primary-150: #f4f4fd;
--c--theme--colors--primary-200: #ececfe;
--c--theme--colors--primary-300: #e3e3fd;
--c--theme--colors--primary-400: #cacafb;
--c--theme--colors--primary-200: #e3e3fd;
--c--theme--colors--primary-300: #cacafb;
--c--theme--colors--primary-400: #8585f6;
--c--theme--colors--primary-500: #6a6af4;
--c--theme--colors--primary-600: #000091;
--c--theme--colors--primary-600: #313178;
--c--theme--colors--primary-700: #272747;
--c--theme--colors--primary-800: #21213f;
--c--theme--colors--primary-900: #1c1a36;
--c--theme--colors--primary-800: #000091;
--c--theme--colors--primary-900: #21213f;
--c--theme--colors--secondary-text: #fff;
--c--theme--colors--secondary-100: #fee9ea;
--c--theme--colors--secondary-200: #fedfdf;
@@ -359,16 +386,22 @@
--c--theme--colors--secondary-800: #341f1f;
--c--theme--colors--secondary-900: #2b1919;
--c--theme--colors--greyscale-text: #303c4b;
--c--theme--colors--greyscale-000: #f6f6f6;
--c--theme--colors--greyscale-000: #fff;
--c--theme--colors--greyscale-050: #f6f6f6;
--c--theme--colors--greyscale-100: #eee;
--c--theme--colors--greyscale-200: #e5e5e5;
--c--theme--colors--greyscale-300: #e1e1e1;
--c--theme--colors--greyscale-400: #ddd;
--c--theme--colors--greyscale-500: #cecece;
--c--theme--colors--greyscale-600: #7b7b7b;
--c--theme--colors--greyscale-700: #666;
--c--theme--colors--greyscale-250: #ddd;
--c--theme--colors--greyscale-300: #cecece;
--c--theme--colors--greyscale-350: #ddd;
--c--theme--colors--greyscale-400: #929292;
--c--theme--colors--greyscale-500: #7c7c7c;
--c--theme--colors--greyscale-600: #666;
--c--theme--colors--greyscale-700: #3a3a3a;
--c--theme--colors--greyscale-750: #353535;
--c--theme--colors--greyscale-800: #2a2a2a;
--c--theme--colors--greyscale-900: #1e1e1e;
--c--theme--colors--greyscale-900: #242424;
--c--theme--colors--greyscale-950: #1e1e1e;
--c--theme--colors--greyscale-1000: #161616;
--c--theme--colors--success-text: #1f8d49;
--c--theme--colors--success-100: #dffee6;
--c--theme--colors--success-200: #b8fec9;
@@ -811,14 +844,26 @@
color: var(--c--theme--colors--primary-bg);
}
.clr-primary-050 {
color: var(--c--theme--colors--primary-050);
}
.clr-primary-150 {
color: var(--c--theme--colors--primary-150);
}
.clr-primary-950 {
color: var(--c--theme--colors--primary-950);
}
.clr-info-150 {
color: var(--c--theme--colors--info-150);
}
.clr-greyscale-1000 {
color: var(--c--theme--colors--greyscale-1000);
}
.bg-secondary-text {
background-color: var(--c--theme--colors--secondary-text);
}
@@ -1107,14 +1152,26 @@
background-color: var(--c--theme--colors--primary-bg);
}
.bg-primary-050 {
background-color: var(--c--theme--colors--primary-050);
}
.bg-primary-150 {
background-color: var(--c--theme--colors--primary-150);
}
.bg-primary-950 {
background-color: var(--c--theme--colors--primary-950);
}
.bg-info-150 {
background-color: var(--c--theme--colors--info-150);
}
.bg-greyscale-1000 {
background-color: var(--c--theme--colors--greyscale-1000);
}
.fw-thin {
font-weight: var(--c--theme--font--weights--thin);
}
@@ -1188,6 +1245,26 @@
letter-spacing: var(--c--theme--font--letterspacings--s);
}
.fs-xs {
font-size: var(--c--theme--font--sizes--xs);
letter-spacing: var(--c--theme--font--letterspacings--xs);
}
.fs-sm {
font-size: var(--c--theme--font--sizes--sm);
letter-spacing: var(--c--theme--font--letterspacings--sm);
}
.fs-md {
font-size: var(--c--theme--font--sizes--md);
letter-spacing: var(--c--theme--font--letterspacings--md);
}
.fs-lg {
font-size: var(--c--theme--font--sizes--lg);
letter-spacing: var(--c--theme--font--letterspacings--lg);
}
.fs-ml {
font-size: var(--c--theme--font--sizes--ml);
letter-spacing: var(--c--theme--font--letterspacings--ml);
@@ -1431,6 +1508,286 @@
margin-right: var(--c--theme--spacings--full);
}
.m-4xs {
margin: var(--c--theme--spacings--4xs);
}
.mb-4xs {
margin-bottom: var(--c--theme--spacings--4xs);
}
.mt-4xs {
margin-top: var(--c--theme--spacings--4xs);
}
.ml-4xs {
margin-left: var(--c--theme--spacings--4xs);
}
.mr-4xs {
margin-right: var(--c--theme--spacings--4xs);
}
.m-3xs {
margin: var(--c--theme--spacings--3xs);
}
.mb-3xs {
margin-bottom: var(--c--theme--spacings--3xs);
}
.mt-3xs {
margin-top: var(--c--theme--spacings--3xs);
}
.ml-3xs {
margin-left: var(--c--theme--spacings--3xs);
}
.mr-3xs {
margin-right: var(--c--theme--spacings--3xs);
}
.m-2xs {
margin: var(--c--theme--spacings--2xs);
}
.mb-2xs {
margin-bottom: var(--c--theme--spacings--2xs);
}
.mt-2xs {
margin-top: var(--c--theme--spacings--2xs);
}
.ml-2xs {
margin-left: var(--c--theme--spacings--2xs);
}
.mr-2xs {
margin-right: var(--c--theme--spacings--2xs);
}
.m-xs {
margin: var(--c--theme--spacings--xs);
}
.mb-xs {
margin-bottom: var(--c--theme--spacings--xs);
}
.mt-xs {
margin-top: var(--c--theme--spacings--xs);
}
.ml-xs {
margin-left: var(--c--theme--spacings--xs);
}
.mr-xs {
margin-right: var(--c--theme--spacings--xs);
}
.m-sm {
margin: var(--c--theme--spacings--sm);
}
.mb-sm {
margin-bottom: var(--c--theme--spacings--sm);
}
.mt-sm {
margin-top: var(--c--theme--spacings--sm);
}
.ml-sm {
margin-left: var(--c--theme--spacings--sm);
}
.mr-sm {
margin-right: var(--c--theme--spacings--sm);
}
.m-base {
margin: var(--c--theme--spacings--base);
}
.mb-base {
margin-bottom: var(--c--theme--spacings--base);
}
.mt-base {
margin-top: var(--c--theme--spacings--base);
}
.ml-base {
margin-left: var(--c--theme--spacings--base);
}
.mr-base {
margin-right: var(--c--theme--spacings--base);
}
.m-md {
margin: var(--c--theme--spacings--md);
}
.mb-md {
margin-bottom: var(--c--theme--spacings--md);
}
.mt-md {
margin-top: var(--c--theme--spacings--md);
}
.ml-md {
margin-left: var(--c--theme--spacings--md);
}
.mr-md {
margin-right: var(--c--theme--spacings--md);
}
.m-lg {
margin: var(--c--theme--spacings--lg);
}
.mb-lg {
margin-bottom: var(--c--theme--spacings--lg);
}
.mt-lg {
margin-top: var(--c--theme--spacings--lg);
}
.ml-lg {
margin-left: var(--c--theme--spacings--lg);
}
.mr-lg {
margin-right: var(--c--theme--spacings--lg);
}
.m-xxl {
margin: var(--c--theme--spacings--xxl);
}
.mb-xxl {
margin-bottom: var(--c--theme--spacings--xxl);
}
.mt-xxl {
margin-top: var(--c--theme--spacings--xxl);
}
.ml-xxl {
margin-left: var(--c--theme--spacings--xxl);
}
.mr-xxl {
margin-right: var(--c--theme--spacings--xxl);
}
.m-xxxl {
margin: var(--c--theme--spacings--xxxl);
}
.mb-xxxl {
margin-bottom: var(--c--theme--spacings--xxxl);
}
.mt-xxxl {
margin-top: var(--c--theme--spacings--xxxl);
}
.ml-xxxl {
margin-left: var(--c--theme--spacings--xxxl);
}
.mr-xxxl {
margin-right: var(--c--theme--spacings--xxxl);
}
.m-4xl {
margin: var(--c--theme--spacings--4xl);
}
.mb-4xl {
margin-bottom: var(--c--theme--spacings--4xl);
}
.mt-4xl {
margin-top: var(--c--theme--spacings--4xl);
}
.ml-4xl {
margin-left: var(--c--theme--spacings--4xl);
}
.mr-4xl {
margin-right: var(--c--theme--spacings--4xl);
}
.m-5xl {
margin: var(--c--theme--spacings--5xl);
}
.mb-5xl {
margin-bottom: var(--c--theme--spacings--5xl);
}
.mt-5xl {
margin-top: var(--c--theme--spacings--5xl);
}
.ml-5xl {
margin-left: var(--c--theme--spacings--5xl);
}
.mr-5xl {
margin-right: var(--c--theme--spacings--5xl);
}
.m-6xl {
margin: var(--c--theme--spacings--6xl);
}
.mb-6xl {
margin-bottom: var(--c--theme--spacings--6xl);
}
.mt-6xl {
margin-top: var(--c--theme--spacings--6xl);
}
.ml-6xl {
margin-left: var(--c--theme--spacings--6xl);
}
.mr-6xl {
margin-right: var(--c--theme--spacings--6xl);
}
.m-7xl {
margin: var(--c--theme--spacings--7xl);
}
.mb-7xl {
margin-bottom: var(--c--theme--spacings--7xl);
}
.mt-7xl {
margin-top: var(--c--theme--spacings--7xl);
}
.ml-7xl {
margin-left: var(--c--theme--spacings--7xl);
}
.mr-7xl {
margin-right: var(--c--theme--spacings--7xl);
}
.p-0 {
padding: var(--c--theme--spacings--0);
}
@@ -1650,3 +2007,283 @@
.pr-full {
padding-right: var(--c--theme--spacings--full);
}
.p-4xs {
padding: var(--c--theme--spacings--4xs);
}
.pb-4xs {
padding-bottom: var(--c--theme--spacings--4xs);
}
.pt-4xs {
padding-top: var(--c--theme--spacings--4xs);
}
.pl-4xs {
padding-left: var(--c--theme--spacings--4xs);
}
.pr-4xs {
padding-right: var(--c--theme--spacings--4xs);
}
.p-3xs {
padding: var(--c--theme--spacings--3xs);
}
.pb-3xs {
padding-bottom: var(--c--theme--spacings--3xs);
}
.pt-3xs {
padding-top: var(--c--theme--spacings--3xs);
}
.pl-3xs {
padding-left: var(--c--theme--spacings--3xs);
}
.pr-3xs {
padding-right: var(--c--theme--spacings--3xs);
}
.p-2xs {
padding: var(--c--theme--spacings--2xs);
}
.pb-2xs {
padding-bottom: var(--c--theme--spacings--2xs);
}
.pt-2xs {
padding-top: var(--c--theme--spacings--2xs);
}
.pl-2xs {
padding-left: var(--c--theme--spacings--2xs);
}
.pr-2xs {
padding-right: var(--c--theme--spacings--2xs);
}
.p-xs {
padding: var(--c--theme--spacings--xs);
}
.pb-xs {
padding-bottom: var(--c--theme--spacings--xs);
}
.pt-xs {
padding-top: var(--c--theme--spacings--xs);
}
.pl-xs {
padding-left: var(--c--theme--spacings--xs);
}
.pr-xs {
padding-right: var(--c--theme--spacings--xs);
}
.p-sm {
padding: var(--c--theme--spacings--sm);
}
.pb-sm {
padding-bottom: var(--c--theme--spacings--sm);
}
.pt-sm {
padding-top: var(--c--theme--spacings--sm);
}
.pl-sm {
padding-left: var(--c--theme--spacings--sm);
}
.pr-sm {
padding-right: var(--c--theme--spacings--sm);
}
.p-base {
padding: var(--c--theme--spacings--base);
}
.pb-base {
padding-bottom: var(--c--theme--spacings--base);
}
.pt-base {
padding-top: var(--c--theme--spacings--base);
}
.pl-base {
padding-left: var(--c--theme--spacings--base);
}
.pr-base {
padding-right: var(--c--theme--spacings--base);
}
.p-md {
padding: var(--c--theme--spacings--md);
}
.pb-md {
padding-bottom: var(--c--theme--spacings--md);
}
.pt-md {
padding-top: var(--c--theme--spacings--md);
}
.pl-md {
padding-left: var(--c--theme--spacings--md);
}
.pr-md {
padding-right: var(--c--theme--spacings--md);
}
.p-lg {
padding: var(--c--theme--spacings--lg);
}
.pb-lg {
padding-bottom: var(--c--theme--spacings--lg);
}
.pt-lg {
padding-top: var(--c--theme--spacings--lg);
}
.pl-lg {
padding-left: var(--c--theme--spacings--lg);
}
.pr-lg {
padding-right: var(--c--theme--spacings--lg);
}
.p-xxl {
padding: var(--c--theme--spacings--xxl);
}
.pb-xxl {
padding-bottom: var(--c--theme--spacings--xxl);
}
.pt-xxl {
padding-top: var(--c--theme--spacings--xxl);
}
.pl-xxl {
padding-left: var(--c--theme--spacings--xxl);
}
.pr-xxl {
padding-right: var(--c--theme--spacings--xxl);
}
.p-xxxl {
padding: var(--c--theme--spacings--xxxl);
}
.pb-xxxl {
padding-bottom: var(--c--theme--spacings--xxxl);
}
.pt-xxxl {
padding-top: var(--c--theme--spacings--xxxl);
}
.pl-xxxl {
padding-left: var(--c--theme--spacings--xxxl);
}
.pr-xxxl {
padding-right: var(--c--theme--spacings--xxxl);
}
.p-4xl {
padding: var(--c--theme--spacings--4xl);
}
.pb-4xl {
padding-bottom: var(--c--theme--spacings--4xl);
}
.pt-4xl {
padding-top: var(--c--theme--spacings--4xl);
}
.pl-4xl {
padding-left: var(--c--theme--spacings--4xl);
}
.pr-4xl {
padding-right: var(--c--theme--spacings--4xl);
}
.p-5xl {
padding: var(--c--theme--spacings--5xl);
}
.pb-5xl {
padding-bottom: var(--c--theme--spacings--5xl);
}
.pt-5xl {
padding-top: var(--c--theme--spacings--5xl);
}
.pl-5xl {
padding-left: var(--c--theme--spacings--5xl);
}
.pr-5xl {
padding-right: var(--c--theme--spacings--5xl);
}
.p-6xl {
padding: var(--c--theme--spacings--6xl);
}
.pb-6xl {
padding-bottom: var(--c--theme--spacings--6xl);
}
.pt-6xl {
padding-top: var(--c--theme--spacings--6xl);
}
.pl-6xl {
padding-left: var(--c--theme--spacings--6xl);
}
.pr-6xl {
padding-right: var(--c--theme--spacings--6xl);
}
.p-7xl {
padding: var(--c--theme--spacings--7xl);
}
.pb-7xl {
padding-bottom: var(--c--theme--spacings--7xl);
}
.pt-7xl {
padding-top: var(--c--theme--spacings--7xl);
}
.pl-7xl {
padding-left: var(--c--theme--spacings--7xl);
}
.pr-7xl {
padding-right: var(--c--theme--spacings--7xl);
}
@@ -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)',
@@ -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',
@@ -427,9 +456,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: {
@@ -486,7 +515,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 +531,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)',
@@ -5,6 +5,8 @@ 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';
@@ -13,6 +15,8 @@ interface AuthStore {
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
colorsTokens: () => Partial<ColorsTokens>;
fontSizesTokens: () => Partial<FontSizesTokens>;
spacingsTokens: () => Partial<SpacingsTokens>;
componentTokens: () => ComponentTokens;
}
@@ -25,6 +29,8 @@ const useCunninghamTheme = create<AuthStore>((set, get) => {
themeTokens: () => currentTheme().theme,
colorsTokens: () => currentTheme().theme.colors,
componentTokens: () => currentTheme().components,
spacingsTokens: () => currentTheme().theme.spacings,
fontSizesTokens: () => currentTheme().theme.font.sizes,
setTheme: (theme: Theme) => {
set({ theme });
},
@@ -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,9 +1,10 @@
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 { useTranslation } from 'react-i18next';
@@ -11,11 +12,10 @@ 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 useSaveDoc from '../hook/useSaveDoc';
import { useDocStore, useHeadingStore } from '../stores';
import { useEditorStore, useHeadingStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
@@ -26,7 +26,13 @@ const cssEditor = (readonly: boolean) => `
};
& .bn-editor {
padding-right: 30px;
${readonly && `padding-left: 30px;`}
${
readonly &&
`
padding-left: 30px;
pointer-events: none;
`
}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
@@ -36,6 +42,11 @@ const cssEditor = (readonly: boolean) => `
padding: 2px;
border-radius: 4px;
}
/* @TODO: A fix is made in v0.19.1 - This code can be removed after the upgrade */
.bn-block-content[data-content-type=codeBlock]>div>select option{
color: black;
background-color: white;
}
@media screen and (width <= 560px) {
& .bn-editor {
padding-left: 40px;
@@ -68,47 +79,22 @@ 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 readOnly = !doc.abilities.partial_update || isVersion;
useSaveDoc(doc.id, provider.document, !readOnly);
const storedEditor = docsStore?.[storeId]?.editor;
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
@@ -139,19 +125,23 @@ export const BlockNoteContent = ({
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.email || 'Anonymous',
name: userData?.full_name || userData?.email || t('Anonymous'),
color: randomColor(),
},
},
dictionary: locales[lang as keyof typeof locales],
dictionary: locales[lang as keyof typeof locales] as Dictionary,
uploadFile,
},
[provider, uploadFile, userData?.email, lang],
[lang, provider, uploadFile, userData?.email, userData?.full_name],
);
useEffect(() => {
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
useEffect(() => {
setHeadings(editor);
@@ -178,7 +168,7 @@ export const BlockNoteContent = ({
)}
<BlockNoteView
editor={storedEditor ?? editor}
editor={editor}
formattingToolbar={false}
editable={!readOnly}
theme="light"
@@ -1,13 +1,12 @@
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 { Box, Card, Text, TextErrors } from '@/components';
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 { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
@@ -32,11 +31,18 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
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' }}>
<Box $width="100%" $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit this document.`)}
</Alert>
@@ -51,10 +57,10 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
)}
<Box
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$width="100%"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip;"
$css="overflow-x: clip; flex: 1;"
$position="relative"
>
<Card
@@ -66,7 +72,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
<BlockNoteEditor doc={doc} storeId={doc.id} provider={provider} />
)}
{!isMobile && <IconOpenPanelEditor headings={headings} />}
</Card>
@@ -91,12 +97,24 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
docId: doc.id,
versionId,
});
const { createProvider, providers } = useDocStore();
const navigate = useNavigate();
const { replace } = useRouter();
useEffect(() => {
if (!version?.id) {
return;
}
const provider = providers?.[version.id];
if (!provider || provider.document.guid !== version.id) {
createProvider(version.id, version.content);
}
}, [createProvider, providers, version]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
void replace(`/404`);
return null;
}
@@ -124,5 +142,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} />;
};
@@ -127,9 +127,7 @@ export const PanelEditor = ({
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && (
<TableContent doc={doc} headings={headings} />
)}
{isPanelTableContentOpen && <TableContent headings={headings} />}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
@@ -1,3 +1,3 @@
export * from './useDocStore';
export * from './useEditorStore';
export * from './useHeadingStore';
export * from './usePanelEditorStore';
@@ -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;
@@ -28,20 +26,3 @@ function hslToHex(h: number, s: number, l: number) {
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]);
});
};
@@ -1,19 +1,18 @@
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';
@@ -23,43 +22,43 @@ interface DocHeaderProps {
}
export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
const { colorsTokens } = useCunninghamTheme();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
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={{ vertical: '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"
@@ -68,55 +67,24 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
$wrap="wrap"
$align="center"
>
<DocTitle doc={doc} />
<Box $flex={1} $width="100%" $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">
<Text $variation="400" $size="s" $weight="bold">
{transRole(currentDocRole(doc.abilities))}&nbsp;·&nbsp;
</Text>
<Text $variation="400" $size="s">
{t('Last update: {{update}}', {
update: DateTime.fromISO(doc.updated_at).toRelative(),
})}
</Text>
</Box>
</Box>
<DocToolBox doc={doc} versionId={versionId} />
</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>
<HorizontalSeparator />
</Box>
</>
);
};
@@ -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,8 +18,7 @@ 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;
@@ -32,7 +31,7 @@ export const DocTitle = ({ doc }: DocTitleProps) => {
return (
<Text
as="h2"
$margin={{ all: 'none', left: 'tiny' }}
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
>
{doc.title}
@@ -50,10 +49,8 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
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 +58,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 +77,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
// If mutation we update
if (sanitizedTitle !== doc.title) {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
}
updateDoc({ id: doc.id, title: sanitizedTitle });
}
},
@@ -94,70 +90,30 @@ 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="input"
defaultValue={isUntitled ? '' : titleDisplay}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
$color={
isUntitled
? colorsTokens()['greyscale-200']
: colorsTokens()['greyscale-text']
}
$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;
}
&:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
placeholder={untitledDocument}
aria-label={t('doc title input')}
name="doc-title"
$radius="4px"
onBlurCapture={(event) => handleTitleSubmit(event.target.value)}
$color={colorsTokens()['greyscale-text']}
$flex={1}
$width="100%"
$padding={{ left: '0' }}
$margin={{ left: '-2px' }}
$background="transparent"
$css={css`
font-size: var(--c--theme--font--sizes--h2);
font-weight: 700;
border: none;
`}
>
{titleDisplay}
</Box>
/>
</Tooltip>
</>
);
@@ -3,21 +3,31 @@ import {
VariantType,
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,
ModalShare,
} from '@/features/docs/doc-management';
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { ModalVersion, Versions } from '../../doc-versioning';
import { ModalPDF } from './ModalExport';
interface DocToolBoxProps {
@@ -27,21 +37,93 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc, versionId }: 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 { setIsPanelOpen, setIsPanelTableContentOpen } = usePanelEditorStore();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { isSmallMobile } = 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: () => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(false);
},
},
{
label: t('Table of contents'),
icon: 'summarize',
callback: () => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(true);
},
},
{
label: t('Export'),
icon: 'file_download',
callback: () => {
setIsModalPDFOpen(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;
@@ -83,9 +165,10 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
</Button>
</Box>
)}
<Box $direction="row" $margin={{ left: 'auto' }} $gap="1rem">
{authenticated && (
<Box $direction="row" $margin={{ left: 'auto' }} $gap={spacings['2xs']}>
{authenticated && !isSmallMobile && (
<Button
color="primary-text"
onClick={() => {
setIsModalShareOpen(true);
}}
@@ -94,91 +177,34 @@ 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="primary-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']}
$css={
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} />
@@ -14,7 +14,7 @@ import { t } from 'i18next';
import { useEffect, useMemo, useState } from 'react';
import { Box, Text } from '@/components';
import { useDocStore } from '@/features/docs/doc-editor/';
import { useEditorStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { useExport } from '../api/useExport';
@@ -31,7 +31,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
});
const { toast } = useToastProvider();
const { docsStore } = useDocStore();
const { editor } = useEditorStore();
const {
mutate: createExport,
data: documentGenerated,
@@ -104,8 +104,6 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
return;
}
const editor = docsStore[doc.id].editor;
if (!editor) {
toast(t('No editor found'), VariantType.ERROR);
return;
@@ -5,7 +5,7 @@ export interface Template {
abilities: {
destroy: boolean;
generate_document: boolean;
manage_accesses: boolean;
accesses_manage: boolean;
retrieve: boolean;
update: boolean;
partial_update: boolean;
@@ -1,6 +1,12 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import {
APIError,
APIList,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { Doc } from '../types';
@@ -52,3 +58,7 @@ export function useDocs(
...queryConfig,
});
}
export const useInfiniteDocs = (params: DocsParams) => {
return useAPIInfiniteQuery(KEY_LIST_DOC, getDocs, params);
};
@@ -30,7 +30,7 @@ export const useRemoveDoc = (options?: UseRemoveDocOptions) => {
mutationFn: removeDoc,
...options,
onSuccess: (data, variables, context) => {
void queryClient.resetQueries({
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs';
import { Doc, KEY_DOC } from '@/features/docs/doc-management';
import { useBroadcastStore } from '@/stores';
export type UpdateDocLinkParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'link_role' | 'link_reach'>>;
@@ -37,14 +38,20 @@ export function useUpdateDocLink({
listInvalideQueries,
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data) => {
onSuccess: (data, variable) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.resetQueries({
queryKey: [queryKey],
});
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.id}`);
onSuccess?.(data);
},
});
@@ -0,0 +1,9 @@
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.01394" y="1.23611" width="25.9722" height="33.5278" rx="3.54167" fill="white"/>
<rect x="2.01394" y="1.23611" width="25.9722" height="33.5278" rx="3.54167" stroke="#DCDCFC" stroke-width="0.472222"/>
<path d="M6.5 8.55556H15" stroke="#6A6AF4" stroke-width="1.88889" stroke-linecap="round"/>
<path d="M6.5 11.3889H23.5M6.5 14.2222H23.5M6.5 17.0556H23.5M6.5 19.8889H23.5M6.5 22.7222H20.6667" stroke="#CACAFB" stroke-width="1.88889" stroke-linecap="round"/>
<rect x="7" y="10" width="16" height="16" rx="8" fill="#6A6AF4"/>
<rect x="7" y="10" width="16" height="16" rx="8" stroke="white" stroke-width="1.5"/>
<path d="M16.8 18L18 19.2V20.1H15.45V22.95L15 23.4L14.55 22.95V20.1H12V19.2L13.2 18V14.7H12.6V13.8H17.4V14.7H16.8V18Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 853 B

@@ -0,0 +1,6 @@
<svg width="28" height="34" viewBox="0 0 28 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.01394" y="0.236111" width="25.9722" height="33.5278" rx="3.54167" fill="white"/>
<rect x="1.01394" y="0.236111" width="25.9722" height="33.5278" rx="3.54167" stroke="#DCDCFC" stroke-width="0.472222"/>
<path d="M5.5 7.55554H14" stroke="#6A6AF4" stroke-width="1.88889" stroke-linecap="round"/>
<path d="M5.5 10.3889H22.5M5.5 13.2222H22.5M5.5 16.0556H22.5M5.5 18.8889H22.5M5.5 21.7222H22.5M5.5 24.5556H22.5M5.5 27.3889H22.5M5.5 30.2222H22.5M5.5 33.0556H22.5" stroke="#CACAFB" stroke-width="1.88889" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 635 B

@@ -7,13 +7,12 @@ import {
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router';
import { Box, Text, TextErrors } from '@/components';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useRemoveDoc } from '../api/useRemoveDoc';
import IconDoc from '../assets/icon-doc.svg';
import { Doc } from '../types';
interface ModalRemoveDocProps {
@@ -22,12 +21,13 @@ interface ModalRemoveDocProps {
}
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
const { colorsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const router = useRouter();
const { push } = useRouter();
const pathname = usePathname();
const {
mutate: removeDoc,
isError,
error,
} = useRemoveDoc({
@@ -35,7 +35,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
router.push('/');
if (pathname === '/') {
onClose();
} else {
void push('/');
}
},
});
@@ -58,7 +62,7 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
rightActions={
<Button
aria-label={t('Confirm deletion')}
color="primary"
color="danger"
fullWidth
onClick={() =>
removeDoc({
@@ -96,32 +100,6 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
)}
{isError && <TextErrors causes={error.cause} />}
<Text
as="p"
$padding="small"
$direction="row"
$gap="0.5rem"
$background={colorsTokens()['primary-150']}
$theme="primary"
$align="center"
$radius="2px"
>
<IconDoc
className="p-t"
aria-label={t(`Document icon`)}
color={colorsTokens()['primary-500']}
width={58}
style={{
borderRadius: '8px',
backgroundColor: '#ffffff',
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
<Text $theme="primary" $weight="bold" $size="l">
{doc.title}
</Text>
</Text>
</Box>
</Modal>
);
@@ -115,7 +115,7 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
</Box>
</Card>
<DocVisibility doc={doc} />
{doc.abilities.manage_accesses && (
{doc.abilities.accesses_manage && (
<AddMembers
doc={doc}
currentRole={currentDocRole(doc.abilities)}
@@ -123,8 +123,12 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
)}
</Box>
<Box $minHeight="0">
<InvitationList doc={doc} />
<MemberList doc={doc} />
{doc.abilities.accesses_view && (
<>
<InvitationList doc={doc} />
<MemberList doc={doc} />
</>
)}
</Box>
</Box>
</SideModal>
@@ -1,5 +1,6 @@
export * from './api';
export * from './components';
export * from './hooks';
export * from './stores';
export * from './types';
export * from './utils';
@@ -0,0 +1 @@
export * from './useDocStore';
@@ -1,31 +1,23 @@
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;
}
import { Base64, Doc, blocksToYDoc } from '@/features/docs/doc-management';
export interface UseDocStore {
currentDoc?: Doc;
docsStore: {
[storeId: string]: DocStore;
providers: {
[storeId: string]: HocuspocusProvider;
};
createProvider: (storeId: string, initialDoc: Base64) => HocuspocusProvider;
setStore: (storeId: string, props: Partial<DocStore>) => void;
setProviders: (storeId: string, providers: HocuspocusProvider) => void;
setCurrentDoc: (doc: Doc | undefined) => void;
}
export const useDocStore = create<UseDocStore>((set, get) => ({
currentDoc: undefined,
docsStore: {},
providers: {},
createProvider: (storeId: string, initialDoc: Base64) => {
const doc = new Y.Doc({
guid: storeId,
@@ -50,23 +42,17 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
document: doc,
});
get().setStore(storeId, { provider });
get().setProviders(storeId, provider);
return provider;
},
setStore: (storeId, props) => {
set(({ docsStore }, ...store) => {
return {
...store,
docsStore: {
...docsStore,
[storeId]: {
...docsStore[storeId],
...props,
},
},
};
});
setProviders: (storeId, provider) => {
set(({ providers }) => ({
providers: {
...providers,
[storeId]: provider,
},
}));
},
setCurrentDoc: (doc) => {
set({ currentDoc: doc });
@@ -44,10 +44,11 @@ export interface Doc {
created_at: string;
updated_at: string;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
attachment_upload: true;
destroy: boolean;
link_configuration: boolean;
manage_accesses: boolean;
partial_update: boolean;
retrieve: boolean;
update: boolean;
@@ -1,11 +1,30 @@
import * as Y from 'yjs';
import { Doc, Role } from './types';
export const currentDocRole = (abilities: Doc['abilities']): Role => {
return abilities.destroy
? Role.OWNER
: abilities.manage_accesses
: abilities.accesses_manage
? Role.ADMIN
: abilities.partial_update
? Role.EDITOR
: Role.READER;
};
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]);
});
};
@@ -1,23 +1,21 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { HeadingBlock, useDocStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { HeadingBlock, useEditorStore } from '@/features/docs/doc-editor';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { useResponsiveStore } from '@/stores';
import { Heading } from './Heading';
interface TableContentProps {
doc: Doc;
headings: HeadingBlock[];
}
export const TableContent = ({ doc, headings }: TableContentProps) => {
const { docsStore } = useDocStore();
export const TableContent = ({ headings }: TableContentProps) => {
const { editor } = useEditorStore();
const { isMobile } = useResponsiveStore();
const { t } = useTranslation();
const editor = docsStore?.[doc.id]?.editor;
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
// To highlight the first heading in the viewport
@@ -49,7 +47,7 @@ export const TableContent = ({ doc, headings }: TableContentProps) => {
}
};
window.addEventListener('scroll', () => {
document.getElementById(MAIN_LAYOUT_ID)?.addEventListener('scroll', () => {
setTimeout(() => {
handleScroll();
}, 300);
@@ -7,12 +7,12 @@ import {
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import * as Y from 'yjs';
import { Box, Text } from '@/components';
import { toBase64, useDocStore } from '@/features/docs/doc-editor';
import { Doc, useUpdateDoc } from '@/features/docs/doc-management';
import { toBase64 } from '@/features/docs/doc-editor';
import { Doc, useDocStore, useUpdateDoc } from '@/features/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
import { Versions } from '../types';
@@ -31,29 +31,25 @@ export const ModalVersion = ({
versionId,
}: ModalVersionProps) => {
const { toast } = useToastProvider();
const router = useRouter();
const { docsStore, setStore } = useDocStore();
const { push } = useRouter();
const { providers } = useDocStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
const onDisplaySuccess = () => {
toast(t('Version restored successfully'), VariantType.SUCCESS);
router.push(`/docs/${docId}`);
void push(`/docs/${docId}`);
};
if (!docsStore?.[docId]?.provider || !docsStore?.[versionId]?.provider) {
if (!providers?.[docId] || !providers?.[versionId]) {
onDisplaySuccess();
return;
}
setStore(docId, {
editor: undefined,
});
revertUpdate(
docsStore[docId].provider.document,
docsStore[docId].provider.document,
docsStore[versionId].provider.document,
providers[docId].document,
providers[docId].document,
providers[versionId].document,
);
onDisplaySuccess();
@@ -83,7 +79,7 @@ export const ModalVersion = ({
fullWidth
onClick={() => {
const newDoc = toBase64(
Y.encodeStateAsUpdate(docsStore?.[versionId]?.provider.document),
Y.encodeStateAsUpdate(providers?.[versionId].document),
);
updateDoc({
@@ -1,6 +1,6 @@
import { Button } from '@openfun/cunningham-react';
import { t } from 'i18next';
import React, { PropsWithChildren, useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -69,10 +69,7 @@ export const VersionItem = ({
{isActive && versionId && (
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the version options')}
/>
<IconOptions aria-label={t('Open the version options')} />
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
@@ -1,220 +1,103 @@
import {
Column,
DataGrid,
SortModel,
usePagination,
} from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { Button, Loader } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { InView } from 'react-intersection-observer';
import { Card, StyledLink, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
DocsOrdering,
LinkReach,
currentDocRole,
isDocsOrdering,
useDocs,
useTrans,
} from '@/features/docs/doc-management';
import { useDate } from '@/hook/';
import { Box, Card, Text } from '@/components';
import { useResponsiveStore } from '@/stores';
import { PAGE_SIZE } from '../conf';
import { useInfiniteDocs } from '../../doc-management';
import { DocsGridActions } from './DocsGridActions';
const DocsGridStyle = createGlobalStyle`
& .c__datagrid thead{
position: sticky;
top: 0;
background: #fff;
z-index: 1;
}
& .c__pagination__goto{
display:none;
}
`;
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
function formatSortModel(sortModel: SortModelItem): DocsOrdering | undefined {
const { field, sort } = sortModel;
const orderingField = sort === 'desc' ? `-${field}` : field;
if (isDocsOrdering(orderingField)) {
return orderingField;
}
}
import { DocsGridItem } from './DocsGridItem';
import { DocsGridLoader } from './DocsGridLoader';
export const DocsGrid = () => {
const { colorsTokens } = useCunninghamTheme();
const { transRole } = useTrans();
const { t } = useTranslation();
const { formatDate } = useDate();
const pagination = usePagination({
pageSize: PAGE_SIZE,
const { isDesktop } = useResponsiveStore();
const {
data,
isFetching,
isRefetching,
isLoading,
fetchNextPage,
hasNextPage,
} = useInfiniteDocs({
page: 1,
});
const [sortModel, setSortModel] = useState<SortModel>([
{
field: 'updated_at',
sort: 'desc',
},
]);
const { page, pageSize, setPagesCount } = pagination;
const [docs, setDocs] = useState<Doc[]>([]);
const { isMobile } = useResponsiveStore();
const loading = isFetching || isLoading;
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const { data, isLoading, error } = useDocs({
page,
ordering,
});
useEffect(() => {
if (isLoading) {
const loadMore = (inView: boolean) => {
if (!inView || loading) {
return;
}
setDocs(data?.results || []);
}, [data?.results, t, isLoading]);
useEffect(() => {
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
}, [data?.count, pageSize, setPagesCount]);
const columns: Column<Doc>[] = [
{
headerName: '',
id: 'visibility',
size: 95,
renderCell: ({ row }) => {
return (
row.link_reach === LinkReach.PUBLIC && (
<StyledLink href={`/docs/${row.id}`}>
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
$color="white"
$padding="xtiny"
$radius="3px"
>
{t('Public')}
</Text>
</StyledLink>
)
);
},
},
{
headerName: t('Document name'),
field: 'title',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold" $theme="primary">
{row.title}
</Text>
</StyledLink>
);
},
},
{
headerName: t('Created at'),
field: 'created_at',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{formatDate(row.created_at)}</Text>
</StyledLink>
);
},
},
{
headerName: t('Updated at'),
field: 'updated_at',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{formatDate(row.updated_at)}</Text>
</StyledLink>
);
},
},
{
headerName: t('Your role'),
id: 'your_role',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">
{transRole(currentDocRole(row.abilities))}
</Text>
</StyledLink>
);
},
},
{
headerName: t('Members'),
id: 'users_number',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{row.accesses.length}</Text>
</StyledLink>
);
},
},
{
id: 'column-actions',
renderCell: ({ row }) => {
return <DocsGridActions doc={row} />;
},
},
];
// Inverse columns for mobile to have the most important information first
if (isMobile) {
const tmpCol = columns[0];
columns[0] = columns[1];
columns[1] = tmpCol;
}
void fetchNextPage();
};
return (
<Card
$padding={{ bottom: 'small', horizontal: 'big' }}
$margin={{ all: isMobile ? 'small' : 'big', top: 'none' }}
$overflow="auto"
aria-label={t(`Datagrid of the documents page {{page}}`, { page })}
$height="100%"
>
<DocsGridStyle />
<Text
$weight="bold"
as="h2"
$theme="primary"
$margin={{ bottom: 'small' }}
>
{t('Documents')}
</Text>
<Box $position="relative" $width="100%" $maxWidth="960px">
<DocsGridLoader isLoading={isRefetching} />
<Card data-testid="docs-grid" $padding="md">
<Text
as="h4"
$size="h4"
$weight="700"
$margin={{ top: '0px', bottom: 'xs' }}
>
{t('All docs')}
</Text>
{error && <TextErrors causes={error.cause} />}
<Box>
<Box $direction="row" $padding="xs" data-testid="docs-grid-header">
<Box $flex={6} $padding="3xs">
<Text $size="xs" $variation="600">
{t('Name')}
</Text>
</Box>
{isDesktop && (
<Box $flex={1} $padding="3xs">
<Text $size="xs" $variation="600">
{t('Updated at')}
</Text>
</Box>
)}
<DataGrid
columns={columns}
rows={docs}
isLoading={isLoading}
pagination={pagination}
onSortModelChange={setSortModel}
sortModel={sortModel}
emptyPlaceholderLabel={t("You don't have any document yet.")}
/>
</Card>
<Box $flex={1} $align="flex-end" $padding="3xs" />
</Box>
{/* Body */}
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
</Box>
{loading && (
<Box
data-testid="docs-grid-loader"
$padding="md"
$align="center"
$justify="center"
$width="100%"
>
<Loader />
</Box>
)}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button onClick={() => void fetchNextPage()} color="primary-text">
{t('More docs')}
</Button>
)}
</InView>
)}
</Card>
</Box>
);
};
@@ -1,7 +1,7 @@
import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useModal } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
interface DocsGridActionsProps {
@@ -10,26 +10,31 @@ interface DocsGridActionsProps {
export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const { t } = useTranslation();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const deleteModal = useModal();
if (!doc.abilities.destroy) {
return null;
}
const options: DropdownMenuOption[] = [
{
label: t('Remove'),
icon: 'delete',
callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`,
},
];
return (
<>
<Button
onClick={() => {
setIsModalRemoveOpen(true);
}}
color="tertiary-text"
icon={<span className="material-icons">delete</span>}
size="small"
style={{ padding: '0rem' }}
aria-label={t('Delete the document')}
/>
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
<DropdownMenu options={options}>
<Icon
data-testid={`docs-grid-actions-button-${doc.id}`}
iconName="more_horiz"
$theme="primary"
$variation="600"
/>
</DropdownMenu>
{deleteModal.isOpen && (
<ModalRemoveDoc onClose={deleteModal.onClose} doc={doc} />
)}
</>
);
@@ -1,40 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { useCreateDoc, useTrans } from '@/features/docs/doc-management/';
import { useResponsiveStore } from '@/stores';
import { DocsGrid } from './DocsGrid';
export const DocsGridContainer = () => {
const { t } = useTranslation();
const { untitledDocument } = useTrans();
const router = useRouter();
const { isMobile } = useResponsiveStore();
const { mutate: createDoc } = useCreateDoc({
onSuccess: (doc) => {
router.push(`/docs/${doc.id}`);
},
});
const handleCreateDoc = () => {
createDoc({ title: untitledDocument });
};
return (
<Box $overflow="auto">
<Box
$align="flex-end"
$justify="center"
$margin={isMobile ? 'small' : 'big'}
>
<Button onClick={handleCreateDoc}>{t('Create a new document')}</Button>
</Box>
<DocsGrid />
</Box>
);
};
@@ -0,0 +1,104 @@
import { Button } from '@openfun/cunningham-react';
import { DateTime } from 'luxon';
import { css } from 'styled-components';
import { Box, Icon, StyledLink, Text } from '@/components';
import { useResponsiveStore } from '@/stores';
import { Doc, LinkReach } from '../../doc-management';
import { DocsGridActions } from './DocsGridActions';
import { SimpleDocItem } from './SimpleDocItem';
type DocsGridItemProps = {
doc: Doc;
};
export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
const { isDesktop } = useResponsiveStore();
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isRestricted = doc.link_reach === LinkReach.RESTRICTED;
const sharedCount = doc.accesses.length - 1;
const isShared = sharedCount > 0;
return (
<Box
$direction="row"
$width="100%"
$align="center"
role="row"
$padding={{ vertical: 'xs', horizontal: 'sm' }}
$css={css`
cursor: pointer;
border-radius: 4px;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
}
`}
>
<StyledLink $css="flex: 7; align-items: center;" href={`/docs/${doc.id}`}>
<Box
data-testid={`docs-grid-name-${doc.id}`}
$flex={6}
$padding={{ right: 'base' }}
>
<SimpleDocItem doc={doc} />
</Box>
{isDesktop && (
<Box $flex={1}>
<Text $variation="500" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
</Box>
)}
</StyledLink>
<Box
$flex={1}
$direction="row"
$align="center"
$justify="flex-end"
$gap="10px"
>
{isDesktop && isPublic && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
size="nano"
icon={<Icon $variation="000" iconName="public" />}
>
{isShared ? sharedCount : undefined}
</Button>
)}
{isDesktop && !isPublic && isRestricted && isShared && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
color="tertiary"
size="nano"
icon={<Icon $variation="800" $theme="primary" iconName="group" />}
>
{sharedCount}
</Button>
)}
{isDesktop && !isPublic && isAuthenticated && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
size="nano"
icon={<Icon $variation="000" iconName="corporate_fare" />}
>
{sharedCount}
</Button>
)}
<DocsGridActions doc={doc} />
</Box>
</Box>
);
};
@@ -0,0 +1,44 @@
import { Loader } from '@openfun/cunningham-react';
import { createGlobalStyle, css } from 'styled-components';
import { Box } from '@/components';
import { HEADER_HEIGHT } from '@/features/header/conf';
const DocsGridLoaderStyle = createGlobalStyle`
body, main {
overflow: hidden!important;
overflow-y: hidden!important;
}
`;
type DocsGridLoaderProps = {
isLoading: boolean;
};
export const DocsGridLoader = ({ isLoading }: DocsGridLoaderProps) => {
if (!isLoading) {
return null;
}
return (
<>
<DocsGridLoaderStyle />
<Box
data-testid="grid-loader"
$align="center"
$justify="center"
$height="calc(100vh - 50px)"
$width="100%"
$maxWidth="960px"
$background="rgba(255, 255, 255, 0.3)"
$zIndex={998}
$position="fixed"
$css={css`
top: ${HEADER_HEIGHT}px;
`}
>
<Loader />
</Box>
</>
);
};
@@ -0,0 +1,83 @@
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, LinkReach } from '@/features/docs';
import PinnedDocumentIcon from '@/features/docs/doc-management/assets/pinned-document.svg';
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
import { useResponsiveStore } from '@/stores';
const ItemTextCss = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: initial;
display: -webkit-box;
line-clamp: 1;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
`;
type SimpleDocItemProps = {
doc: Doc;
isPinned?: boolean;
subText?: string;
};
export const SimpleDocItem = ({
doc,
isPinned = false,
subText,
}: SimpleDocItemProps) => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const spacings = spacingsTokens();
const isPublic = doc?.link_reach === LinkReach.PUBLIC;
const isShared = !isPublic && doc.accesses.length > 1;
const accessCount = doc.accesses.length - 1;
const isSharedOrPublic = isShared || isPublic;
return (
<Box $direction="row" $gap={spacings.sm}>
<Box
$direction="row"
$align="center"
$css={css`
background-color: transparent;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
`}
>
{isPinned ? <PinnedDocumentIcon /> : <SimpleFileIcon />}
</Box>
<Box>
<Text
aria-describedby="doc-title"
aria-label={doc.title}
$size="sm"
$variation="1000"
$weight="500"
$css={ItemTextCss}
>
{doc.title}
</Text>
<Box $direction="row" $align="center" $gap={spacings['3xs']}>
{!isDesktop && (
<>
{isPublic && <Icon iconName="public" $size="16px" />}
{isShared && <Icon iconName="group" $size="16px" />}
{isSharedOrPublic && accessCount > 0 && (
<Text $size="12px">{accessCount}</Text>
)}
{isSharedOrPublic && <Text $size="12px">·</Text>}
</>
)}
<Text $size="xs" $variation="500" $weight="500" $css={ItemTextCss}>
{subText ??
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vel ante libero. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed imperdiet neque quam, sed euismod metus mollis ut. '}
</Text>
</Box>
</Box>
</Box>
);
};
@@ -1 +0,0 @@
export * from './DocsGridContainer';
@@ -112,7 +112,7 @@ export const InvitationItem = ({
}}
/>
</Box>
{doc.abilities.manage_accesses && (
{doc.abilities.accesses_manage && (
<Box $margin={isSmallMobile ? 'auto' : ''}>
<Button
color="tertiary-text"
@@ -5,11 +5,13 @@ import { User } from '@/core/auth';
import {
Access,
Doc,
KEY_DOC,
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list';
import { ContentLanguage } from '@/i18n/types';
import { useBroadcastStore } from '@/stores';
import { OptionType } from '../types';
@@ -53,9 +55,11 @@ export const createDocAccess = async ({
export function useCreateDocAccess() {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Access, APIError, CreateDocAccessParams>({
mutationFn: createDocAccess,
onSuccess: () => {
onSuccess: (_data, variable) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
@@ -65,6 +69,9 @@ export function useCreateDocAccess() {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.docId}`);
},
});
}
@@ -170,14 +170,14 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending || !doc.abilities.manage_accesses}
disabled={isPending || !doc.abilities.accesses_manage}
/>
</Box>
<Box $css="flex: auto;">
<ChooseRole
key={resetKey}
currentRole={currentRole}
disabled={isPending || !doc.abilities.manage_accesses}
disabled={isPending || !doc.abilities.accesses_manage}
setRole={setSelectedRole}
/>
</Box>
@@ -189,7 +189,7 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
!selectedUsers.length ||
isPending ||
!selectedRole ||
!doc.abilities.manage_accesses
!doc.abilities.accesses_manage
}
onClick={() => void handleValidate()}
style={{ height: '100%', maxHeight: '55px' }}
@@ -7,6 +7,7 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_DOC, KEY_LIST_DOC } from '@/features/docs/doc-management';
import { KEY_LIST_USER } from '@/features/docs/members/members-add';
import { useBroadcastStore } from '@/stores';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
@@ -39,6 +40,8 @@ type UseDeleteDocAccessOptions = UseMutationOptions<
export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<void, APIError, DeleteDocAccessProps>({
mutationFn: deleteDocAccess,
...options,
@@ -49,6 +52,10 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variables.docId}`);
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
@@ -11,6 +11,7 @@ import {
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { useBroadcastStore } from '@/stores';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
@@ -49,6 +50,8 @@ type UseUpdateDocAccessOptions = UseMutationOptions<
export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Access, APIError, UpdateDocAccessProps>({
mutationFn: updateDocAccess,
...options,
@@ -59,6 +62,10 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variables.docId}`);
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
@@ -5,7 +5,7 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -35,7 +35,7 @@ export const MemberItem = ({
const { isSmallMobile, screenWidth } = useResponsiveStore();
const [localRole, setLocalRole] = useState(role);
const { toast } = useToastProvider();
const router = useRouter();
const { push } = useRouter();
const { mutate: updateDocAccess, error: errorUpdate } = useUpdateDocAccess({
onSuccess: () => {
toast(t('The role has been updated'), VariantType.SUCCESS, {
@@ -55,13 +55,13 @@ export const MemberItem = ({
);
if (isMyself) {
router.push('/');
void push('/');
}
},
});
const isNotAllowed =
isOtherOwner || isLastOwner || !doc.abilities.manage_accesses;
isOtherOwner || isLastOwner || !doc.abilities.accesses_manage;
if (!access.user) {
return (
@@ -112,7 +112,7 @@ export const MemberItem = ({
}}
/>
</Box>
{doc.abilities.manage_accesses && (
{doc.abilities.accesses_manage && (
<Box $margin={isSmallMobile ? 'auto' : ''}>
<Button
color="tertiary-text"
@@ -136,7 +136,7 @@ export const MemberItem = ({
<TextErrors causes={errorUpdate?.cause || errorDelete?.cause} />
</Box>
)}
{(isLastOwner || isOtherOwner) && doc.abilities.manage_accesses && (
{(isLastOwner || isOtherOwner) && doc.abilities.accesses_manage && (
<Box $margin={{ top: 'tiny' }}>
<Alert
canClose={false}
@@ -1,5 +1,4 @@
import Image from 'next/image';
import React from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -1,31 +1,4 @@
<svg viewBox="0 0 36 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5_830)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M26.5706 16.681V20.756H9.59985V16.681H26.5706ZM26.5706 23.0467V27.1215H9.59985V23.0467H26.5706ZM19.5375 29.2926V33.3674H9.59985V29.2926H19.5375Z"
fill="#E1000F"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M35.6982 16.7351V33.2911C35.6826 33.2845 35.667 33.2756 35.6492 33.269V37.4663C35.6492 39.8874 33.641 41.8683 31.1867 41.8683H4.461C2.00664 41.8683 -0.00146484 39.8874 -0.00146484 37.4663V4.45137C-0.00146484 2.03028 2.00664 0.0493774 4.461 0.0493774H18.723V4.45137H4.461V37.4663H31.1867V16.7351H35.6982Z"
fill="#000091"
/>
<path
d="M33.9524 0.0360107H23.626C22.6794 0.0360107 21.9049 0.799997 21.9049 1.73376V11.9202C21.9049 12.854 22.6794 13.618 23.626 13.618H33.9524C34.899 13.618 35.6735 12.854 35.6735 11.9202V1.73376C35.6735 0.799997 34.899 0.0360107 33.9524 0.0360107ZM26.6378 6.40257C26.6378 7.10713 26.0613 7.67588 25.347 7.67588H24.4865V8.73697C24.4865 9.08501 24.1939 9.37362 23.8411 9.37362C23.4883 9.37362 23.1957 9.08501 23.1957 8.73697V5.12925C23.1957 4.66237 23.5829 4.28038 24.0562 4.28038H25.347C26.0613 4.28038 26.6378 4.84913 26.6378 5.55369V6.40257ZM30.9405 8.10031C30.9405 8.80488 30.364 9.37362 29.6497 9.37362H27.9286C27.6877 9.37362 27.4984 9.18687 27.4984 8.94919V4.70482C27.4984 4.46713 27.6877 4.28038 27.9286 4.28038H29.6497C30.364 4.28038 30.9405 4.84913 30.9405 5.55369V8.10031ZM34.3827 4.91704C34.3827 5.26507 34.0901 5.55369 33.7373 5.55369H33.0919V6.40257H33.7373C34.0901 6.40257 34.3827 6.69118 34.3827 7.03922C34.3827 7.38726 34.0901 7.67588 33.7373 7.67588H33.0919V8.73697C33.0919 9.08501 32.7993 9.37362 32.4465 9.37362C32.0936 9.37362 31.8011 9.08501 31.8011 8.73697V5.12925C31.8011 4.66237 32.1883 4.28038 32.6616 4.28038H33.7373C34.0901 4.28038 34.3827 4.569 34.3827 4.91704ZM24.4865 6.40257H25.347V5.55369H24.4865V6.40257ZM28.7892 8.10031H29.6497V5.55369H28.7892V8.10031Z"
fill="#000091"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M35.6734 10.0666V13.6574H32.4942V10.0666H35.6734ZM25.0866 0.0441895V3.59171H21.9041V0.0441895H25.0866Z"
fill="#000091"
/>
</g>
<defs>
<clipPath id="clip0_5_830">
<rect width="36" height="42" fill="white" />
</clipPath>
</defs>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.6305 28.8312C22.7983 28.5038 23.9166 27.9062 24.6505 26.8503C25.3749 25.8163 25.5789 24.5047 25.5789 23.2425V4.75099C25.5789 4.42358 25.5611 4.09557 25.5216 3.77148C26.1016 3.99961 26.5486 4.37658 26.8626 4.90239C27.2331 5.50024 27.4184 6.28757 27.4184 7.26435V26.0464C27.4184 27.3684 27.0942 28.3578 26.4458 29.0146C25.7974 29.6714 24.8207 29.9998 23.5155 29.9998H16.4209C16.5889 29.9704 16.7574 29.9401 16.9262 29.909C18.4067 29.6444 19.9713 29.2854 21.6185 28.8346L21.6305 28.8312Z" fill="#C9191E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58203 25.655V6.8477C4.58203 5.70251 4.88938 4.83519 5.50408 4.24575C6.1272 3.65631 6.95242 3.33212 7.97972 3.27318C9.49542 3.18055 10.9311 3.05425 12.2868 2.89425C13.6425 2.72584 14.9393 2.53217 16.1771 2.31324C17.4234 2.0943 18.6359 1.85011 19.8148 1.58065C21.0274 1.29435 21.9578 1.4375 22.6062 2.0101C23.2546 2.58269 23.5788 3.49632 23.5788 4.75099V23.2425C23.5788 24.3456 23.3893 25.1666 23.0104 25.7055C22.6315 26.2529 21.9915 26.6528 21.0905 26.9054C19.4906 27.3433 17.9833 27.6886 16.5687 27.9412C15.154 28.2022 13.7731 28.4001 12.4258 28.5348C11.0785 28.6696 9.69751 28.7748 8.28286 28.8506C7.11241 28.918 6.20299 28.6738 5.5546 28.118C4.90622 27.5707 4.58203 26.7497 4.58203 25.655ZM9.20865 10.2624C11.0635 10.1444 12.7632 9.96305 14.3075 9.71831C14.6822 9.65722 15.0564 9.5936 15.4291 9.52759C15.8192 9.45851 16.1013 9.11859 16.1013 8.72337C16.1013 8.21154 15.638 7.82609 15.135 7.91189C14.846 7.96118 14.5555 8.00909 14.2635 8.05562C12.7346 8.29923 11.0452 8.47998 9.19523 8.5977C8.91819 8.61558 8.69776 8.70188 8.55608 8.87391C8.42209 9.03661 8.35645 9.23229 8.35645 9.45535C8.35645 9.68212 8.43296 9.87951 8.58568 10.0418L8.58783 10.0439C8.75336 10.2095 8.96369 10.2811 9.20865 10.2624ZM9.20801 14.456C11.0631 14.338 12.763 14.1566 14.3075 13.9119C15.8588 13.6589 17.3936 13.3638 18.9112 13.0266C19.2191 12.9581 19.4498 12.8503 19.5652 12.683C19.6786 12.5221 19.7347 12.3376 19.7347 12.1332C19.7347 11.9026 19.6469 11.704 19.476 11.5426C19.2921 11.3689 19.0348 11.3284 18.7304 11.3911L18.7285 11.3915C17.2823 11.7194 15.794 12.0053 14.2635 12.2492C12.7346 12.4928 11.0452 12.6735 9.19523 12.7913C8.91819 12.8091 8.69776 12.8954 8.55608 13.0675C8.42276 13.2294 8.35645 13.4205 8.35645 13.6363C8.35645 13.8703 8.43209 14.0723 8.58558 14.2354L8.59 14.2396C8.75499 14.3949 8.96316 14.4655 9.20551 14.4562L9.20801 14.456ZM9.20847 18.6494C11.0634 18.5229 12.7631 18.3374 14.3075 18.0927C15.8589 17.8482 17.3934 17.5573 18.9112 17.22C19.2199 17.1514 19.4508 17.0391 19.566 16.8627C19.6783 16.7029 19.7347 16.5233 19.7347 16.3266C19.7347 16.0961 19.6469 15.8974 19.476 15.7361C19.2921 15.5623 19.0348 15.5218 18.7304 15.5845L18.729 15.5848C17.2827 15.9043 15.7942 16.1861 14.2635 16.43C12.7345 16.6736 11.045 16.8586 9.19495 16.9847C8.91804 17.0026 8.69771 17.0889 8.55608 17.2609C8.42276 17.4228 8.35645 17.6139 8.35645 17.8297C8.35645 18.0637 8.43209 18.2658 8.58558 18.4289L8.59 18.433C8.75499 18.5883 8.96316 18.6589 9.20551 18.6496L9.20847 18.6494ZM14.3075 22.257C12.7632 22.5018 11.0635 22.6831 9.20867 22.8012C8.9637 22.8198 8.75337 22.7482 8.58783 22.5826L8.58572 22.5805C8.433 22.4182 8.35645 22.2208 8.35645 21.9941C8.35645 21.771 8.42209 21.5753 8.55608 21.4126C8.69776 21.2406 8.91827 21.1543 9.19531 21.1364C11.0453 21.0187 12.7346 20.838 14.2635 20.5943C14.5555 20.5478 14.846 20.4999 15.135 20.4506C15.638 20.3648 16.1013 20.7503 16.1013 21.2621C16.1013 21.6573 15.8192 21.9972 15.4291 22.0663C15.0564 22.1323 14.6822 22.1959 14.3075 22.257Z" fill="#000091"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,32 +0,0 @@
.burgerIcon {
cursor: pointer;
transform: translate(-20%, 0%);
}
.burgerIcon path {
stroke-width: 40;
stroke-linecap: round;
fill: none;
transition: all 0.5s ease-in-out;
}
/* In menu form */
.burgerIcon path:first-child,
.burgerIcon path:last-child {
stroke-dasharray: 240px 910px;
}
.burgerIcon .middle_bar {
stroke-dasharray: 240px 240px;
}
/* In cross form */
.open path:first-child,
.open path:last-child {
stroke-dashoffset: -650px;
}
.open :nth-child(2) {
stroke-dasharray: 0 220px;
stroke-dashoffset: -120px;
}
@@ -1,18 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Burger } from './Burger';
describe('<Burger />', () => {
test('Burger interactions', () => {
const { rerender } = render(<Burger isOpen={true} />);
const burger = screen.getByRole('img');
expect(burger).toBeInTheDocument();
expect(burger.classList.contains('open')).toBeTruthy();
rerender(<Burger isOpen={false} />);
expect(burger.classList.contains('open')).not.toBeTruthy();
});
});

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