mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 23:22:15 +02:00
Compare commits
2 Commits
refacto/re
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294b1a0dc5 | ||
|
|
cbd2705c9f |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -6,22 +6,10 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡️(frontend) add skeleton on content loading #2254
|
||||
|
||||
### Changed
|
||||
|
||||
- 🧵(backend) remove lock from db table #2272
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💬(frontend) add missing link in onboarding description #2233
|
||||
- 🐛(frontend) sanitize pasted and dropped content in document title #2210
|
||||
- 🐛(frontend) Emoji menu doesn't display above comment box #2229
|
||||
- 🐛(frontend) Block menu doesn't stay open on 1st line #2229
|
||||
- 🐛(frontend) The "+" on the first line of a new doc doesn't work #2229
|
||||
|
||||
- 🐛(backend) replace document creation table locks with retry strategy
|
||||
|
||||
## [v5.0.0] - 2026-04-08
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
|
||||
| TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS | Number of attempts to create a document before failing. | 10 |
|
||||
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||
| USER_ONBOARDING_DOCUMENTS | A list of documents IDs for which a read-only access will be created for new s | [] |
|
||||
| USER_ONBOARDING_SANDBOX_DOCUMENT | ID of a template sandbox document that will be duplicated for new users | |
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
import binascii
|
||||
import mimetypes
|
||||
from base64 import b64decode
|
||||
from logging import getLogger
|
||||
from os.path import splitext
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
@@ -25,8 +23,7 @@ from core.services.converter_services import (
|
||||
ConversionError,
|
||||
Converter,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
from core.utils.treebeard import create_tree_node_with_retry
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -470,18 +467,12 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
{"content": ["Could not convert content"]}
|
||||
) from err
|
||||
|
||||
while True:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
document = models.Document.add_root(
|
||||
title=validated_data["title"],
|
||||
creator=user,
|
||||
)
|
||||
break
|
||||
except IntegrityError as e:
|
||||
if "impress_document_path_key" not in str(e):
|
||||
raise
|
||||
logger.warning("Path key conflict when creating document, retrying...")
|
||||
document = create_tree_node_with_retry(
|
||||
lambda: models.Document.add_root(
|
||||
title=validated_data["title"],
|
||||
creator=user,
|
||||
)
|
||||
)
|
||||
|
||||
if user:
|
||||
# Associate the document with the pre-existing user
|
||||
|
||||
@@ -19,7 +19,7 @@ from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import IntegrityError, connection, transaction
|
||||
from django.db import connection, transaction
|
||||
from django.db import models as db
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Greatest, Left, Length
|
||||
@@ -67,11 +67,10 @@ from core.services.search_indexers import (
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import (
|
||||
extract_attachments,
|
||||
filter_descendants,
|
||||
users_sharing_documents_with,
|
||||
)
|
||||
from core.utils.paths import filter_descendants
|
||||
from core.utils.treebeard import create_tree_node_with_retry
|
||||
from core.utils.users import users_sharing_documents_with
|
||||
from core.utils.yjs import extract_attachments
|
||||
|
||||
from ..enums import FeatureFlag, SearchType
|
||||
from . import permissions, serializers, utils
|
||||
@@ -708,18 +707,12 @@ class DocumentViewSet(
|
||||
{"file": ["Could not convert file content"]}
|
||||
) from err
|
||||
|
||||
while True:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
obj = models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
break
|
||||
except IntegrityError as e:
|
||||
if "impress_document_path_key" not in str(e):
|
||||
raise
|
||||
logger.warning("Path key conflict when creating document, retrying...")
|
||||
obj = create_tree_node_with_retry(
|
||||
lambda: models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
)
|
||||
serializer.instance = obj
|
||||
models.DocumentAccess.objects.create(
|
||||
document=obj,
|
||||
@@ -1023,16 +1016,12 @@ class DocumentViewSet(
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
with transaction.atomic():
|
||||
# "select_for_update" locks the table to ensure safe concurrent access
|
||||
locked_parent = models.Document.objects.select_for_update().get(
|
||||
pk=document.pk
|
||||
)
|
||||
|
||||
child_document = locked_parent.add_child(
|
||||
child_document = create_tree_node_with_retry(
|
||||
lambda: document.add_child(
|
||||
creator=request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
)
|
||||
|
||||
# Set the created instance to the serializer
|
||||
serializer.instance = child_document
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.db import migrations, models
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
import core.models
|
||||
from core.utils import extract_attachments
|
||||
from core.utils.yjs import extract_attachments
|
||||
|
||||
|
||||
def populate_attachments_on_all_documents(apps, schema_editor):
|
||||
|
||||
@@ -19,7 +19,7 @@ from django.core.cache import cache
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import send_mail
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db import models, transaction
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
@@ -39,6 +39,7 @@ from core.choices import (
|
||||
RoleChoices,
|
||||
get_equivalent_link_definition,
|
||||
)
|
||||
from core.utils.treebeard import create_tree_node_with_retry
|
||||
from core.validators import sub_validator
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -265,8 +266,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
duplicate the sandbox document for the user
|
||||
"""
|
||||
if settings.USER_ONBOARDING_SANDBOX_DOCUMENT:
|
||||
# transaction.atomic is used in a context manager to avoid a transaction if
|
||||
# the settings USER_ONBOARDING_SANDBOX_DOCUMENT is unused
|
||||
sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT
|
||||
try:
|
||||
template_document = Document.objects.get(id=sandbox_id)
|
||||
@@ -276,27 +275,20 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
sandbox_id,
|
||||
)
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
sandbox_document = Document.add_root(
|
||||
title=template_document.title,
|
||||
content=template_document.content,
|
||||
attachments=template_document.attachments,
|
||||
duplicated_from=template_document,
|
||||
creator=self,
|
||||
)
|
||||
DocumentAccess.objects.create(
|
||||
user=self, document=sandbox_document, role=RoleChoices.OWNER
|
||||
)
|
||||
break
|
||||
except IntegrityError as e:
|
||||
if "impress_document_path_key" not in str(e):
|
||||
raise
|
||||
logger.warning(
|
||||
"Path key conflict when creating sandbox document, retrying..."
|
||||
with transaction.atomic():
|
||||
sandbox_document = create_tree_node_with_retry(
|
||||
lambda: Document.add_root(
|
||||
title=template_document.title,
|
||||
content=template_document.content,
|
||||
attachments=template_document.attachments,
|
||||
duplicated_from=template_document,
|
||||
creator=self,
|
||||
)
|
||||
)
|
||||
|
||||
DocumentAccess.objects.create(
|
||||
user=self, document=sandbox_document, role=RoleChoices.OWNER
|
||||
)
|
||||
|
||||
def _convert_valid_invitations(self):
|
||||
"""
|
||||
|
||||
@@ -12,8 +12,11 @@ from django.utils.module_loading import import_string
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
from core import models
|
||||
from core.enums import SearchType
|
||||
from core.utils.dicts import get_value_by_pattern
|
||||
from core.utils.paths import get_ancestor_to_descendants_map
|
||||
from core.utils.yjs import base64_yjs_to_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,7 +47,7 @@ def get_batch_accesses_by_users_and_teams(paths):
|
||||
Get accesses related to a list of document paths,
|
||||
grouped by users and teams, including all ancestor paths.
|
||||
"""
|
||||
ancestor_map = utils.get_ancestor_to_descendants_map(
|
||||
ancestor_map = get_ancestor_to_descendants_map(
|
||||
paths, steplen=models.Document.steplen
|
||||
)
|
||||
ancestor_paths = list(ancestor_map.keys())
|
||||
@@ -297,7 +300,7 @@ class FindDocumentIndexer(BaseDocumentIndexer):
|
||||
>>> get_title({"id": 1})
|
||||
""
|
||||
"""
|
||||
titles = utils.get_value_by_pattern(source, r"^title\.")
|
||||
titles = get_value_by_pattern(source, r"^title\.")
|
||||
for title in titles:
|
||||
if title:
|
||||
return title
|
||||
@@ -318,7 +321,7 @@ class FindDocumentIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
doc_path = document.path
|
||||
doc_content = document.content
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
text_content = base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
return {
|
||||
"id": str(document.id),
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.dispatch import receiver
|
||||
|
||||
from core import models
|
||||
from core.tasks.search import trigger_batch_document_indexer
|
||||
from core.utils import get_users_sharing_documents_with_cache_key
|
||||
from core.utils.users import get_users_sharing_documents_with_cache_key
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.Document)
|
||||
|
||||
@@ -12,13 +12,14 @@ import pytest
|
||||
import responses
|
||||
from requests import HTTPError
|
||||
|
||||
from core import factories, models, utils
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import (
|
||||
BaseDocumentIndexer,
|
||||
FindDocumentIndexer,
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
from core.utils.yjs import base64_yjs_to_text
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -199,7 +200,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
|
||||
"depth": 1,
|
||||
"path": document.path,
|
||||
"numchild": 1,
|
||||
"content": utils.base64_yjs_to_text(document.content),
|
||||
"content": base64_yjs_to_text(document.content),
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"reach": document.link_reach,
|
||||
|
||||
@@ -8,7 +8,18 @@ from django.core.cache import cache
|
||||
import pycrdt
|
||||
import pytest
|
||||
|
||||
from core import factories, utils
|
||||
from core import factories
|
||||
from core.utils.dicts import get_value_by_pattern
|
||||
from core.utils.paths import get_ancestor_to_descendants_map
|
||||
from core.utils.users import (
|
||||
get_users_sharing_documents_with_cache_key,
|
||||
users_sharing_documents_with,
|
||||
)
|
||||
from core.utils.yjs import (
|
||||
base64_yjs_to_text,
|
||||
base64_yjs_to_xml,
|
||||
extract_attachments,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -34,12 +45,12 @@ TEST_BASE64_STRING = (
|
||||
|
||||
def test_utils_base64_yjs_to_text():
|
||||
"""Test extract text from saved yjs document"""
|
||||
assert utils.base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
|
||||
assert base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
|
||||
|
||||
|
||||
def test_utils_base64_yjs_to_xml():
|
||||
"""Test extract xml from saved yjs document"""
|
||||
content = utils.base64_yjs_to_xml(TEST_BASE64_STRING)
|
||||
content = base64_yjs_to_xml(TEST_BASE64_STRING)
|
||||
assert (
|
||||
'<heading textAlignment="left" level="1"><italic>Hello</italic></heading>'
|
||||
in content
|
||||
@@ -79,13 +90,13 @@ def test_utils_extract_attachments():
|
||||
update = ydoc.get_update()
|
||||
base64_string = base64.b64encode(update).decode("utf-8")
|
||||
# image_key2 is missing the "/media/" part and shouldn't get extracted
|
||||
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
|
||||
assert extract_attachments(base64_string) == [image_key1, image_key3]
|
||||
|
||||
|
||||
def test_utils_get_ancestor_to_descendants_map_single_path():
|
||||
"""Test ancestor mapping of a single path."""
|
||||
paths = ["000100020005"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
result = get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005"},
|
||||
@@ -97,7 +108,7 @@ def test_utils_get_ancestor_to_descendants_map_single_path():
|
||||
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
|
||||
"""Test ancestor mapping of multiple paths with shared prefixes."""
|
||||
paths = ["000100020005", "00010003"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
result = get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005", "00010003"},
|
||||
@@ -119,10 +130,10 @@ def test_utils_users_sharing_documents_with_cache_miss():
|
||||
factories.UserDocumentAccessFactory(user=user2, document=doc1)
|
||||
factories.UserDocumentAccessFactory(user=user3, document=doc2)
|
||||
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
cache_key = get_users_sharing_documents_with_cache_key(user1)
|
||||
cache.delete(cache_key)
|
||||
|
||||
result = utils.users_sharing_documents_with(user1)
|
||||
result = users_sharing_documents_with(user1)
|
||||
|
||||
assert user2.id in result
|
||||
|
||||
@@ -139,12 +150,12 @@ def test_utils_users_sharing_documents_with_cache_hit():
|
||||
factories.UserDocumentAccessFactory(user=user1, document=doc1)
|
||||
factories.UserDocumentAccessFactory(user=user2, document=doc1)
|
||||
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
cache_key = get_users_sharing_documents_with_cache_key(user1)
|
||||
|
||||
test_cached_data = {user2.id: "2025-02-10"}
|
||||
cache.set(cache_key, test_cached_data, 86400)
|
||||
|
||||
result = utils.users_sharing_documents_with(user1)
|
||||
result = users_sharing_documents_with(user1)
|
||||
assert result == test_cached_data
|
||||
|
||||
|
||||
@@ -156,7 +167,7 @@ def test_utils_users_sharing_documents_with_cache_invalidation_on_create():
|
||||
doc1 = factories.DocumentFactory()
|
||||
|
||||
# Pre-populate cache
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
cache_key = get_users_sharing_documents_with_cache_key(user1)
|
||||
cache.set(cache_key, {}, 86400)
|
||||
|
||||
# Verify cache exists
|
||||
@@ -182,7 +193,7 @@ def test_utils_users_sharing_documents_with_cache_invalidation_on_delete():
|
||||
|
||||
doc_access = factories.UserDocumentAccessFactory(user=user1, document=doc1)
|
||||
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
cache_key = get_users_sharing_documents_with_cache_key(user1)
|
||||
cache.set(cache_key, {user2.id: "2025-02-10"}, 86400)
|
||||
|
||||
assert cache.get(cache_key) is not None
|
||||
@@ -196,10 +207,10 @@ def test_utils_users_sharing_documents_with_empty_result():
|
||||
"""Test when user is not sharing any documents."""
|
||||
user1 = factories.UserFactory()
|
||||
|
||||
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
|
||||
cache_key = get_users_sharing_documents_with_cache_key(user1)
|
||||
cache.delete(cache_key)
|
||||
|
||||
result = utils.users_sharing_documents_with(user1)
|
||||
result = users_sharing_documents_with(user1)
|
||||
|
||||
assert result == {}
|
||||
|
||||
@@ -210,7 +221,7 @@ def test_utils_users_sharing_documents_with_empty_result():
|
||||
def test_utils_get_value_by_pattern_matching_key():
|
||||
"""Test extracting value from a dictionary with a matching key pattern."""
|
||||
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
result = get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
@@ -218,7 +229,7 @@ def test_utils_get_value_by_pattern_matching_key():
|
||||
def test_utils_get_value_by_pattern_multiple_matches():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
result = get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {
|
||||
"Bonjour",
|
||||
@@ -229,7 +240,7 @@ def test_utils_get_value_by_pattern_multiple_matches():
|
||||
def test_utils_get_value_by_pattern_multiple_extensions():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
result = get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
@@ -237,6 +248,6 @@ def test_utils_get_value_by_pattern_multiple_extensions():
|
||||
def test_utils_get_value_by_pattern_no_match():
|
||||
"""Test that empty list is returned when no key matches the pattern."""
|
||||
data = {"name": "Test", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
result = get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert result == []
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for the create_tree_node_with_retry utils."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.models import Document
|
||||
from core.utils.treebeard import _is_tree_path_collision, create_tree_node_with_retry
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
DjangoValidationError({"path": "not unique"}),
|
||||
IntegrityError("impress_document_path_key"),
|
||||
],
|
||||
)
|
||||
def test_utils_create_tree_node_with_retry_exceed_max_attempts(settings, exc):
|
||||
"""Test exceeding the max attempts should reraise the exception."""
|
||||
|
||||
settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2
|
||||
|
||||
create_fn = mock.MagicMock()
|
||||
create_fn.side_effect = exc
|
||||
|
||||
with (
|
||||
pytest.raises(exc.__class__),
|
||||
mock.patch(
|
||||
"core.utils.treebeard._is_tree_path_collision"
|
||||
) as mock__is_tree_path_collision,
|
||||
):
|
||||
mock__is_tree_path_collision.side_effect = _is_tree_path_collision
|
||||
create_tree_node_with_retry(create_fn)
|
||||
|
||||
mock__is_tree_path_collision.assert_called()
|
||||
assert mock__is_tree_path_collision.call_count == 2
|
||||
assert create_fn.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
DjangoValidationError({"foo": "bar"}),
|
||||
IntegrityError("not handled"),
|
||||
],
|
||||
)
|
||||
def test_utils_create_tree_node_with_retry_exceed_exception_not_handled(settings, exc):
|
||||
"""Test with an exception not handled should return reraise it immediatly."""
|
||||
|
||||
settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2
|
||||
|
||||
create_fn = mock.MagicMock()
|
||||
create_fn.side_effect = exc
|
||||
|
||||
with (
|
||||
pytest.raises(exc.__class__),
|
||||
mock.patch(
|
||||
"core.utils.treebeard._is_tree_path_collision"
|
||||
) as mock__is_tree_path_collision,
|
||||
):
|
||||
mock__is_tree_path_collision.side_effect = _is_tree_path_collision
|
||||
create_tree_node_with_retry(create_fn)
|
||||
|
||||
mock__is_tree_path_collision.assert_called()
|
||||
assert mock__is_tree_path_collision.call_count == 1
|
||||
assert create_fn.call_count == 1
|
||||
|
||||
|
||||
def test_utils_create_tree_node_with_retry_success():
|
||||
"""Test executing successfully the create_fn callback."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
document = create_tree_node_with_retry(
|
||||
lambda: Document.add_root(
|
||||
creator=user,
|
||||
title="success",
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(document, Document)
|
||||
assert document.title == "success"
|
||||
assert document.path is not None
|
||||
@@ -2,7 +2,7 @@
|
||||
Unit tests for the filter_root_paths utility function.
|
||||
"""
|
||||
|
||||
from core.utils import filter_descendants
|
||||
from core.utils.paths import filter_descendants
|
||||
|
||||
|
||||
def test_utils_filter_descendants_success():
|
||||
|
||||
@@ -4,7 +4,8 @@ from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, utils
|
||||
from core import factories
|
||||
from core.utils.users import users_sharing_documents_with
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -54,7 +55,7 @@ def test_utils_users_sharing_documents_with():
|
||||
doc_3_pierre_2.created_at = yesterday
|
||||
doc_3_pierre_2.save()
|
||||
|
||||
shared_map = utils.users_sharing_documents_with(user)
|
||||
shared_map = users_sharing_documents_with(user)
|
||||
|
||||
assert shared_map == {
|
||||
pierre_1.id: last_week,
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Utils for the core app."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import models as db
|
||||
from django.db.models import Subquery
|
||||
|
||||
import pycrdt
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from core import enums, models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_value_by_pattern(data, pattern):
|
||||
"""
|
||||
Get all values from keys matching a regex pattern in a dictionary.
|
||||
|
||||
Args:
|
||||
data (dict): Source dictionary to search
|
||||
pattern (str): Regex pattern to match against keys
|
||||
|
||||
Returns:
|
||||
list: List of values for all matching keys, empty list if no matches
|
||||
|
||||
Example:
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
|
||||
["Bonjour"]
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
|
||||
["Bonjour", "Hello"]
|
||||
"""
|
||||
regex = re.compile(pattern)
|
||||
return [value for key, value in data.items() if regex.match(key)]
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
"""
|
||||
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
|
||||
|
||||
Each path is assumed to use materialized path format with fixed-length segments.
|
||||
|
||||
Args:
|
||||
paths (list of str): List of full document paths.
|
||||
steplen (int): Length of each path segment.
|
||||
|
||||
Returns:
|
||||
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
|
||||
"""
|
||||
ancestor_map = defaultdict(set)
|
||||
for path in paths:
|
||||
for i in range(steplen, len(path) + 1, steplen):
|
||||
ancestor = path[:i]
|
||||
ancestor_map[ancestor].add(path)
|
||||
return ancestor_map
|
||||
|
||||
|
||||
def filter_descendants(paths, root_paths, skip_sorting=False):
|
||||
"""
|
||||
Filters paths to keep only those that are descendants of any path in root_paths.
|
||||
|
||||
A path is considered a descendant of a root path if it starts with the root path.
|
||||
If `skip_sorting` is not set to True, the function will sort both lists before
|
||||
processing because both `paths` and `root_paths` need to be in lexicographic order
|
||||
before going through the algorithm.
|
||||
|
||||
Args:
|
||||
paths (iterable of str): List of paths to be filtered.
|
||||
root_paths (iterable of str): List of paths to check as potential prefixes.
|
||||
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
|
||||
|
||||
Returns:
|
||||
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
|
||||
"""
|
||||
results = []
|
||||
i = 0
|
||||
n = len(root_paths)
|
||||
|
||||
if not skip_sorting:
|
||||
paths.sort()
|
||||
root_paths.sort()
|
||||
|
||||
for path in paths:
|
||||
# Try to find a matching prefix in the sorted accessible paths
|
||||
while i < n:
|
||||
if path.startswith(root_paths[i]):
|
||||
results.append(path)
|
||||
break
|
||||
if root_paths[i] < path:
|
||||
i += 1
|
||||
else:
|
||||
# If paths[i] > path, no need to keep searching
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def base64_yjs_to_xml(base64_string):
|
||||
"""Extract xml from base64 yjs document."""
|
||||
|
||||
decoded_bytes = base64.b64decode(base64_string)
|
||||
# uint8_array = bytearray(decoded_bytes)
|
||||
|
||||
doc = pycrdt.Doc()
|
||||
doc.apply_update(decoded_bytes)
|
||||
return str(doc.get("document-store", type=pycrdt.XmlFragment))
|
||||
|
||||
|
||||
def base64_yjs_to_text(base64_string):
|
||||
"""Extract text from base64 yjs document."""
|
||||
|
||||
blocknote_structure = base64_yjs_to_xml(base64_string)
|
||||
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
|
||||
return soup.get_text(separator=" ", strip=True)
|
||||
|
||||
|
||||
def extract_attachments(content):
|
||||
"""Helper method to extract media paths from a document's content."""
|
||||
if not content:
|
||||
return []
|
||||
|
||||
xml_content = base64_yjs_to_xml(content)
|
||||
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
|
||||
|
||||
|
||||
def get_users_sharing_documents_with_cache_key(user):
|
||||
"""Generate a unique cache key for each user."""
|
||||
return f"users_sharing_documents_with_{user.id}"
|
||||
|
||||
|
||||
def users_sharing_documents_with(user):
|
||||
"""
|
||||
Returns a map of users sharing documents with the given user,
|
||||
sorted by last shared date.
|
||||
"""
|
||||
start_time = time.time()
|
||||
cache_key = get_users_sharing_documents_with_cache_key(user)
|
||||
cached_result = cache.get(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
|
||||
user.id,
|
||||
elapsed,
|
||||
)
|
||||
return cached_result
|
||||
|
||||
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
|
||||
"document_id", flat=True
|
||||
)
|
||||
shared_qs = (
|
||||
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
|
||||
.exclude(user=user)
|
||||
.values("user")
|
||||
.annotate(last_shared=db.Max("created_at"))
|
||||
)
|
||||
result = {item["user"]: item["last_shared"] for item in shared_qs}
|
||||
cache.set(cache_key, result, 86400) # Cache for 1 day
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
|
||||
user.id,
|
||||
elapsed,
|
||||
)
|
||||
return result
|
||||
1
src/backend/core/utils/__init__.py
Normal file
1
src/backend/core/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core utilities package."""
|
||||
24
src/backend/core/utils/dicts.py
Normal file
24
src/backend/core/utils/dicts.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Dictionary utility functions."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def get_value_by_pattern(data, pattern):
|
||||
"""
|
||||
Get all values from keys matching a regex pattern in a dictionary.
|
||||
|
||||
Args:
|
||||
data (dict): Source dictionary to search
|
||||
pattern (str): Regex pattern to match against keys
|
||||
|
||||
Returns:
|
||||
list: List of values for all matching keys, empty list if no matches
|
||||
|
||||
Example:
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
|
||||
["Bonjour"]
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
|
||||
["Bonjour", "Hello"]
|
||||
"""
|
||||
regex = re.compile(pattern)
|
||||
return [value for key, value in data.items() if regex.match(key)]
|
||||
63
src/backend/core/utils/paths.py
Normal file
63
src/backend/core/utils/paths.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Path and tree structure utilities."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
"""
|
||||
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
|
||||
|
||||
Each path is assumed to use materialized path format with fixed-length segments.
|
||||
|
||||
Args:
|
||||
paths (list of str): List of full document paths.
|
||||
steplen (int): Length of each path segment.
|
||||
|
||||
Returns:
|
||||
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
|
||||
"""
|
||||
ancestor_map = defaultdict(set)
|
||||
for path in paths:
|
||||
for i in range(steplen, len(path) + 1, steplen):
|
||||
ancestor = path[:i]
|
||||
ancestor_map[ancestor].add(path)
|
||||
return ancestor_map
|
||||
|
||||
|
||||
def filter_descendants(paths, root_paths, skip_sorting=False):
|
||||
"""
|
||||
Filters paths to keep only those that are descendants of any path in root_paths.
|
||||
|
||||
A path is considered a descendant of a root path if it starts with the root path.
|
||||
If `skip_sorting` is not set to True, the function will sort both lists before
|
||||
processing because both `paths` and `root_paths` need to be in lexicographic order
|
||||
before going through the algorithm.
|
||||
|
||||
Args:
|
||||
paths (iterable of str): List of paths to be filtered.
|
||||
root_paths (iterable of str): List of paths to check as potential prefixes.
|
||||
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
|
||||
|
||||
Returns:
|
||||
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
|
||||
"""
|
||||
results = []
|
||||
i = 0
|
||||
n = len(root_paths)
|
||||
|
||||
if not skip_sorting:
|
||||
paths.sort()
|
||||
root_paths.sort()
|
||||
|
||||
for path in paths:
|
||||
# Try to find a matching prefix in the sorted accessible paths
|
||||
while i < n:
|
||||
if path.startswith(root_paths[i]):
|
||||
results.append(path)
|
||||
break
|
||||
if root_paths[i] < path:
|
||||
i += 1
|
||||
else:
|
||||
# If paths[i] > path, no need to keep searching
|
||||
break
|
||||
return results
|
||||
56
src/backend/core/utils/treebeard.py
Normal file
56
src/backend/core/utils/treebeard.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Treebeard path collision handling utilities."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_tree_path_collision(exc):
|
||||
"""Return True when `exc` is caused by a Document.path uniqueness conflict.
|
||||
|
||||
Treebeard computes the materialized path by reading the current siblings;
|
||||
under concurrency two callers may compute the same value. Depending on
|
||||
timing this surfaces either as:
|
||||
|
||||
- `django.core.exceptions.ValidationError` raised by `full_clean()` /
|
||||
`validate_unique()` before the INSERT (BaseModel.save calls full_clean),
|
||||
- or `IntegrityError` from the database unique index when the validate
|
||||
step misses the conflict.
|
||||
"""
|
||||
if isinstance(exc, DjangoValidationError):
|
||||
message_dict = getattr(exc, "message_dict", None)
|
||||
if message_dict is not None:
|
||||
return "path" in message_dict
|
||||
return "path" in str(exc).lower()
|
||||
|
||||
# search in the IntegrityError exception
|
||||
return "impress_document_path_key" in str(exc).lower()
|
||||
|
||||
|
||||
def create_tree_node_with_retry(create_fn):
|
||||
"""Run `create_fn` in a fresh atomic block, retrying on path collisions.
|
||||
|
||||
The Document.path field carries a unique constraint, which is the source of
|
||||
truth that prevents duplicate paths. On collision we let the failed
|
||||
transaction roll back, and call `create_fn` again so treebeard recomputes
|
||||
the path from the latest state.
|
||||
"""
|
||||
max_attempts = settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
return create_fn()
|
||||
except (IntegrityError, DjangoValidationError) as exc:
|
||||
if not _is_tree_path_collision(exc) or attempt == max_attempts - 1:
|
||||
raise
|
||||
logger.info(
|
||||
"tree path collision on attempt %d/%d, retrying",
|
||||
attempt + 1,
|
||||
max_attempts,
|
||||
)
|
||||
|
||||
raise RuntimeError("create_tree_node_with_retry exited without result")
|
||||
55
src/backend/core/utils/users.py
Normal file
55
src/backend/core/utils/users.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""User sharing cache utilities."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import models as db
|
||||
from django.db.models import Subquery
|
||||
|
||||
from core import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_users_sharing_documents_with_cache_key(user):
|
||||
"""Generate a unique cache key for each user."""
|
||||
return f"users_sharing_documents_with_{user.id}"
|
||||
|
||||
|
||||
def users_sharing_documents_with(user):
|
||||
"""
|
||||
Returns a map of users sharing documents with the given user,
|
||||
sorted by last shared date.
|
||||
"""
|
||||
start_time = time.time()
|
||||
cache_key = get_users_sharing_documents_with_cache_key(user)
|
||||
cached_result = cache.get(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
|
||||
user.id,
|
||||
elapsed,
|
||||
)
|
||||
return cached_result
|
||||
|
||||
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
|
||||
"document_id", flat=True
|
||||
)
|
||||
shared_qs = (
|
||||
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
|
||||
.exclude(user=user)
|
||||
.values("user")
|
||||
.annotate(last_shared=db.Max("created_at"))
|
||||
)
|
||||
result = {item["user"]: item["last_shared"] for item in shared_qs}
|
||||
cache.set(cache_key, result, 86400) # Cache for 1 day
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
|
||||
user.id,
|
||||
elapsed,
|
||||
)
|
||||
return result
|
||||
36
src/backend/core/utils/yjs.py
Normal file
36
src/backend/core/utils/yjs.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Yjs document conversion utilities."""
|
||||
|
||||
import base64
|
||||
import re
|
||||
|
||||
import pycrdt
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from core import enums
|
||||
|
||||
|
||||
def base64_yjs_to_xml(base64_string):
|
||||
"""Extract xml from base64 yjs document."""
|
||||
|
||||
decoded_bytes = base64.b64decode(base64_string)
|
||||
|
||||
doc = pycrdt.Doc()
|
||||
doc.apply_update(decoded_bytes)
|
||||
return str(doc.get("document-store", type=pycrdt.XmlFragment))
|
||||
|
||||
|
||||
def base64_yjs_to_text(base64_string):
|
||||
"""Extract text from base64 yjs document."""
|
||||
|
||||
blocknote_structure = base64_yjs_to_xml(base64_string)
|
||||
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
|
||||
return soup.get_text(separator=" ", strip=True)
|
||||
|
||||
|
||||
def extract_attachments(content):
|
||||
"""Helper method to extract media paths from a document's content."""
|
||||
if not content:
|
||||
return []
|
||||
|
||||
xml_content = base64_yjs_to_xml(content)
|
||||
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
|
||||
@@ -161,8 +161,7 @@
|
||||
},
|
||||
"onboarding": {
|
||||
"enabled": true,
|
||||
"learn_more_url": "",
|
||||
"ready_template_url": ""
|
||||
"learn_more_url": ""
|
||||
},
|
||||
"help": {
|
||||
"documentation_url": ""
|
||||
|
||||
@@ -1081,6 +1081,12 @@ class Base(Configuration):
|
||||
60 * 60 * 24, environ_name="CONTENT_METADATA_CACHE_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
|
||||
TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = values.IntegerValue(
|
||||
10,
|
||||
environ_name="TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
|
||||
@@ -688,23 +688,25 @@ test.describe('Doc Editor', () => {
|
||||
test('it checks interlink feature', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const { name: docChild1 } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-interlink-child-1',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild1);
|
||||
|
||||
const { name: docChild2 } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-interlink-child-2',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
|
||||
const treeRow = await getTreeRow(page, docChild2);
|
||||
|
||||
// To let the time for the emoji-picker to load
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await treeRow.locator('.--docs--doc-icon').click();
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
|
||||
|
||||
@@ -104,9 +104,6 @@ test.describe('Doc Header', () => {
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await writeInEditor({ page, text: 'Hello Content' });
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await updateShareLink(page, 'Public', 'Editing');
|
||||
|
||||
@@ -119,9 +116,7 @@ test.describe('Doc Header', () => {
|
||||
docTitle,
|
||||
});
|
||||
|
||||
await expect(otherPage.getByText('Hello Content')).toBeVisible();
|
||||
|
||||
// Wait for other page to broadcast sync
|
||||
// Wait for other page to sync
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
@@ -129,8 +124,9 @@ test.describe('Doc Header', () => {
|
||||
await expect(elTitle).toBeVisible();
|
||||
await elTitle.fill('Hello World');
|
||||
await elTitle.blur();
|
||||
await verifyDocName(page, 'Hello World');
|
||||
|
||||
// Wait for other page to broadcast sync
|
||||
// Wait for other page to sync
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check other user page
|
||||
@@ -535,7 +531,7 @@ test.describe('Doc Header', () => {
|
||||
browserName === 'webkit',
|
||||
'navigator.clipboard is not working with webkit and playwright',
|
||||
);
|
||||
await mockedDocument(page, {
|
||||
const uuid = await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false, // Means owner
|
||||
link_configuration: true,
|
||||
@@ -556,6 +552,7 @@ test.describe('Doc Header', () => {
|
||||
name: 'Share',
|
||||
exact: true,
|
||||
});
|
||||
await expect(shareButton).toBeVisible();
|
||||
|
||||
await shareButton.click();
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
@@ -566,8 +563,8 @@ test.describe('Doc Header', () => {
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
|
||||
const url = page.url();
|
||||
expect(clipboardContent.trim()).toMatch(url);
|
||||
const origin = await page.evaluate(() => window.location.origin);
|
||||
expect(clipboardContent.trim()).toMatch(`${origin}/docs/${uuid}/`);
|
||||
});
|
||||
|
||||
test('it pins a document', async ({ page, browserName }) => {
|
||||
|
||||
@@ -31,8 +31,6 @@ test.describe('Inherited share accesses', () => {
|
||||
.getByRole('link')
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
|
||||
await verifyDocName(page, parentTitle);
|
||||
});
|
||||
|
||||
|
||||
@@ -185,23 +185,23 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await page.getByLabel('Restore', { exact: true }).click();
|
||||
|
||||
const mainEditor = page.getByLabel('Document editor');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(mainEditor.getByText('Hello')).toBeVisible();
|
||||
await expect(mainEditor.getByText('World')).toBeHidden();
|
||||
await expect(editor.getByText('Hello')).toBeVisible();
|
||||
await expect(editor.getByText('World')).toBeHidden();
|
||||
|
||||
// The old comment is not restored
|
||||
await expect(mainEditor.getByText('Hello')).toHaveCSS(
|
||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
|
||||
// We can add a new comment
|
||||
await mainEditor.getByText('Hello').selectText();
|
||||
await editor.getByText('Hello').selectText();
|
||||
await page.getByRole('button', { name: 'Add comment' }).click();
|
||||
|
||||
await thread.getByRole('paragraph').first().fill('This is a comment');
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
await expect(mainEditor.getByText('Hello')).toHaveClass('bn-thread-mark');
|
||||
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,8 +153,7 @@ test.describe('Help feature', () => {
|
||||
theme_customization: {
|
||||
onboarding: {
|
||||
enabled: true,
|
||||
learn_more_url: 'http://localhost:3000/learn-more',
|
||||
ready_template_url: 'http://localhost:3000/ready-template',
|
||||
learn_more_url: 'https://example.com/learn-more',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -185,19 +184,18 @@ test.describe('Help feature', () => {
|
||||
'0',
|
||||
);
|
||||
|
||||
const step3 = page.getByTestId('onboarding-step-3');
|
||||
await step3.click();
|
||||
await expect(step3).toHaveAttribute('tabindex', '0');
|
||||
await expect(
|
||||
step3.getByRole('link', { name: 'ready-made template' }),
|
||||
).toHaveAttribute('href', 'http://localhost:3000/ready-template');
|
||||
await page.getByTestId('onboarding-step-3').click();
|
||||
await expect(page.getByTestId('onboarding-step-3')).toHaveAttribute(
|
||||
'tabindex',
|
||||
'0',
|
||||
);
|
||||
|
||||
const learnMoreLink = page.getByRole('link', {
|
||||
name: 'Learn more docs features',
|
||||
});
|
||||
await expect(learnMoreLink).toHaveAttribute(
|
||||
'href',
|
||||
'http://localhost:3000/learn-more',
|
||||
'https://example.com/learn-more',
|
||||
);
|
||||
await learnMoreLink.click();
|
||||
|
||||
@@ -243,16 +241,6 @@ test.describe('Help feature', () => {
|
||||
await expect(
|
||||
modal.getByRole('button', { name: /Suivant/i }),
|
||||
).toBeVisible();
|
||||
await modal
|
||||
.getByText(/Tirez parti de la bibliothèque de contenu/)
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
modal.getByText(/Commencez à partir de/).first(),
|
||||
).toBeVisible();
|
||||
await expect(modal.getByRole('link')).toHaveText(
|
||||
"modèles prêts à l'emploi",
|
||||
);
|
||||
});
|
||||
|
||||
test('Modal is displayed automatically on first connection', async ({
|
||||
|
||||
@@ -131,64 +131,42 @@ export const createDoc = async (
|
||||
await openHeaderMenu(page);
|
||||
}
|
||||
|
||||
const responsePromiseCreateDoc = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/v1.0/documents/') &&
|
||||
response.status() === 201 &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.waitForURL('**/docs/**', {
|
||||
timeout: 10000,
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
const responseCreateDoc = await responsePromiseCreateDoc;
|
||||
expect(responseCreateDoc.ok()).toBeTruthy();
|
||||
const { id: docId } = (await responseCreateDoc.json()) as { id: string };
|
||||
|
||||
const responsePromiseUpdateDoc = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/api/v1.0/documents/${docId}`) &&
|
||||
response.status() === 200 &&
|
||||
response.request().method() === 'PATCH',
|
||||
);
|
||||
|
||||
const input = page.getByLabel('Document title');
|
||||
await expect(input).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(input).toHaveText('', {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(input).toHaveText('');
|
||||
|
||||
await input.fill(randomDocs[i]);
|
||||
void input.blur();
|
||||
|
||||
const responseUpdateDoc = await responsePromiseUpdateDoc;
|
||||
expect(responseUpdateDoc.ok()).toBeTruthy();
|
||||
await input.blur();
|
||||
}
|
||||
|
||||
return randomDocs;
|
||||
};
|
||||
|
||||
export const verifyDocName = async (page: Page, docName: string) => {
|
||||
const card = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
);
|
||||
await expect(card).toBeVisible({
|
||||
await expect(
|
||||
page.getByLabel('It is the card information about the document.'),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(card).toHaveText(new RegExp(docName), {
|
||||
timeout: 10000,
|
||||
});
|
||||
/*replace toHaveText with toContainText to handle cases where emojis or other characters might be added*/
|
||||
try {
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toContainText(docName, {
|
||||
timeout: 3000,
|
||||
});
|
||||
} catch {
|
||||
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
export const getGridRow = async (page: Page, title: string) => {
|
||||
@@ -250,9 +228,11 @@ export const updateDocTitle = async (page: Page, title: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'Document title' });
|
||||
await expect(input).toHaveText('');
|
||||
await expect(input).toBeVisible();
|
||||
await input.click();
|
||||
await input.fill(title, {
|
||||
force: true,
|
||||
});
|
||||
await input.click();
|
||||
await input.blur();
|
||||
await verifyDocName(page, title);
|
||||
};
|
||||
@@ -268,11 +248,10 @@ export const waitForResponseCreateDoc = (page: Page) => {
|
||||
|
||||
export const mockedDocument = async (page: Page, data: object) => {
|
||||
// document/[ID]/ or document/[ID]/tree/ routes
|
||||
let uuid: string | undefined;
|
||||
const uuid = crypto.randomUUID();
|
||||
await page.route(/.*\/documents\/[^/]+\/(?:$|tree\/.*)/, async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET') && !request.url().includes('page=')) {
|
||||
uuid = request.url().match(/\/documents\/([^/]+)\//)?.[1];
|
||||
const { abilities, ...doc } = data as unknown as {
|
||||
abilities?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.59.1",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.5",
|
||||
"eslint-plugin-docs": "*",
|
||||
@@ -24,7 +24,7 @@
|
||||
"dependencies": {
|
||||
"@types/pngjs": "6.0.5",
|
||||
"convert-stream": "1.0.2",
|
||||
"dotenv": "17.4.2",
|
||||
"dotenv": "17.3.1",
|
||||
"pdf-parse": "2.4.5",
|
||||
"pixelmatch": "7.1.0",
|
||||
"pngjs": "7.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -23,59 +23,59 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@blocknote/code-block": "0.49.0",
|
||||
"@blocknote/core": "0.49.0",
|
||||
"@blocknote/mantine": "0.49.0",
|
||||
"@blocknote/react": "0.49.0",
|
||||
"@blocknote/xl-ai": "0.49.0",
|
||||
"@blocknote/xl-docx-exporter": "0.49.0",
|
||||
"@blocknote/xl-multi-column": "0.49.0",
|
||||
"@blocknote/xl-odt-exporter": "0.49.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.49.0",
|
||||
"@ai-sdk/openai": "3.0.47",
|
||||
"@blocknote/code-block": "0.47.3",
|
||||
"@blocknote/core": "0.47.3",
|
||||
"@blocknote/mantine": "0.47.3",
|
||||
"@blocknote/react": "0.47.3",
|
||||
"@blocknote/xl-ai": "0.47.3",
|
||||
"@blocknote/xl-docx-exporter": "0.47.3",
|
||||
"@blocknote/xl-multi-column": "0.47.3",
|
||||
"@blocknote/xl-odt-exporter": "0.47.3",
|
||||
"@blocknote/xl-pdf-exporter": "0.47.3",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.42",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.38",
|
||||
"@fontsource/material-icons": "5.2.7",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.3.0",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.2.0",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.20.1",
|
||||
"@gouvfr-lasuite/ui-kit": "0.19.10",
|
||||
"@hocuspocus/provider": "3.4.4",
|
||||
"@mantine/core": "9.0.2",
|
||||
"@mantine/hooks": "9.0.2",
|
||||
"@react-aria/live-announcer": "3.5.0",
|
||||
"@mantine/core": "8.3.18",
|
||||
"@mantine/hooks": "8.3.18",
|
||||
"@react-aria/live-announcer": "3.4.4",
|
||||
"@react-pdf/renderer": "4.3.1",
|
||||
"@sentry/nextjs": "10.49.0",
|
||||
"@tanstack/react-query": "5.99.2",
|
||||
"@sentry/nextjs": "10.45.0",
|
||||
"@tanstack/react-query": "5.95.0",
|
||||
"@tiptap/extensions": "*",
|
||||
"ai": "6.0.168",
|
||||
"ai": "6.0.134",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.27",
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.6.0",
|
||||
"i18next": "26.0.6",
|
||||
"i18next": "25.10.4",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.18.1",
|
||||
"luxon": "3.7.2",
|
||||
"next": "16.2.4",
|
||||
"posthog-js": "1.369.4",
|
||||
"next": "16.2.3",
|
||||
"posthog-js": "1.363.1",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.17.0",
|
||||
"react-aria-components": "1.16.0",
|
||||
"react-dom": "*",
|
||||
"react-dropzone": "15.0.0",
|
||||
"react-i18next": "17.0.4",
|
||||
"react-i18next": "16.6.1",
|
||||
"react-intersection-observer": "10.0.3",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"react-select": "5.10.2",
|
||||
"styled-components": "6.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"styled-components": "6.3.12",
|
||||
"use-debounce": "10.1.0",
|
||||
"uuid": "14.0.0",
|
||||
"y-protocols": "1.0.7",
|
||||
"yjs": "*",
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.99.2",
|
||||
"@tanstack/react-query-devtools": "5.95.0",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
@@ -96,18 +96,18 @@
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.4.2",
|
||||
"dotenv": "17.3.1",
|
||||
"eslint-plugin-docs": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jsdom": "29.0.2",
|
||||
"jsdom": "29.0.1",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.8.3",
|
||||
"prettier": "3.8.1",
|
||||
"stylelint": "16.26.1",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"vitest": "4.1.4",
|
||||
"webpack": "5.106.2",
|
||||
"vitest": "4.1.0",
|
||||
"webpack": "5.105.4",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { ComponentPropsWithRef, Ref, forwardRef } from 'react';
|
||||
import { Ref, forwardRef } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxProps } from './Box';
|
||||
import { Box, BoxType } from './Box';
|
||||
|
||||
export type BoxButtonType = BoxProps &
|
||||
Omit<ComponentPropsWithRef<'button'>, keyof BoxProps | 'ref'> & {
|
||||
disabled?: boolean;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
};
|
||||
export type BoxButtonType = Omit<BoxType, 'ref'> & {
|
||||
disabled?: boolean;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Styleless button that extends the Box component.
|
||||
@@ -60,7 +59,7 @@ const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
props.onClick?.(event);
|
||||
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,9 @@ export const TextStyled = styled(Box)<TextProps>`
|
||||
|
||||
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
|
||||
(props, ref) => {
|
||||
return <TextStyled ref={ref} as="span" {...props} />;
|
||||
return (
|
||||
<TextStyled ref={ref as React.Ref<HTMLDivElement>} as="span" {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Button,
|
||||
ButtonProps,
|
||||
Modal,
|
||||
ModalDefaultVariantProps,
|
||||
ModalProps,
|
||||
ModalSize,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
@@ -20,7 +20,7 @@ export type AlertModalProps = {
|
||||
title: string;
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
} & Partial<ModalDefaultVariantProps>;
|
||||
} & Partial<ModalProps>;
|
||||
|
||||
export const AlertModal = ({
|
||||
cancelLabel,
|
||||
|
||||
@@ -49,7 +49,7 @@ export const SideModal = ({
|
||||
return (
|
||||
<>
|
||||
<SideModalStyle width={width} side={side} $css={$css} />
|
||||
<Modal {...modalProps} size={ModalSize.FULL} variant="default">
|
||||
<Modal {...modalProps} size={ModalSize.FULL}>
|
||||
{children}
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -28,7 +28,6 @@ interface ThemeCustomization {
|
||||
onboarding?: {
|
||||
enabled: true;
|
||||
learn_more_url?: string;
|
||||
ready_template_url?: string;
|
||||
};
|
||||
translations?: Resource;
|
||||
waffle?: WaffleType;
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
|
||||
.c__modal__title {
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.c__modal__footer {
|
||||
|
||||
@@ -361,7 +361,7 @@
|
||||
--c--globals--font--weights--medium: 500;
|
||||
--c--globals--font--weights--bold: 600;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--families--base:
|
||||
inter variable, roboto flex variable, sans-serif;
|
||||
--c--globals--font--families--accent:
|
||||
@@ -849,18 +849,6 @@
|
||||
--c--components--forms-checkbox--font-size: var(
|
||||
--c--globals--font--sizes--sm
|
||||
);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
@@ -1743,6 +1731,7 @@
|
||||
--c--globals--font--sizes--xs-alt: 3rem;
|
||||
--c--globals--font--weights--thin: 100;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--families--accent:
|
||||
marianne, inter variable, roboto flex variable, sans-serif;
|
||||
--c--globals--font--families--base:
|
||||
@@ -2550,18 +2539,6 @@
|
||||
--c--components--forms-checkbox--font-size: var(
|
||||
--c--globals--font--sizes--sm
|
||||
);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
|
||||
@@ -372,7 +372,7 @@ export const tokens = {
|
||||
medium: 500,
|
||||
bold: 600,
|
||||
extrabold: 800,
|
||||
black: 800,
|
||||
black: 900,
|
||||
},
|
||||
families: {
|
||||
base: 'Inter Variable, Roboto Flex Variable, sans-serif',
|
||||
@@ -664,26 +664,6 @@ export const tokens = {
|
||||
'body--background-color-hover': '#F0F0F3',
|
||||
},
|
||||
'forms-checkbox': { 'font-size': '0.875rem' },
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
badge: {
|
||||
'font-size': '0.75rem',
|
||||
'border-radius': '12px',
|
||||
@@ -1354,7 +1334,7 @@ export const tokens = {
|
||||
'sm-alt': '3.5rem',
|
||||
'xs-alt': '3rem',
|
||||
},
|
||||
weights: { thin: 100, extrabold: 800 },
|
||||
weights: { thin: 100, extrabold: 800, black: 900 },
|
||||
families: {
|
||||
accent:
|
||||
'Marianne, Inter Variable, Roboto Flex Variable, sans-serif',
|
||||
@@ -1968,26 +1948,6 @@ export const tokens = {
|
||||
'body--background-color-hover': '#F0F0F3',
|
||||
},
|
||||
'forms-checkbox': { 'font-size': '0.875rem' },
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
badge: {
|
||||
'font-size': '0.75rem',
|
||||
'border-radius': '12px',
|
||||
|
||||
@@ -35,7 +35,7 @@ const initialState: ThemeStore = {
|
||||
colorsTokens: defaultTokens.globals.colors,
|
||||
componentTokens: defaultTokens.components,
|
||||
contextualTokens: defaultTokens.contextuals,
|
||||
currentTokens: tokens.themes[DEFAULT_THEME],
|
||||
currentTokens: tokens.themes[DEFAULT_THEME] as Partial<Tokens>,
|
||||
fontSizesTokens: defaultTokens.globals.font.sizes,
|
||||
setTheme: () => {},
|
||||
spacingsTokens: defaultTokens.globals.spacings,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { ComponentPropsWithRef } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Box, BoxProps } from '@/components';
|
||||
import { Box, BoxType } from '@/components';
|
||||
|
||||
type AvatarSvgProps = BoxProps &
|
||||
Omit<ComponentPropsWithRef<'svg'>, keyof BoxProps> & {
|
||||
initials: string;
|
||||
background: string;
|
||||
fontFamily?: string;
|
||||
};
|
||||
type AvatarSvgProps = {
|
||||
initials: string;
|
||||
background: string;
|
||||
fontFamily?: string;
|
||||
} & BoxType;
|
||||
|
||||
export const AvatarSvg: React.FC<AvatarSvgProps> = ({
|
||||
initials,
|
||||
|
||||
@@ -11,15 +11,11 @@ vi.mock('@/stores', () => ({
|
||||
useResponsiveStore: () => ({ isDesktop: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/skeletons', async () => {
|
||||
const actual = await vi.importActual<any>('../../../skeletons');
|
||||
return {
|
||||
...actual,
|
||||
useSkeletonStore: () => ({
|
||||
setIsSkeletonVisible: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/features/skeletons', () => ({
|
||||
useSkeletonStore: () => ({
|
||||
setIsSkeletonVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../doc-management', async () => {
|
||||
const actual = await vi.importActual<any>('../../doc-management');
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useCreateBlockNote } from '@blocknote/react';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
import type { Awareness } from 'y-protocols/awareness';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@@ -34,14 +35,14 @@ import {
|
||||
useUploadStatus,
|
||||
} from '../hook';
|
||||
import { useEditorStore } from '../stores';
|
||||
import { DocsEditorStyle } from '../styles';
|
||||
import { cssEditor } from '../styles';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
import { randomColor } from '../utils';
|
||||
|
||||
import BlockNoteAI from './AI';
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { DocsCommentsStyle, useComments } from './comments/';
|
||||
import { cssComments, useComments } from './comments/';
|
||||
import {
|
||||
AccessibleImageBlock,
|
||||
CalloutBlock,
|
||||
@@ -259,12 +260,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
}, [setEditor, editor]);
|
||||
|
||||
return (
|
||||
<Box ref={refEditorContainer} $height="100%">
|
||||
<DocsEditorStyle />
|
||||
<DocsCommentsStyle
|
||||
canSeeComment={canSeeComment}
|
||||
currentUserAvatarUrl={currentUserAvatarUrl}
|
||||
/>
|
||||
<Box
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(showComments, currentUserAvatarUrl)}
|
||||
`}
|
||||
>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
<TextErrors
|
||||
@@ -348,9 +350,12 @@ export const BlockNoteReader = ({
|
||||
useHeadings(editor);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<DocsEditorStyle />
|
||||
<DocsCommentsStyle canSeeComment={false} />
|
||||
<Box
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(false)}
|
||||
`}
|
||||
>
|
||||
<BlockNoteView
|
||||
className="--docs--main-editor"
|
||||
editor={editor}
|
||||
|
||||
@@ -1,41 +1,37 @@
|
||||
import clsx from 'clsx';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Box, Loading } from '@/components';
|
||||
import { DocHeader } from '@/docs/doc-header/';
|
||||
import {
|
||||
Doc,
|
||||
LinkReach,
|
||||
getDocLinkReach,
|
||||
useCollaboration,
|
||||
useIsCollaborativeEditable,
|
||||
useProviderStore,
|
||||
} from '@/docs/doc-management';
|
||||
import { TableContent } from '@/docs/doc-table-content/';
|
||||
import { useAuth } from '@/features/auth/';
|
||||
import { SkeletonEditorCore, useSkeletonStore } from '@/features/skeletons';
|
||||
import { useSkeletonFadeOut } from '@/features/skeletons/hooks/useFadeOut';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useCollaboration } from '../hook/useCollaboration';
|
||||
|
||||
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
|
||||
|
||||
const DOCS_EDITOR_CLASS = '--docs--doc-editor';
|
||||
|
||||
interface DocEditorContainerProps {
|
||||
docHeader: React.ReactNode;
|
||||
docEditor: React.ReactNode;
|
||||
isDeletedDoc: boolean;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export const DocEditorContainer = ({
|
||||
children,
|
||||
docHeader,
|
||||
docEditor,
|
||||
isDeletedDoc,
|
||||
readOnly,
|
||||
}: PropsWithChildren<DocEditorContainerProps>) => {
|
||||
}: DocEditorContainerProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
return (
|
||||
@@ -43,8 +39,8 @@ export const DocEditorContainer = ({
|
||||
<Box
|
||||
$maxWidth="868px"
|
||||
$width="100%"
|
||||
$flex="1"
|
||||
className={DOCS_EDITOR_CLASS}
|
||||
$height="100%"
|
||||
className="--docs--doc-editor"
|
||||
>
|
||||
<Box
|
||||
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
|
||||
@@ -70,7 +66,7 @@ export const DocEditorContainer = ({
|
||||
})}
|
||||
$height="100%"
|
||||
>
|
||||
{children}
|
||||
{docEditor}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -86,19 +82,23 @@ interface DocEditorProps {
|
||||
export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
useCollaboration(doc.id);
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
const isDeletedDoc = !!doc.deleted_at;
|
||||
const readOnly =
|
||||
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const isProviderReady = isReady && provider;
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [hasTracked, setHasTracked] = useState(false);
|
||||
const { authenticated } = useAuth();
|
||||
const isPublicDoc = getDocLinkReach(doc) === LinkReach.PUBLIC;
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
|
||||
useEffect(() => {
|
||||
setIsSkeletonVisible(false);
|
||||
}, [setIsSkeletonVisible, doc.id]);
|
||||
if (isProviderReady) {
|
||||
setIsSkeletonVisible(false);
|
||||
}
|
||||
}, [isProviderReady, setIsSkeletonVisible]);
|
||||
|
||||
/**
|
||||
* Track doc view event only once per doc change
|
||||
@@ -124,57 +124,30 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
});
|
||||
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
|
||||
|
||||
if (!isProviderReady || provider?.configuration.name !== doc.id) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDesktop && <TableContent selector={`.${DOCS_EDITOR_CLASS}`} />}
|
||||
{isDesktop && <TableContent />}
|
||||
<DocEditorContainer
|
||||
docHeader={<DocHeader doc={doc} />}
|
||||
docEditor={
|
||||
readOnly ? (
|
||||
<BlockNoteReader
|
||||
initialContent={provider.document.getXmlFragment(
|
||||
'document-store',
|
||||
)}
|
||||
docId={doc.id}
|
||||
/>
|
||||
) : (
|
||||
<BlockNoteEditor doc={doc} provider={provider} />
|
||||
)
|
||||
}
|
||||
isDeletedDoc={isDeletedDoc}
|
||||
readOnly={readOnly}
|
||||
>
|
||||
<DocCoreEditor doc={doc} readOnly={readOnly} />
|
||||
</DocEditorContainer>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DocCoreEditorProps {
|
||||
doc: Doc;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
|
||||
useCollaboration(doc.id);
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const isProviderReady = isReady && provider;
|
||||
const showContent = !!(
|
||||
isProviderReady && provider?.configuration.name === doc.id
|
||||
);
|
||||
const { skeletonVisible, isFadingOut } = useSkeletonFadeOut(showContent);
|
||||
|
||||
if (
|
||||
skeletonVisible ||
|
||||
!isProviderReady ||
|
||||
provider?.configuration.name !== doc.id
|
||||
) {
|
||||
return (
|
||||
<SkeletonEditorCore
|
||||
isFadingOut={isFadingOut}
|
||||
$css={css`
|
||||
padding-top: 0px;
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<BlockNoteReader
|
||||
initialContent={provider.document.getXmlFragment('document-store')}
|
||||
docId={doc.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <BlockNoteEditor doc={doc} provider={provider} />;
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state:
|
||||
const state = states.get(clientId) as
|
||||
| {
|
||||
[DocsThreadStore.COMMENTS_PING]?: {
|
||||
at: number;
|
||||
@@ -76,7 +76,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
threadId: string;
|
||||
};
|
||||
}
|
||||
| undefined = states.get(clientId);
|
||||
| undefined;
|
||||
|
||||
const ping = state?.commentsPing;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
export const DocsCommentsStyle = createGlobalStyle<{
|
||||
canSeeComment: boolean;
|
||||
currentUserAvatarUrl?: string;
|
||||
}>`
|
||||
.--docs--main-editor.bn-root,
|
||||
.--docs--main-editor.bn-root .ProseMirror {
|
||||
export const cssComments = (
|
||||
canSeeComment: boolean,
|
||||
currentUserAvatarUrl?: string,
|
||||
) => css`
|
||||
& .--docs--main-editor,
|
||||
& .--docs--main-editor .ProseMirror {
|
||||
// Comments marks in the editor
|
||||
.bn-editor {
|
||||
// Resets blocknote comments styles
|
||||
@@ -14,31 +14,30 @@ export const DocsCommentsStyle = createGlobalStyle<{
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
${({ canSeeComment }) =>
|
||||
canSeeComment &&
|
||||
css`
|
||||
.bn-thread-mark:not([data-orphan='true']) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
|
||||
transparent
|
||||
${canSeeComment &&
|
||||
css`
|
||||
.bn-thread-mark:not([data-orphan='true']) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
|
||||
transparent
|
||||
);
|
||||
border-bottom: 2px solid
|
||||
var(--c--contextuals--background--palette--yellow--secondary);
|
||||
|
||||
mix-blend-mode: multiply;
|
||||
|
||||
transition:
|
||||
background-color var(--c--globals--transitions--duration),
|
||||
border-bottom-color var(--c--globals--transitions--duration);
|
||||
|
||||
&:has(.bn-thread-mark-selected) {
|
||||
background-color: var(
|
||||
--c--contextuals--background--palette--yellow--tertiary
|
||||
);
|
||||
border-bottom: 2px solid
|
||||
var(--c--contextuals--background--palette--yellow--secondary);
|
||||
|
||||
mix-blend-mode: multiply;
|
||||
|
||||
transition:
|
||||
background-color var(--c--globals--transitions--duration),
|
||||
border-bottom-color var(--c--globals--transitions--duration);
|
||||
|
||||
&:has(.bn-thread-mark-selected) {
|
||||
background-color: var(
|
||||
--c--contextuals--background--palette--yellow--tertiary
|
||||
);
|
||||
}
|
||||
}
|
||||
`}
|
||||
}
|
||||
`}
|
||||
|
||||
[data-show-selection] {
|
||||
color: HighlightText;
|
||||
@@ -83,8 +82,6 @@ export const DocsCommentsStyle = createGlobalStyle<{
|
||||
|
||||
.bn-thread-comment {
|
||||
padding: 8px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0px;
|
||||
|
||||
& .bn-editor {
|
||||
padding-left: var(--c--globals--spacings--lg);
|
||||
@@ -108,14 +105,10 @@ export const DocsCommentsStyle = createGlobalStyle<{
|
||||
|
||||
// Top bar (Name / Date / Actions) when actions displayed
|
||||
&:has(.bn-comment-actions) {
|
||||
& > .mantine-Group-root:first-child {
|
||||
& > .mantine-Group-root {
|
||||
max-width: 70%;
|
||||
right: 0.3rem !important;
|
||||
top: 0.3rem !important;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
#fff 90%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bn-menu-dropdown {
|
||||
@@ -131,6 +124,7 @@ export const DocsCommentsStyle = createGlobalStyle<{
|
||||
|
||||
// Date
|
||||
span.mantine-focus-auto {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.bn-comment-actions {
|
||||
@@ -156,8 +150,7 @@ export const DocsCommentsStyle = createGlobalStyle<{
|
||||
}
|
||||
|
||||
// Actions button edit comment
|
||||
.bn-root + .bn-comment-actions-wrapper {
|
||||
margin-top: var(--c--globals--spacings--2xs);
|
||||
.bn-container + .bn-comment-actions-wrapper {
|
||||
.bn-comment-actions {
|
||||
flex-direction: row-reverse;
|
||||
background: none;
|
||||
@@ -208,8 +201,9 @@ export const DocsCommentsStyle = createGlobalStyle<{
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 26px;
|
||||
background-image: ${({ currentUserAvatarUrl }) =>
|
||||
currentUserAvatarUrl ? `url("${currentUserAvatarUrl}")` : 'none'};
|
||||
background-image: ${currentUserAvatarUrl
|
||||
? `url("${currentUserAvatarUrl}")`
|
||||
: 'none'};
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
|
||||
@@ -41,7 +41,7 @@ export const LinkSelected = ({
|
||||
const router = useRouter();
|
||||
const href = `/docs/${docId}/`;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
// If ctrl or command is pressed, it opens a new tab. If shift is pressed, it opens a new window
|
||||
@@ -53,7 +53,7 @@ export const LinkSelected = ({
|
||||
};
|
||||
|
||||
// This triggers on middle-mouse click
|
||||
const handleAuxClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleAuxClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,13 +39,7 @@ const withMultiColumnNoDropHandler = <
|
||||
});
|
||||
};
|
||||
|
||||
type ModulesXL =
|
||||
| (Omit<typeof XLMultiColumn, 'withMultiColumn'> & {
|
||||
withMultiColumn: typeof withMultiColumnNoDropHandler;
|
||||
})
|
||||
| undefined;
|
||||
|
||||
let modulesXL: ModulesXL = undefined;
|
||||
let modulesXL = undefined;
|
||||
if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
|
||||
modulesXL = {
|
||||
...XLMultiColumn,
|
||||
@@ -53,4 +47,10 @@ if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
|
||||
};
|
||||
}
|
||||
|
||||
export default modulesXL;
|
||||
type ModulesXL =
|
||||
| (Omit<typeof XLMultiColumn, 'withMultiColumn'> & {
|
||||
withMultiColumn: typeof withMultiColumnNoDropHandler;
|
||||
})
|
||||
| undefined;
|
||||
|
||||
export default modulesXL as ModulesXL;
|
||||
|
||||
@@ -1,306 +1,266 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
export const DocsEditorStyle = createGlobalStyle`
|
||||
.bn-root {
|
||||
export const cssEditor = css`
|
||||
.mantine-Menu-itemLabel,
|
||||
.mantine-Button-label {
|
||||
font-family: var(--c--components--button--font-family);
|
||||
}
|
||||
|
||||
&,
|
||||
& > .bn-container,
|
||||
& .ProseMirror {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bn-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mantine-Menu-itemLabel,
|
||||
.mantine-Button-label {
|
||||
font-family: var(--c--components--button--font-family);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token Mantine
|
||||
*/
|
||||
/**
|
||||
* Token Mantime
|
||||
*/
|
||||
& > .bn-container {
|
||||
--bn-colors-editor-text: var(
|
||||
--c--contextuals--content--semantic--neutral--primary
|
||||
);
|
||||
--bn-colors-side-menu: var(
|
||||
--c--contextuals--content--semantic--neutral--tertiary
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure long placeholder text is truncated with ellipsis
|
||||
*/
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
|
||||
position: relative;
|
||||
}
|
||||
/**
|
||||
* Ensure long placeholder text is truncated with ellipsis
|
||||
*/
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure images with unsafe URLs are not interactive
|
||||
*/
|
||||
img.bn-visual-media[src*='-unsafe'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
/**
|
||||
* Ensure images with unsafe URLs are not interactive
|
||||
*/
|
||||
img.bn-visual-media[src*='-unsafe'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaboration cursor styles
|
||||
*/
|
||||
.collaboration-cursor-custom__base {
|
||||
position: relative;
|
||||
}
|
||||
.collaboration-cursor-custom__caret {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
bottom: 4%;
|
||||
left: -1px;
|
||||
}
|
||||
/**
|
||||
* Collaboration cursor styles
|
||||
*/
|
||||
.collaboration-cursor-custom__base {
|
||||
position: relative;
|
||||
}
|
||||
.collaboration-cursor-custom__caret {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
bottom: 4%;
|
||||
left: -1px;
|
||||
}
|
||||
.collaboration-cursor-custom__label {
|
||||
color: #0d0d0d;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: -17px;
|
||||
left: 0px;
|
||||
padding: 0px 6px;
|
||||
border-radius: 0px;
|
||||
white-space: nowrap;
|
||||
transition: clip-path 0.3s ease-in-out;
|
||||
border-radius: 4px 4px 4px 0;
|
||||
box-shadow: inset -2px 2px 6px #ffffff00;
|
||||
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
|
||||
}
|
||||
.collaboration-cursor-custom__base[data-active]
|
||||
.collaboration-cursor-custom__label {
|
||||
color: #0d0d0d;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: -17px;
|
||||
left: 0px;
|
||||
padding: 0px 6px;
|
||||
border-radius: 0px;
|
||||
white-space: nowrap;
|
||||
transition: clip-path 0.3s ease-in-out;
|
||||
border-radius: 4px 4px 4px 0;
|
||||
box-shadow: inset -2px 2px 6px #ffffff00;
|
||||
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
|
||||
}
|
||||
.collaboration-cursor-custom__base[data-active]
|
||||
.collaboration-cursor-custom__label {
|
||||
pointer-events: none;
|
||||
box-shadow: inset -2px 2px 6px #ffffff88;
|
||||
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
|
||||
}
|
||||
pointer-events: none;
|
||||
box-shadow: inset -2px 2px 6px #ffffff88;
|
||||
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
/**
|
||||
* Side menu
|
||||
*/
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: var(
|
||||
--c--contextuals--content--semantic--neutral--tertiary
|
||||
) !important;
|
||||
}
|
||||
/**
|
||||
* Side menu
|
||||
*/
|
||||
.bn-side-menu[data-block-type='heading'][data-level='1'] {
|
||||
height: 54px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='2'] {
|
||||
height: 43px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='3'] {
|
||||
height: 35px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='divider'] {
|
||||
height: 38px;
|
||||
}
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: var(
|
||||
--c--contextuals--content--semantic--neutral--tertiary
|
||||
) !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callout, Paragraph and Heading blocks
|
||||
*/
|
||||
.bn-block {
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-outer {
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block > .bn-block-content[data-background-color] {
|
||||
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
|
||||
.bn-inline-content {
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
color: var(--c--globals--colors--gray-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
/**
|
||||
* Callout, Paragraph and Heading blocks
|
||||
*/
|
||||
.bn-block {
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-outer {
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block > .bn-block-content[data-background-color] {
|
||||
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
|
||||
.bn-inline-content {
|
||||
text-decoration: none;
|
||||
}
|
||||
.bn-default-styles h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
.bn-default-styles h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.bn-default-styles h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
a {
|
||||
color: var(--c--globals--colors--gray-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bn-block-group
|
||||
.bn-block-group
|
||||
.bn-block-group
|
||||
.bn-block-outer:not([data-prev-depth-changed]):before {
|
||||
border-left: none;
|
||||
}
|
||||
.bn-block-outer:not([data-prev-depth-changed]):before {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.bn-toolbar {
|
||||
max-width: 95vw;
|
||||
}
|
||||
.bn-toolbar {
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quotes
|
||||
*/
|
||||
blockquote {
|
||||
border-left: 4px solid var(--c--globals--colors--gray-300);
|
||||
font-style: italic;
|
||||
}
|
||||
/**
|
||||
* Quotes
|
||||
*/
|
||||
blockquote {
|
||||
border-left: 4px solid var(--c--globals--colors--gray-300);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* AI
|
||||
*/
|
||||
ins,
|
||||
[data-type='modification'] {
|
||||
background: var(--c--globals--colors--brand-100);
|
||||
border-bottom: 2px solid var(--c--globals--colors--brand-300);
|
||||
color: var(--c--globals--colors--brand-700);
|
||||
}
|
||||
ins,
|
||||
[data-type='modification'] {
|
||||
background: var(--c--globals--colors--brand-100);
|
||||
border-bottom: 2px solid var(--c--globals--colors--brand-300);
|
||||
color: var(--c--globals--colors--brand-700);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider
|
||||
*/
|
||||
[data-content-type='divider'] hr {
|
||||
background: #d3d2cf;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
border: 1px solid #d3d2cf;
|
||||
}
|
||||
.bn-side-menu[data-block-type='divider'] {
|
||||
height: 38px;
|
||||
}
|
||||
/**
|
||||
* Divider
|
||||
*/
|
||||
[data-content-type='divider'] hr {
|
||||
background: #d3d2cf;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
border: 1px solid #d3d2cf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checklist items
|
||||
*/
|
||||
.bn-block-content[data-content-type='checkListItem'] > div > input {
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid
|
||||
var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
align-self: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'] > div > input:checked {
|
||||
background-color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
border-color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem']
|
||||
> div
|
||||
> input:checked::after {
|
||||
content: 'check';
|
||||
font-family: 'Material Symbols Outlined Variable', sans-serif;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--c--contextuals--content--semantic--overlay--primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
/**
|
||||
* Checklist items
|
||||
*/
|
||||
.bn-block-content[data-content-type='checkListItem'] > div > input {
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid
|
||||
var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
align-self: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'] > div > input:checked {
|
||||
background-color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
border-color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem']
|
||||
> div
|
||||
> input:checked::after {
|
||||
content: 'check';
|
||||
font-family: 'Material Symbols Outlined Variable', sans-serif;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--c--contextuals--content--semantic--overlay--primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headings
|
||||
* Ensure consistent spacing between headings and paragraphs
|
||||
*/
|
||||
[data-content-type='heading'] {
|
||||
--level: 1.875rem;
|
||||
&[data-level='2'] {
|
||||
--level: 1.5rem;
|
||||
}
|
||||
&[data-level='3'] {
|
||||
--level: 1.25rem;
|
||||
}
|
||||
&[data-level='4'] {
|
||||
--level: 1.125rem;
|
||||
}
|
||||
&[data-level='5'] {
|
||||
--level: 1rem;
|
||||
}
|
||||
&[data-level='6'] {
|
||||
--level: 0.875rem;
|
||||
}
|
||||
/**
|
||||
* Ensure consistent spacing between headings and paragraphs
|
||||
*/
|
||||
& .bn-block-outer:not(:first-child) {
|
||||
&:has(h1) {
|
||||
margin-top: 32px;
|
||||
}
|
||||
&:has(h2) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
&:has(h3) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
& .bn-inline-content code {
|
||||
background-color: gainsboro;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
& .bn-editor {
|
||||
padding-right: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 560px) {
|
||||
.--docs--doc-readonly & .bn-editor {
|
||||
padding-left: 10px;
|
||||
}
|
||||
& .bn-editor {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='1'] {
|
||||
height: 54px;
|
||||
height: 46px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='2'] {
|
||||
height: 43px;
|
||||
height: 40px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='3'] {
|
||||
height: 35px;
|
||||
height: 40px;
|
||||
}
|
||||
& .bn-default-styles h1 {
|
||||
font-size: 1.875rem;
|
||||
& .bn-editor h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
& .bn-default-styles h2 {
|
||||
font-size: 1.5rem;
|
||||
& .bn-editor h2 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
& .bn-default-styles h3 {
|
||||
font-size: 1.25rem;
|
||||
& .bn-editor h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
& .bn-default-styles h4 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
& .bn-default-styles h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
& .bn-default-styles h6 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
& .bn-block-outer:not(:first-child) {
|
||||
&:has(h1) {
|
||||
margin-top: 32px;
|
||||
}
|
||||
&:has(h2) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
&:has(h3) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
& .bn-inline-content code {
|
||||
background-color: gainsboro;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
& .bn-editor {
|
||||
padding-right: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 560px) {
|
||||
.--docs--doc-readonly & .bn-editor {
|
||||
padding-left: 10px;
|
||||
}
|
||||
& .bn-editor {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='1'] {
|
||||
height: 46px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='2'] {
|
||||
height: 40px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='3'] {
|
||||
height: 40px;
|
||||
}
|
||||
[data-content-type='heading'] {
|
||||
--level: 1.6rem;
|
||||
&[data-level='2'] {
|
||||
--level: 1.35rem;
|
||||
}
|
||||
&[data-level='3'] {
|
||||
--level: 1.2rem;
|
||||
}
|
||||
&[data-level='4'] {
|
||||
--level: 1.125rem;
|
||||
}
|
||||
&[data-level='5'] {
|
||||
--level: 1rem;
|
||||
}
|
||||
&[data-level='6'] {
|
||||
--level: 0.875rem;
|
||||
}
|
||||
}
|
||||
& .bn-editor h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
font-size: 14px;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { DocumentProps, pdf } from '@react-pdf/renderer';
|
||||
import jsonemoji from 'emoji-datasource-apple' with { type: 'json' };
|
||||
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
|
||||
import i18next from 'i18next';
|
||||
import JSZip from 'jszip';
|
||||
import { cloneElement, isValidElement, useState } from 'react';
|
||||
|
||||
@@ -16,4 +16,6 @@ if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
|
||||
};
|
||||
}
|
||||
|
||||
export default modulesExport;
|
||||
type ModulesExport = typeof modulesExport;
|
||||
|
||||
export default modulesExport as ModulesExport;
|
||||
|
||||
@@ -84,7 +84,7 @@ const PRINT_ONLY_CONTENT_CSS = `
|
||||
|
||||
/* Ensure BlockNote content flows properly */
|
||||
.bn-editor,
|
||||
.bn-root,
|
||||
.bn-container,
|
||||
.--docs--main-editor,
|
||||
.bn-block-outer {
|
||||
height: auto !important;
|
||||
|
||||
@@ -68,7 +68,7 @@ export const BoutonShare = ({
|
||||
/>
|
||||
}
|
||||
onClick={(e) => {
|
||||
addLastFocus(e.currentTarget);
|
||||
addLastFocus(e.currentTarget as HTMLElement);
|
||||
open();
|
||||
}}
|
||||
size="medium"
|
||||
@@ -85,7 +85,7 @@ export const BoutonShare = ({
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
onClick={(e) => {
|
||||
addLastFocus(e.currentTarget);
|
||||
addLastFocus(e.currentTarget as HTMLElement);
|
||||
open();
|
||||
}}
|
||||
size="medium"
|
||||
|
||||
@@ -244,7 +244,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
<Icon iconName="download" $color="inherit" aria-hidden={true} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
addLastFocus(e.currentTarget);
|
||||
addLastFocus(e.currentTarget as HTMLElement);
|
||||
setIsModalExportOpen(true);
|
||||
}}
|
||||
size={isSmallMobile ? 'small' : 'medium'}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './useCollaboration';
|
||||
export * from './useCopyDocLink';
|
||||
export * from './useCreateChildDocTree';
|
||||
export * from './useDocTitleUpdate';
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useCollaborationUrl } from '@/core/config';
|
||||
import { KEY_DOC } from '@/docs/doc-management/api/useDoc';
|
||||
import {
|
||||
KEY_DOC_CONTENT,
|
||||
useDocContent,
|
||||
@@ -11,15 +10,13 @@ import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore'
|
||||
import { useIsOffline } from '@/features/service-worker/hooks/useOffline';
|
||||
import { useBroadcastStore } from '@/stores/useBroadcastStore';
|
||||
|
||||
import { KEY_DOC } from '../api';
|
||||
|
||||
export const useCollaboration = (room: string) => {
|
||||
const collaborationUrl = useCollaborationUrl(room);
|
||||
const { addTask } = useBroadcastStore();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
setBroadcastProvider,
|
||||
cleanupBroadcast,
|
||||
provider: broadcastProvider,
|
||||
} = useBroadcastStore();
|
||||
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
|
||||
const {
|
||||
provider,
|
||||
createProvider,
|
||||
@@ -68,7 +65,7 @@ export const useCollaboration = (room: string) => {
|
||||
* when the document visibility changes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!room || broadcastProvider?.document?.guid !== room) {
|
||||
if (!room || !isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +74,7 @@ export const useCollaboration = (room: string) => {
|
||||
queryKey: [KEY_DOC, { id: room }],
|
||||
});
|
||||
});
|
||||
}, [addTask, room, queryClient, broadcastProvider?.document?.guid]);
|
||||
}, [addTask, room, queryClient, isReady]);
|
||||
|
||||
/**
|
||||
* Set the provider when the collaboration URL and the document content are available.
|
||||
@@ -14,7 +14,7 @@ import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
|
||||
import { Heading } from './Heading';
|
||||
|
||||
export const TableContent = ({ selector }: { selector: string }) => {
|
||||
export const TableContent = () => {
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const [containerHeight, setContainerHeight] = useState('100vh');
|
||||
const { headings } = useHeadingStore();
|
||||
@@ -27,29 +27,11 @@ export const TableContent = ({ selector }: { selector: string }) => {
|
||||
* Calculate container height based on the scrollable content
|
||||
*/
|
||||
useEffect(() => {
|
||||
const layout = document.querySelector<HTMLElement>(selector);
|
||||
if (!layout) {
|
||||
return;
|
||||
const mainLayout = document.getElementById(MAIN_LAYOUT_ID);
|
||||
if (mainLayout) {
|
||||
setContainerHeight(`${mainLayout.scrollHeight}px`);
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const updateHeight = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
setContainerHeight(`${layout.scrollHeight}px`);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const observer = new ResizeObserver(updateHeight);
|
||||
observer.observe(layout);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [selector]);
|
||||
}, []);
|
||||
|
||||
const onOpen = () => {
|
||||
setIsOpen(true);
|
||||
|
||||
@@ -274,16 +274,11 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
/* Remove outline from TreeViewItem wrapper elements */
|
||||
.c__tree-view--row {
|
||||
outline: none !important;
|
||||
pointer-events: initial;
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.c__tree-view--node {
|
||||
pointer-events: inherit;
|
||||
}
|
||||
|
||||
.c__tree-view--container {
|
||||
z-index: 1;
|
||||
margin-top: -10px;
|
||||
|
||||
@@ -85,14 +85,15 @@ export const DocVersionEditor = ({
|
||||
return (
|
||||
<DocEditorContainer
|
||||
docHeader={<DocVersionHeader />}
|
||||
docEditor={
|
||||
<BlockNoteReader
|
||||
initialContent={initialContent}
|
||||
docId={version.id}
|
||||
isMainEditor={false}
|
||||
/>
|
||||
}
|
||||
isDeletedDoc={false}
|
||||
readOnly={true}
|
||||
>
|
||||
<BlockNoteReader
|
||||
initialContent={initialContent}
|
||||
docId={version.id}
|
||||
isMainEditor={false}
|
||||
/>
|
||||
</DocEditorContainer>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export const DocsGridItemSharedButton = ({ doc, disabled }: Props) => {
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
addLastFocus(event.currentTarget);
|
||||
addLastFocus(event.currentTarget as HTMLElement);
|
||||
shareModal.open();
|
||||
}}
|
||||
color="brand"
|
||||
|
||||
@@ -109,7 +109,6 @@ export const useImport = ({ onDragOver }: UseImportProps) => {
|
||||
});
|
||||
},
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
});
|
||||
const { mutate: importDoc, isPending } = useImportDoc();
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ import {
|
||||
ModalSize,
|
||||
OnboardingModal,
|
||||
type OnboardingModalProps,
|
||||
OnboardingStep,
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
@@ -12,29 +10,9 @@ import { useConfig } from '@/core/config/api/useConfig';
|
||||
|
||||
import { useOnboardingSteps } from '../hooks/useOnboardingSteps';
|
||||
|
||||
/**
|
||||
* typing was not correct on ui-kit side for the description prop of OnboardingStep,
|
||||
* it can be a string or a ReactNode but was typed as string only, so we need to override the
|
||||
* type here to be able to use ReactNode
|
||||
*/
|
||||
type OnboardingStepFixed = Omit<OnboardingStep, 'description'> & {
|
||||
description?: ReactNode;
|
||||
};
|
||||
|
||||
type OnboardingModalPropsFixed = Omit<OnboardingModalProps, 'steps'> & {
|
||||
steps?: OnboardingStepFixed[];
|
||||
};
|
||||
|
||||
const OnboardingModalFixed =
|
||||
OnboardingModal as React.ComponentType<OnboardingModalPropsFixed>;
|
||||
|
||||
const OnBoardingStyle = createGlobalStyle`
|
||||
.c__onboarding-modal__steps{
|
||||
height: auto;
|
||||
|
||||
& a{
|
||||
color:inherit;
|
||||
}
|
||||
}
|
||||
.c__onboarding-modal__content {
|
||||
height: 350px;
|
||||
@@ -54,7 +32,7 @@ const OnBoardingStyle = createGlobalStyle`
|
||||
*:not(.material-icons):not(.material-icons-filled):not(
|
||||
.material-symbols-outlined
|
||||
) {
|
||||
font-family: var(--c--globals--font--families--base);
|
||||
font-family: Marianne, Inter, Roboto Flex Variable, sans-serif;
|
||||
}
|
||||
|
||||
/* Separator between content and footer actions/link */
|
||||
@@ -78,10 +56,6 @@ const OnBoardingStyle = createGlobalStyle`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
a{
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
& .c__onboarding-modal__body{
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -107,7 +81,7 @@ export const OnBoarding = (props: OnBoardingProps) => {
|
||||
return (
|
||||
<>
|
||||
{props.isOpen ? <OnBoardingStyle /> : null}
|
||||
<OnboardingModalFixed
|
||||
<OnboardingModal
|
||||
size={ModalSize.LARGE}
|
||||
appName={t('Discover Docs')}
|
||||
mainTitle={t('Learn the core principles')}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { type OnboardingStep } from '@gouvfr-lasuite/ui-kit';
|
||||
import Image from 'next/image';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConfig } from '@/core';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import DragIndicatorIcon from '../assets/drag_indicator.svg';
|
||||
@@ -17,9 +16,6 @@ export interface OnboardingStepsData {
|
||||
|
||||
export const useOnboardingSteps = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const readyTemplateUrl =
|
||||
config?.theme_customization?.onboarding?.ready_template_url;
|
||||
const { contextualTokens, colorsTokens } = useCunninghamTheme();
|
||||
const activeColor =
|
||||
contextualTokens.content.semantic.brand.tertiary ??
|
||||
@@ -126,21 +122,8 @@ export const useOnboardingSteps = () => {
|
||||
</OnboardingStepIcon>
|
||||
),
|
||||
title: t('Draw inspiration from the content library'),
|
||||
description: (
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="Start from <Link>ready-made templates</Link> for common use cases, then customize them to match your workflow in minutes."
|
||||
components={{
|
||||
Link: (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={readyTemplateUrl}
|
||||
aria-label={t('Ready-made templates (opens in a new tab)')}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
description: t(
|
||||
'Start from ready-made templates for common use cases, then customize them to match your workflow in minutes.',
|
||||
),
|
||||
content: (
|
||||
<Image
|
||||
|
||||
@@ -50,7 +50,7 @@ export class RequestSerializer {
|
||||
|
||||
public static arrayBufferToString(buffer: ArrayBufferLike) {
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(buffer);
|
||||
return decoder.decode(buffer as ArrayBuffer);
|
||||
}
|
||||
|
||||
public static arrayBufferToJson<T>(buffer: ArrayBufferLike) {
|
||||
|
||||
@@ -15,7 +15,7 @@ const mockServiceWorkerScope = {
|
||||
(global as any).self = {
|
||||
...global,
|
||||
clients: mockServiceWorkerScope.clients,
|
||||
};
|
||||
} as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
describe('OfflinePlugin', () => {
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { css, keyframes } from 'styled-components';
|
||||
|
||||
import { Box, BoxType } from '@/components';
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { SkeletonCircle, SkeletonLine } from './SkeletionUI';
|
||||
|
||||
export const DocEditorSkeleton = () => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Editor Container */}
|
||||
@@ -16,117 +17,80 @@ export const DocEditorSkeleton = () => {
|
||||
$height="100%"
|
||||
className="--docs--doc-editor-skeleton"
|
||||
>
|
||||
<SkeletonEditorHeader />
|
||||
<SkeletonEditorCore />
|
||||
{/* Header Skeleton */}
|
||||
<Box
|
||||
$padding={{ horizontal: isDesktop ? '70px' : 'base' }}
|
||||
className="--docs--doc-editor-header-skeleton"
|
||||
>
|
||||
<Box
|
||||
$width="100%"
|
||||
$padding={{ top: isDesktop ? '65px' : 'md' }}
|
||||
$gap={spacingsTokens['base']}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$width="100%"
|
||||
$padding={{ bottom: 'xs' }}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$css="flex:1;"
|
||||
$gap="0.5rem 1rem"
|
||||
$align="center"
|
||||
$maxWidth="100%"
|
||||
>
|
||||
{/* Title and metadata skeleton */}
|
||||
<Box $gap="0.25rem" $css="flex:1;">
|
||||
{/* Title - "Untitled Document" style */}
|
||||
<SkeletonLine $width="35%" $height="40px" />
|
||||
|
||||
{/* Metadata (role and last update) */}
|
||||
<Box $direction="row" $gap="0.5rem" $align="center">
|
||||
<SkeletonLine $maxWidth="260px" $height="12px" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Toolbox skeleton (buttons) */}
|
||||
<Box $direction="row" $gap="0.75rem" $align="center">
|
||||
{/* Share button */}
|
||||
<SkeletonLine $width="90px" $height="40px" />
|
||||
{/* Download icon */}
|
||||
<SkeletonCircle $width="40px" $height="40px" />
|
||||
{/* Menu icon */}
|
||||
<SkeletonCircle $width="40px" $height="40px" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<SkeletonLine $height="1px" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<Box
|
||||
$direction="row"
|
||||
$width="100%"
|
||||
$css="overflow-x: clip; flex: 1;"
|
||||
$position="relative"
|
||||
className="--docs--doc-editor-content-skeleton"
|
||||
>
|
||||
<Box
|
||||
$css="flex:1;"
|
||||
$position="relative"
|
||||
$width="100%"
|
||||
$padding={{ horizontal: isDesktop ? '70px' : 'base', top: 'lg' }}
|
||||
>
|
||||
{/* Placeholder text similar to screenshot */}
|
||||
<Box $gap="0rem">
|
||||
{/* Single placeholder line like in the screenshot */}
|
||||
<SkeletonLine $width="85%" $height="20px" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonEditorHeader = () => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
|
||||
className="--docs--doc-editor-header-skeleton"
|
||||
>
|
||||
<Box
|
||||
$width="100%"
|
||||
$padding={{ top: isDesktop ? '65px' : 'md' }}
|
||||
$gap={spacingsTokens['base']}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$width="100%"
|
||||
$padding={{ bottom: 'xs' }}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$css="flex:1;"
|
||||
$gap="0.5rem 1rem"
|
||||
$align="center"
|
||||
$maxWidth="100%"
|
||||
>
|
||||
{/* Title and metadata skeleton */}
|
||||
<Box $gap="0.25rem" $css="flex:1;">
|
||||
{/* Title - "Untitled Document" style */}
|
||||
<SkeletonLine $width="35%" $height="40px" />
|
||||
|
||||
{/* Metadata (role and last update) */}
|
||||
<Box $direction="row" $gap="0.5rem" $align="center">
|
||||
<SkeletonLine $maxWidth="260px" $height="12px" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Toolbox skeleton (buttons) */}
|
||||
<Box $direction="row" $gap={spacingsTokens['t']} $align="center">
|
||||
{/* Share button */}
|
||||
<SkeletonLine $width="90px" $height="40px" />
|
||||
{/* Download icon */}
|
||||
<SkeletonCircle $width="40px" $height="40px" />
|
||||
{/* Menu icon */}
|
||||
<SkeletonCircle $width="40px" $height="40px" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<SkeletonLine $height="1px" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const SKELETON_FADE_DURATION_MS = 150;
|
||||
const skeletonFadeOut = keyframes`
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
`;
|
||||
|
||||
type SkeletonEditorCoreProps = Partial<BoxType> & {
|
||||
isFadingOut?: boolean;
|
||||
};
|
||||
|
||||
export const SkeletonEditorCore = ({
|
||||
isFadingOut,
|
||||
$css,
|
||||
...props
|
||||
}: SkeletonEditorCoreProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$width="100%"
|
||||
$css="overflow-x: clip; flex: 1;"
|
||||
$position="relative"
|
||||
className="--docs--doc-editor-content-skeleton"
|
||||
>
|
||||
<Box
|
||||
$position="relative"
|
||||
$width="100%"
|
||||
$padding={{ horizontal: isDesktop ? '54px' : 'base', top: 'md' }}
|
||||
$flex="1"
|
||||
$css={css`
|
||||
${$css}
|
||||
${isFadingOut &&
|
||||
css`
|
||||
animation: ${skeletonFadeOut} ${SKELETON_FADE_DURATION_MS}ms
|
||||
ease-in-out forwards;
|
||||
`}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<Box $gap="1.5rem">
|
||||
<SkeletonLine $width="65%" $height="35px" />
|
||||
<SkeletonLine $width="55%" $height="25px" />
|
||||
<SkeletonLine $width="35%" $height="20px" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { SKELETON_FADE_DURATION_MS } from '../components/DocEditorSkeleton';
|
||||
|
||||
export const useSkeletonFadeOut = (showContent: boolean) => {
|
||||
const [skeletonVisible, setSkeletonVisible] = useState(!showContent);
|
||||
const [isFadingOut, setIsFadingOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showContent) {
|
||||
setIsFadingOut(true);
|
||||
const timer = setTimeout(
|
||||
() => setSkeletonVisible(false),
|
||||
SKELETON_FADE_DURATION_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setSkeletonVisible(true);
|
||||
setIsFadingOut(false);
|
||||
}
|
||||
}, [showContent]);
|
||||
|
||||
return { skeletonVisible, isFadingOut };
|
||||
};
|
||||
@@ -36,6 +36,7 @@ if (!isInitialized && !i18next.isInitialized) {
|
||||
lowerCaseLng: true,
|
||||
nsSeparator: false,
|
||||
keySeparator: false,
|
||||
showSupportNotice: false,
|
||||
})
|
||||
.then(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
|
||||
@@ -1482,7 +1482,7 @@
|
||||
"Simple document icon": "Icône simple du document",
|
||||
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.",
|
||||
"Start Writing": "Commencer à écrire",
|
||||
"Start from <Link>ready-made templates</Link> for common use cases, then customize them to match your workflow in minutes.": "Commencez à partir de <Link>modèles prêts à l'emploi</Link> pour les cas d'utilisation courants, puis personnalisez-les pour correspondre à votre flux de travail en quelques minutes.",
|
||||
"Start from ready-made templates for common use cases, then customize them to match your workflow in minutes.": "Commencez à partir de modèles prêts à l'emploi pour les cas d'utilisation courants, puis personnalisez-les pour correspondre à votre flux de travail en quelques minutes.",
|
||||
"Stop": "Arrêter",
|
||||
"Summarize": "Résumer",
|
||||
"Summary": "Sommaire",
|
||||
|
||||
@@ -8,16 +8,12 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-id-pattern */
|
||||
body > #__next > .c__app > div:has(> .c__loader) {
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -109,7 +109,5 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
|
||||
Object.values(get().tasks).forEach(({ task, observer }) => {
|
||||
task.unobserve(observer);
|
||||
});
|
||||
|
||||
set({ tasks: {}, provider: undefined });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -31,17 +31,16 @@
|
||||
"server:test": "yarn COLLABORATION_SERVER run test"
|
||||
},
|
||||
"resolutions": {
|
||||
"@tiptap/extensions": "3.22.4",
|
||||
"@types/node": "24.12.2",
|
||||
"@tiptap/extensions": "3.20.4",
|
||||
"@types/node": "24.12.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"eslint": "10.2.1",
|
||||
"eslint": "10.1.0",
|
||||
"postcss": "8.5.10",
|
||||
"prosemirror-view": "1.41.8",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"serialize-javascript": "7.0.5",
|
||||
"typescript": "6.0.3",
|
||||
"prosemirror-view": "1.41.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"typescript": "5.9.3",
|
||||
"uuid": "14.0.0",
|
||||
"wrap-ansi": "10.0.0",
|
||||
"yjs": "13.6.30"
|
||||
|
||||
@@ -18,23 +18,23 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@next/eslint-plugin-next": "16.2.4",
|
||||
"@tanstack/eslint-plugin-query": "5.99.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.0",
|
||||
"@typescript-eslint/parser": "8.59.0",
|
||||
"@typescript-eslint/utils": "8.59.0",
|
||||
"@vitest/eslint-plugin": "1.6.16",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"@next/eslint-plugin-next": "16.2.1",
|
||||
"@tanstack/eslint-plugin-query": "5.95.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
"@typescript-eslint/parser": "8.57.1",
|
||||
"@typescript-eslint/utils": "8.57.1",
|
||||
"@vitest/eslint-plugin": "1.6.13",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-jest": "29.15.2",
|
||||
"eslint-plugin-jest": "29.15.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-playwright": "2.10.2",
|
||||
"eslint-plugin-playwright": "2.10.1",
|
||||
"eslint-plugin-prettier": "5.5.5",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-testing-library": "7.16.2",
|
||||
"prettier": "3.8.3"
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
"eslint-plugin-testing-library": "7.16.1",
|
||||
"prettier": "3.8.1"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"i18next-parser": "9.4.0",
|
||||
"jest": "30.3.0",
|
||||
"ts-jest": "29.4.9",
|
||||
"ts-jest": "29.4.6",
|
||||
"typescript": "*",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/server-util": "0.49.0",
|
||||
"@blocknote/server-util": "0.47.3",
|
||||
"@hocuspocus/server": "3.4.4",
|
||||
"@sentry/node": "10.49.0",
|
||||
"@sentry/profiling-node": "10.49.0",
|
||||
"@sentry/node": "10.45.0",
|
||||
"@sentry/profiling-node": "10.45.0",
|
||||
"@tiptap/extensions": "*",
|
||||
"axios": "1.15.2",
|
||||
"axios": "1.15.0",
|
||||
"cors": "2.8.6",
|
||||
"express": "5.2.1",
|
||||
"express-ws": "5.0.2",
|
||||
@@ -30,7 +30,7 @@
|
||||
"yjs": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocknote/core": "0.49.0",
|
||||
"@blocknote/core": "0.47.3",
|
||||
"@hocuspocus/provider": "3.4.4",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.6",
|
||||
@@ -45,8 +45,8 @@
|
||||
"ts-node": "10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"typescript": "*",
|
||||
"vitest": "4.1.4",
|
||||
"vitest-mock-extended": "4.0.0",
|
||||
"vitest": "4.1.0",
|
||||
"vitest-mock-extended": "3.1.0",
|
||||
"ws": "8.20.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -62,7 +62,7 @@ const readers: InputReader[] = [
|
||||
const ydoc = new Y.Doc();
|
||||
try {
|
||||
Y.applyUpdate(ydoc, data);
|
||||
return editor.yDocToBlocks(ydoc, 'document-store');
|
||||
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
|
||||
} finally {
|
||||
ydoc.destroy();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,4 +6,4 @@ DOCS_DIR_MAILS="${DOCS_DIR_MAILS:-../backend/core/templates/mail}/html/"
|
||||
if [ ! -d "${DOCS_DIR_MAILS}" ]; then
|
||||
mkdir -p "${DOCS_DIR_MAILS}";
|
||||
fi
|
||||
mjml mjml/*.mjml -o "${DOCS_DIR_MAILS}" --config.allowIncludes true;
|
||||
mjml mjml/*.mjml -o "${DOCS_DIR_MAILS}";
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<mj-head>
|
||||
<mj-title>{{ title }}</mj-title>
|
||||
<mj-preview>{{ title }}</mj-preview>
|
||||
<mj-font name="Inter" href="https://fonts.bunny.net/css?family=inter:300,400,500,700" />
|
||||
<mj-preview>
|
||||
<!--
|
||||
We load django tags here, in this way there are put within the body in html output
|
||||
so the html-to-text command includes it within its output
|
||||
-->
|
||||
{% load i18n static extra_tags %}
|
||||
{{ title }}
|
||||
</mj-preview>
|
||||
<mj-attributes>
|
||||
<mj-all
|
||||
font-family="Inter, sans-serif"
|
||||
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Cantarell, 'Helvetica Neue', sans-serif"
|
||||
font-size="16px"
|
||||
line-height="normal"
|
||||
color="#3A3A3A"
|
||||
/>
|
||||
<mj-text font-family="Inter, sans-serif" />
|
||||
<mj-button font-family="Inter, sans-serif" />
|
||||
</mj-attributes>
|
||||
<mj-style>
|
||||
/* Reset */
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
<mj-include path="./partial/header.mjml" />
|
||||
|
||||
<mj-body mj-class="bg--blue-100">
|
||||
<!--
|
||||
We load django tags here so they appear in the body of the HTML output.
|
||||
This ensures html-to-text also includes them in the plain text template.
|
||||
-->
|
||||
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
|
||||
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
|
||||
<mj-section css-class="wrapper-logo">
|
||||
<mj-column>
|
||||
@@ -21,11 +16,11 @@
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
|
||||
<mj-column>
|
||||
<mj-text align="center" font-family="Inter, sans-serif">
|
||||
<mj-text align="center">
|
||||
<h1>{{title|capfirst}}</h1>
|
||||
</mj-text>
|
||||
<!-- Main Message -->
|
||||
<mj-text font-family="Inter, sans-serif">
|
||||
<mj-text>
|
||||
{{message|capfirst}}
|
||||
<a href="{{link}}">{{link_label}}</a>
|
||||
</mj-text>
|
||||
@@ -34,7 +29,6 @@
|
||||
background-color="#000091"
|
||||
color="white"
|
||||
padding-bottom="30px"
|
||||
font-family="Inter, sans-serif"
|
||||
>
|
||||
{{button_label}}
|
||||
</mj-button>
|
||||
@@ -45,13 +39,13 @@
|
||||
width="30%"
|
||||
align="center"
|
||||
/>
|
||||
<mj-text font-family="Inter, sans-serif">
|
||||
<mj-text>
|
||||
{% blocktrans %}
|
||||
Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
<!-- Signature -->
|
||||
<mj-text font-family="Inter, sans-serif">
|
||||
<mj-text>
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
Brought to you by {{brandname}}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
"mjml": "5.0.1"
|
||||
"mjml": "4.18.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"minimatch": "^10.0.0"
|
||||
|
||||
1275
src/mail/yarn.lock
1275
src/mail/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user