Compare commits

..

32 Commits

Author SHA1 Message Date
rvveber
0483a80784 📝(documentation) Add custom export templates section
... with usage instructions and limitations

Signed-off-by: rvveber <weber@b1-systems.de>
2025-10-29 11:26:22 +01:00
Cyril
2f010cf36d (frontend) set empty alt on logo due to Axe a11y error
image is decorative; alt was redundant with link aria-label

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-27 07:34:14 +01:00
Olivier Laurendeau
9d3c1eb9d5 🐛(frontend) emoji-picker fix lack of overlay
The EmojiPicker component now displays an overlay
when opened, it fixes an issue when multiple pickers
are present on the same page and we click on one of them,
the others were not closing.
2025-10-23 17:29:45 +02:00
Olivier Laurendeau
08f3ceaf3f (frontend) add EmojiPicker in DocumentTitle
We can now add emojis to the document title using
the EmojiPicker component.
2025-10-23 17:29:45 +02:00
Olivier Laurendeau
b1d033edc9 🩹(frontend) handle properly emojis in interlinking
Emoji in interlinking were not replacing
the default icon when present.
2025-10-23 17:29:18 +02:00
Olivier Laurendeau
192fa76b54 (frontend) can remove emoji in the tree item actions
Add action button to remove emoji
from a document title from the document tree.
2025-10-23 17:29:18 +02:00
Olivier Laurendeau
b667200ebd (frontend) add an EmojiPicker in the document tree
This allows users to easily add emojis easily to
their documents from the tree, enhancing the
overall user experience.
2025-10-23 17:29:17 +02:00
Olivier Laurendeau
294922f966 🩹(frontend) do not display emoji as page icon on main pages
We decided to not display the leading emoji
as page icon on the main pages to keep consistency
in the document list.
2025-10-23 17:29:17 +02:00
Cyril
8b73aa3644 (frontend) create skeleton feature
creating a skeleton to be display during doc creation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-23 14:41:09 +02:00
Anthony LC
dd56a8abeb 🐛(backend) fix trashbin list
Fix listing of deleted documents in trashbin for
users without owner access
2025-10-23 12:03:31 +02:00
Anthony LC
145c688830 🐛(frontend) fix lost content during sync
The tests e2e highlighted a problem where content
was lost during synchronization. This bug
started to occurs after upgrading Blocknote to
0.41.1 version.
It seems to happen only when the initial document
is empty and 2 users are collaborating, so before
the first minute.
We now initialize the editor only when the y-doc
has attempted to sync. This should ensure that
all updates are applied before the editor
is initialized.
2025-10-22 14:47:11 +02:00
Anthony LC
950d215632 🚸(frontend) fresh data on share modal open
When we open the share modal, the requests were
then in cache, if other users where interacting
with the share settings in parallel,
we would not see the changes until the cache expired.
We now force a fresh fetch of the data when opening
the share modal, it ensures we always have the
latest data when opening the modal.
2025-10-22 14:47:11 +02:00
Anthony LC
7d5cc4e84b 🚚(frontend) move useUpdateDocLink to doc-share feature
Move the `useUpdateDocLink` hook from the
`doc-management` feature to the `doc-share` feature
to better align with its functionality related
to document sharing.
2025-10-22 14:47:11 +02:00
Anthony LC
3e5bcf96ea ⬆️(y-provider) update hocuspocus to 3.2.5
The last version of Blocknote seems to have a
conflict with hocuspocus 2.15.2, it is a good
moment to upgrade to hocuspocus 3.2.5.
2025-10-22 14:47:10 +02:00
Anthony LC
fe24c00178 ♻️(frontend) adapt custom blocks to new implementation
Last release of Blocknote introduced breaking
changes for custom blocks.
We adapted our custom blocks to the new
implementation.
"code-block" is considered as a block now, we
update the way to import and use it.
The custom blocks should be now more tiptap friendly.
2025-10-22 13:53:55 +02:00
Anthony LC
aca334f81f 🔥(frontend) remove custom DividerBlock
Blocknote now has a built-in divider block, so we
can remove our custom implementation.
2025-10-22 13:52:34 +02:00
Anthony LC
2003e41c22 🚨(frontend) adapt signatures to @tanstack/react-query to >5.90
Recent upgrade of @tanstack/react-query to
version >5.90 introduced a breaking change in the
onSuccess and onError callback signatures for
the useMutation hook.
The context parameter has been replaced with an
onMutateResult parameter, which provides
information about the result of the
onMutate callback.
2025-10-22 13:52:34 +02:00
Anthony LC
5ebdf4b4d4 ⬇️(dependencies) downgrade to cunningham 3.2.3
Version 4.0.0 is not yet compatible with UiKit,
better to wait.
2025-10-22 13:52:34 +02:00
renovate[bot]
35e771a1ce ⬆️(dependencies) update js dependencies 2025-10-22 13:52:33 +02:00
Manuel Raynaud
2b5a9e1af8 ♻️(backend) increase user short_name field length
The user's short_name field length was set to 20. This is not enought
and we have some users who cannot register because of that. We changed
this length to a higher one, 100, like the full_name.
2025-10-22 11:44:39 +02:00
Cyril
a833fdc7a1 (frontend) add resizable left panel on desktop with persistence
mainlayout and leftpanel updated with resizable panel saved in localstorage

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) show full nested doc names with horizontal scroll support

horizontal overflow enabled and opacity used for sticky actions visibility

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) show full nested doc names with horizontal scroll support

horizontal overflow enabled and opacity used for sticky actions visibility

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) add resizable-panels lib also used in our shared ui kit

needed for adaptable ui consistent with our shared ui kit components

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-21 10:59:24 +02:00
Anthony LC
b3cc2bf833 🚨(eslint) add missing rules
We recently upgraded to Eslint v9, it seems that
it is missing some rules that we had previously.
We add them back:
- @typescript-eslint/no-inferrable-types
- @typescript-eslint/no-floating-promises
2025-10-20 21:53:10 +02:00
Anthony LC
18feab10cb (e2e) reduce flakinees
- Because of parallel test execution, some tests
were flaky when using goToGridDoc, the title
changed between the time we got the document list
and the time we clicked on the document.
- Improve addChild function.
2025-10-20 10:17:03 +02:00
Anthony LC
2777488d24 🐛(frontend) fix duplicate document entries in grid
The tests e2e were failing sometimes because
the documents list was containing duplicates.
This was happening when multiple users were
modifying the documents list (creation, update, ...).
We now deduplicate documents by their ID
before displaying them.
2025-10-20 10:17:03 +02:00
Anthony LC
a11258f778 🔖(patch) release 3.8.2
Fixed:

- 🐛(service-worker) fix sw registration and page reload
  logic
2025-10-17 15:54:56 +02:00
Anthony LC
33647f124f 🐛(service-worker) fix sw registration and page reload logic
When a new service worker is installed, the page
was reloaded to ensure the new service worker took
control, it is not a big issue in normal browsing mode
because the service worker is only updated once in a
while (every release).
However, in incognito mode, the service worker has to be
re-registered on each new session, which means that
the page was reloading each time the user opened a
new incognito window, creating a bad user experience.
We now take in consideration the case where the
service-worker is installed for the first time, and don't
reload if it is this case.
2025-10-17 15:14:04 +02:00
Anthony LC
e339cda5c6 🔖(patch) release 3.8.1
Fixed:
- ️(backend) improve trashbin endpoint performance
- 🐛(backend) manage invitation partial update without email
- (frontend) improve accessibility:
  -  add missing aria-label to add sub-doc button
  for accessibility
  -  add missing aria-label to more options button
  on sub-docs

Removed:
- 🔥(backend) remove treebeard form for the document admin
2025-10-17 10:41:38 +02:00
Manuel Raynaud
4ce65c654f 🔥(backend) remove treebeard form for the document admin
The document change admin page is unusable. The django treebeard library
can change the form used by one provided but this one is really slow.
And it is collapsing the configuration made with the other fields and
readonly fields declared on the DocumentAdmin class. In a first time we
remove the form usage, it seems useless. Later we have to provide more
information on this admin page.
2025-10-17 08:35:22 +00:00
Manuel Raynaud
c048b2ae95 🐛(backend) manage invitation partial update without email
An invitation can be updated to change its role. The front use a PATCH
sending only the changed role, so the email is missing in the
InivtationSerializer.validate method. We have to check first if an email
is present before working on it.
2025-10-16 15:26:02 +00:00
Manuel Raynaud
5908afb098 ️(backend) improve trashbin endpoint performance (#1495)
The trashbin endpoint is slow. To filter documents the user has owner
access, we use a subquery to compute the roles and then filter on this
subquery. This is very slow. To improve it, we use the same way to
filter children used in the tree endpoint. First we look for all highest
ancestors the user has access on with the owner role. Then we create one
queryset filtering on all the docs starting by the given path and are
deleted.
2025-10-16 17:06:47 +02:00
Cyril
e2298a3658 (frontend) add missing aria-label to more options button on sub-docs
improves accessibility by making the options button screen reader friendly

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-16 15:02:04 +02:00
Cyril
278eb233e9 (frontend) add missing aria-label to add sub-doc button for a11y
improves screen reader support for the add sub-doc action in the document tree

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-16 14:18:55 +02:00
132 changed files with 6199 additions and 5803 deletions

3
.gitignore vendored
View File

@@ -75,3 +75,6 @@ db.sqlite3
.vscode/
*.iml
.devcontainer
# Cursor rules
.cursorrules

View File

@@ -6,6 +6,44 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
### Changed
- ♻️(frontend) adapt custom blocks to new implementation #1375
- ♻️(backend) increase user short_name field length
### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(frontend) show full nested doc names with ajustable bar #1456
- 🐛(backend) fix trashbin list
- ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
## [3.8.2] - 2025-10-17
### Fixed
- 🐛(service-worker) fix sw registration and page reload logic #1500
## [3.8.1] - 2025-10-17
### Fixed
- ⚡️(backend) improve trashbin endpoint performance #1495
- 🐛(backend) manage invitation partial update without email #1494
- ♿(frontend) improve accessibility:
- ♿ add missing aria-label to add sub-doc button for accessibility #1480
- ♿ add missing aria-label to more options button on sub-docs #1481
### Removed
- 🔥(backend) remove treebeard form for the document admin #1470
## [3.8.0] - 2025-10-14
### Added
@@ -55,11 +93,18 @@ and this project adheres to
- ✨(frontend) load docs logo from public folder via url #1462
- 🔧(keycloak) Fix https required issue in dev mode #1286
## Removed
- 🔥(frontend) remove custom DividerBlock ##1375
## [3.7.0] - 2025-09-12
### Added
- ✨(api) add API route to fetch document content #1206
- ✨(frontend) doc emojis improvements #1381
- add an EmojiPicker in the document tree and document title
- remove emoji buttons in menus
### Changed
@@ -73,6 +118,8 @@ and this project adheres to
- ✨unify tab focus style for better visual consistency #1341
- ♿hide decorative icons, label menus, avoid accessible name… #1362
- ♻️(tilt) use helm dev-backend chart
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
- 🩹(frontend) handle properly emojis in interlinking #1381
### Removed
@@ -788,7 +835,9 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.8.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.8.2...main
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -85,4 +85,44 @@ THEME_CUSTOMIZATION_FILE_PATH=<path>
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
----
# **Custom Export Templates** 📄
You can define custom export templates to add introductory content, such as headers or titles, to documents before exporting them as PDF, Docx, etc...
Export Templates are managed through the admin interface and can be selected by users during the export process.
### Benefits
This feature offers several advantages:
* **Header customization** 📄: Add custom headers, titles, or branding to exported documents.
* **No code changes required** 🔧: Templates are managed through the admin interface without needing developer intervention.
* **Flexible content** 🌟: Use HTML to create headers that match your organization's style.
### Limitations ⚠️
- Currently, templates are only prepended to the document.
More complex layouts are not supported at this time.
- The `CSS` and `Description` fields are being ignored at this time.
- <b>Due to technical conversion limitations, not all HTML can be converted to the internal format!</b>
### How to Use
1. Create the Template in a new document.
![Build Template in Editor](./assets/export-template-tutorial/build-template-in-editor.png)
2. Copy it as HTML code using the `Copy as HTML` feature:
![Three Dots Copy as HTML](./assets/export-template-tutorial/three-dots-copy-as-html.png)
3. Log in to the admin interface at `/admin` (backend container).
2. Create a new template:
![Admin Create Template Workflow](./assets/export-template-tutorial/admin-create-template-workflow.png)
- **Title**: Enter a descriptive name for the template.
- **Code**: Paste the HTML content from step 2.
- **Public**: Check this box to make the template available in the frontend export modal.
- **Save** the template.
Once saved, users can select the template from the export modal in the frontend during the export process.
![Select Template in Editor](./assets/export-template-tutorial/select-template-in-editor.png)

View File

@@ -24,8 +24,6 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@hocuspocus/provider",
"@hocuspocus/server",
"docx",
"fetch-mock",
"node",

View File

@@ -5,7 +5,6 @@ from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from . import models
@@ -157,7 +156,6 @@ class DocumentAdmin(TreeAdmin):
},
),
)
form = movenodeform_factory(models.Document)
inlines = (DocumentAccessInline,)
list_display = (
"id",

View File

@@ -749,7 +749,8 @@ class InvitationSerializer(serializers.ModelSerializer):
if self.instance is None:
attrs["issuer"] = user
attrs["email"] = attrs["email"].lower()
if attrs.get("email"):
attrs["email"] = attrs["email"].lower()
return attrs

View File

@@ -623,12 +623,32 @@ class DocumentViewSet(
The selected documents are those deleted within the cutoff period defined in the
settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted.
"""
if not request.user.is_authenticated:
return self.get_response_for_queryset(self.queryset.none())
access_documents_paths = (
models.DocumentAccess.objects.select_related("document")
.filter(
db.Q(user=self.request.user) | db.Q(team__in=self.request.user.teams),
role=models.RoleChoices.OWNER,
)
.values_list("document__path", flat=True)
)
if not access_documents_paths:
return self.get_response_for_queryset(self.queryset.none())
children_clause = db.Q()
for path in access_documents_paths:
children_clause |= db.Q(path__startswith=path)
queryset = self.queryset.filter(
children_clause,
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = queryset.annotate_user_roles(self.request.user)
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
return self.get_response_for_queryset(queryset)

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-10-22 06:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_add_is_masked_field_to_link_trace"),
]
operations = [
migrations.AlterField(
model_name="user",
name="short_name",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="short name"
),
),
]

View File

@@ -148,7 +148,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
short_name = models.CharField(
_("short name"), max_length=100, null=True, blank=True
)
email = models.EmailField(_("identity email address"), blank=True, null=True)

View File

@@ -769,6 +769,37 @@ def test_api_document_invitations_update_authenticated_unprivileged(
assert value == old_invitation_values[key]
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_document_invitations_patch(via, role, mock_user_teams):
"""Partially updating an invitation should be allowed."""
user = factories.UserFactory()
invitation = factories.InvitationFactory(role="editor")
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
{"role": "reader"},
format="json",
)
assert response.status_code == 200
invitation.refresh_from_db()
assert invitation.role == "reader"
# Delete

View File

@@ -166,10 +166,10 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
with django_assert_num_queries(10):
with django_assert_num_queries(11):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
@@ -208,10 +208,10 @@ def test_api_documents_trashbin_authenticated_via_team(
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
with django_assert_num_queries(7):
with django_assert_num_queries(8):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(3):
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
@@ -293,3 +293,29 @@ def test_api_documents_trashbin_distinct():
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_trashbin_empty_queryset_bug():
"""
Test that users with no owner role don't see documents.
"""
# Create a new user with no owner access to any document
new_user = factories.UserFactory()
client = APIClient()
client.force_login(new_user)
# Create some deleted documents owned by other users
other_user = factories.UserFactory()
item1 = factories.DocumentFactory(users=[(other_user, "owner")])
item1.soft_delete()
item2 = factories.DocumentFactory(users=[(other_user, "owner")])
item2.soft_delete()
item3 = factories.DocumentFactory(users=[(other_user, "owner")])
item3.soft_delete()
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 0
assert len(content["results"]) == 0

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.8.0"
version = "3.8.2"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@@ -13,7 +13,11 @@ import {
} from './utils-common';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages';
import {
createRootSubPage,
getTreeRow,
navigateToPageFromTree,
} from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -676,10 +680,9 @@ test.describe('Doc Editor', () => {
await calloutBlock.locator('.inline-content').fill('example text');
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'yellow',
);
await expect(
page.locator('.bn-block-content[data-content-type="callout"]').first(),
).toHaveAttribute('data-background-color', 'yellow');
const emojiButton = calloutBlock.getByRole('button');
await expect(emojiButton).toHaveText('💡');
@@ -703,10 +706,9 @@ test.describe('Doc Editor', () => {
await page.locator('.mantine-Menu-dropdown > button').last().click();
await page.locator('.bn-color-picker-dropdown > button').last().click();
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'pink',
);
await expect(
page.locator('.bn-block-content[data-content-type="callout"]').first(),
).toHaveAttribute('data-background-color', 'pink');
});
test('it checks interlink feature', async ({ page, browserName }) => {
@@ -730,7 +732,13 @@ test.describe('Doc Editor', () => {
await verifyDocName(page, docChild2);
await page.locator('.bn-block-outer').last().fill('/');
const treeRow = await getTreeRow(page, docChild2);
await treeRow.locator('.--docs--doc-icon').click();
await page.getByRole('button', { name: '😀' }).first().click();
await navigateToPageFromTree({ page, title: docChild1 });
await openSuggestionMenu({ page });
await page.getByText('Link a doc').first().click();
const input = page.locator(
@@ -744,6 +752,16 @@ test.describe('Doc Editor', () => {
await expect(searchContainer.getByText(docChild1)).toBeVisible();
await expect(searchContainer.getByText(docChild2)).toBeVisible();
const searchContainerRow = searchContainer
.getByRole('option')
.filter({
hasText: docChild2,
})
.first();
await expect(searchContainerRow).toContainText('😀');
await expect(searchContainerRow.locator('svg').first()).toBeHidden();
await input.pressSequentially('-child');
await expect(searchContainer.getByText(docChild1)).toBeVisible();
@@ -758,32 +776,30 @@ test.describe('Doc Editor', () => {
await expect(searchContainer).toBeHidden();
// Wait for the interlink to be created and rendered
const editor = page.locator('.ProseMirror.bn-editor');
const editor = await getEditor({ page });
const interlink = editor.getByRole('button', {
const interlinkChild2 = editor.getByRole('button', {
name: docChild2,
});
await expect(interlink).toBeVisible({ timeout: 10000 });
await interlink.click();
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
await expect(interlinkChild2).toContainText('😀');
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
await interlinkChild2.click();
await verifyDocName(page, docChild2);
});
test('it checks interlink shortcut @', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
await verifyDocName(page, randomDoc);
const editor = page.locator('.bn-block-outer').last();
await editor.click();
await page.keyboard.press('@');
await expect(
page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
),
).toBeVisible();
await page.keyboard.press('@');
await input.fill(docChild1);
await searchContainer.getByText(docChild1).click();
const interlinkChild1 = editor.getByRole('button', {
name: docChild1,
});
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
});
test('it checks multiple big doc scroll to the top', async ({
@@ -844,10 +860,10 @@ test.describe('Doc Editor', () => {
await expect(pdfBlock).toBeVisible();
await page.getByText('Add PDF').click();
await page.getByText(/Add (PDF|file)/).click();
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download');
await page.getByText('Upload file').click();
await page.getByText(/Upload (PDF|file)/).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test-pdf.pdf'));

View File

@@ -2,7 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { pdf } from 'pdf-parse';
import {
TestLanguage,
@@ -59,20 +59,16 @@ test.describe('Doc Export', () => {
await verifyDocName(page, randomDoc);
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.locator('.bn-block-outer').last().fill('Hello');
const editor = await writeInEditor({ page, text: 'Hello' });
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('/');
await openSuggestionMenu({ page });
await page.getByText('Page Break').click();
await expect(editor.locator('.bn-page-break')).toBeVisible();
await expect(
editor.locator('div[data-content-type="pageBreak"]'),
).toBeVisible();
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await writeInEditor({ page, text: 'World' });
await page
.getByRole('button', {
@@ -92,9 +88,9 @@ test.describe('Doc Export', () => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.numpages).toBe(2);
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
expect(pdfData.info.Title).toBe(randomDoc);
expect(pdfData.total).toBe(2);
expect(pdfData.text).toContain('Hello\n\nWorld\n\n'); // This is the doc text
expect(pdfData.info?.Title).toBe(randomDoc);
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -274,49 +270,6 @@ test.describe('Doc Export', () => {
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
});
/**
* We cannot assert the line break is visible in the pdf, but we can assert the
* line break is visible in the editor and that the pdf is generated.
*/
test('it exports the doc with divider', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'export-divider', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World');
// Trigger slash menu to show menu
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Add a horizontal line').click();
await expect(
editor.locator('.bn-block-content[data-content-type="divider"]'),
).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Hello World');
});
test('it exports the doc with multi columns', async ({
page,
browserName,

View File

@@ -8,7 +8,7 @@ import {
verifyDocName,
} from './utils-common';
import { mockedAccesses, mockedInvitations } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
import { createRootSubPage, getTreeRow } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -65,25 +65,36 @@ test.describe('Doc Header', () => {
page,
browserName,
}) => {
await createDoc(page, 'doc-update', browserName, 1);
await createDoc(page, 'doc-update-emoji', browserName, 1);
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-update-emoji-child',
);
await verifyDocName(page, docChild);
await expect(emojiPicker).toBeVisible();
await emojiPicker.click({
delay: 100,
});
await page.getByRole('button', { name: '😀' }).first().click();
await expect(emojiPicker).toHaveText('😀');
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await docTitle.fill('👍 Hello Emoji World');
await docTitle.fill('Hello Emoji World');
await docTitle.blur();
await verifyDocName(page, '👍 Hello Emoji World');
await verifyDocName(page, 'Hello Emoji World');
// Check the tree
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
await expect(docTree.getByTestId('doc-emoji-icon')).toBeVisible();
await expect(docTree.getByTestId('doc-simple-icon')).toBeHidden();
await page.getByTestId('home-button').click();
// Check the documents grid
const gridRow = await getGridRow(page, 'Hello Emoji World');
await expect(gridRow.getByTestId('doc-emoji-icon')).toBeVisible();
await expect(gridRow.getByTestId('doc-simple-icon')).toBeHidden();
const row = await getTreeRow(page, 'Hello Emoji World');
await expect(row.getByText('😀')).toBeVisible();
});
test('it deletes the doc', async ({ page, browserName }) => {

View File

@@ -7,6 +7,7 @@ import {
randomName,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateRoleUser } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -202,9 +203,19 @@ test.describe('Document create member', () => {
);
await expect(userInvitation).toBeVisible();
const responsePromisePatchInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') &&
response.status() === 200 &&
response.request().method() === 'PATCH',
);
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
const moreActions = userInvitation.getByRole('button', {
name: 'Open invitation actions menu',
});
@@ -230,11 +241,7 @@ test.describe('Document create member', () => {
await verifyDocName(page, docTitle);
await page
.locator('.ProseMirror')
.locator('.bn-block-outer')
.last()
.fill('Hello World');
await writeInEditor({ page, text: 'Hello World' });
const docUrl = page.url();

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { createDoc, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => {
@@ -8,8 +8,14 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Document list members', () => {
test('it checks a big list of members', async ({ page }) => {
const docTitle = await goToGridDoc(page);
test('it checks a big list of members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'members-big-members-list',
browserName,
1,
);
await verifyDocName(page, docTitle);
// Get the current URL and extract the last part
@@ -73,7 +79,7 @@ test.describe('Document list members', () => {
await expect(loadMore).toBeHidden();
});
test('it checks a big list of invitations', async ({ page }) => {
test('it checks a big list of invitations', async ({ page, browserName }) => {
await page.route(
/.*\/documents\/.*\/invitations\/\?page=.*/,
async (route) => {
@@ -108,7 +114,12 @@ test.describe('Document list members', () => {
},
);
const docTitle = await goToGridDoc(page);
const [docTitle] = await createDoc(
page,
'members-big-invitation-list',
browserName,
1,
);
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();

View File

@@ -9,6 +9,7 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc Routing', () => {
@@ -58,16 +59,23 @@ test.describe('Doc Routing', () => {
await createRootSubPage(page, browserName, '401-doc-child');
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await writeInEditor({ page, text: 'Hello World' });
const responsePromise = page.route(
/.*\/documents\/.*\/$|users\/me\/$/,
async (route) => {
const request = route.request();
// When we quit a document, a PATCH request is sent to save the document.
// We intercept this request to simulate a 401 error from the backend.
// The GET request to users/me is also intercepted to simulate the user
// being logged out when trying to fetch user info.
// This way we can test the 401 error handling when saving the document
if (
request.method().includes('PATCH') ||
request.method().includes('GET')
(request.url().includes('/documents/') &&
request.method().includes('PATCH')) ||
(request.url().includes('/users/me/') &&
request.method().includes('GET'))
) {
await route.fulfill({
status: 401,

View File

@@ -102,6 +102,7 @@ test.describe('Doc Trashbin', () => {
page,
browserName,
docParent: subDocName,
docName: 'my-trash-editor-subsubdoc',
});
await verifyDocName(page, subsubDocName);

View File

@@ -9,7 +9,11 @@ import {
verifyDocName,
} from './utils-common';
import { addNewMember } from './utils-share';
import { clickOnAddRootSubPage, createRootSubPage } from './utils-sub-pages';
import {
clickOnAddRootSubPage,
createRootSubPage,
getTreeRow,
} from './utils-sub-pages';
test.describe('Doc Tree', () => {
test.beforeEach(async ({ page }) => {
@@ -298,6 +302,58 @@ test.describe('Doc Tree', () => {
// Now test keyboard navigation on sub-document
await expect(docTree.getByText(docChild)).toBeVisible();
});
test('it updates the child icon from the tree', async ({
page,
browserName,
}) => {
const [docParent] = await createDoc(
page,
'doc-child-emoji',
browserName,
1,
);
await verifyDocName(page, docParent);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-child-emoji-child',
);
const row = await getTreeRow(page, docChild);
// Check Remove emoji is not present initially
await row.hover();
const menu = row.getByText(`more_horiz`);
await menu.click();
await expect(
page.getByRole('menuitem', { name: 'Remove emoji' }),
).toBeHidden();
// Close the menu
await page.keyboard.press('Escape');
// Update the emoji from the tree
await row.locator('.--docs--doc-icon').click();
await page.getByRole('button', { name: '😀' }).first().click();
// Verify the emoji is updated in the tree and in the document title
await expect(row.getByText('😀')).toBeVisible();
const titleEmojiPicker = page
.locator('.--docs--doc-title')
.getByRole('button');
await expect(titleEmojiPicker).toHaveText('😀');
// Now remove the emoji using the new action
await row.hover();
await menu.click();
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).not.toHaveText('😀');
});
});
test.describe('Doc Tree: Inheritance', () => {

View File

@@ -7,6 +7,7 @@ import {
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -151,18 +152,15 @@ test.describe('Doc Visibility: Restricted', () => {
await verifyDocName(page, docTitle);
await page
.locator('.ProseMirror')
.locator('.bn-block-outer')
.last()
.fill('Hello World');
await writeInEditor({ page, text: 'Hello World' });
const docUrl = page.url();
const { otherBrowserName, otherPage } = await connectOtherUserToDoc({
browserName,
docUrl,
});
const { otherBrowserName, otherPage, cleanup } =
await connectOtherUserToDoc({
browserName,
docUrl,
});
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
@@ -175,7 +173,11 @@ test.describe('Doc Visibility: Restricted', () => {
await addNewMember(page, 0, 'Reader', otherBrowserName);
await otherPage.reload();
await expect(otherPage.getByText('Hello World')).toBeVisible();
await expect(otherPage.getByText('Hello World')).toBeVisible({
timeout: 10000,
});
await cleanup();
});
});

View File

@@ -159,7 +159,7 @@ test.describe('Header: Override configuration', () => {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: 'Gouvernement Logo',
alt: '',
},
},
},
@@ -168,8 +168,11 @@ test.describe('Header: Override configuration', () => {
await page.goto('/');
const header = page.locator('header').first();
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
const logoImage = header.getByTestId('header-icon-docs');
await expect(logoImage).toBeVisible();
await expect(header.getByAltText('Docs')).toBeHidden();
await expect(logoImage).not.toHaveAttribute('src', '/assets/icon-docs.svg');
await expect(logoImage).toHaveAttribute('src', '/assets/logo-gouv.svg');
await expect(logoImage).toHaveAttribute('alt', '');
});
});

View File

@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { TestLanguage, createDoc, waitForLanguageSwitch } from './utils-common';
import { openSuggestionMenu } from './utils-editor';
test.describe('Language', () => {
test.beforeEach(async ({ page }) => {
@@ -51,6 +52,7 @@ test.describe('Language', () => {
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
await expect(languagePicker).toContainText('English');
});
test('can switch language using only keyboard', async ({ page }) => {
await page.goto('/');
await waitForLanguageSwitch(page, TestLanguage.English);
@@ -106,18 +108,18 @@ test.describe('Language', () => {
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show english menu
await editor.click();
await editor.fill('/');
const editor = await openSuggestionMenu({ page });
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await editor.click(); // close the menu
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
// Change language to French
await waitForLanguageSwitch(page, TestLanguage.French);
// Trigger slash menu to show french menu
await editor.locator('.bn-block-outer').last().fill('/');
await openSuggestionMenu({ page });
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
});
});

View File

@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './utils-common';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -11,6 +13,53 @@ test.describe('Left panel desktop', () => {
await expect(page.getByTestId('home-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
});
test('checks resize handle is present and functional on document page', async ({
page,
browserName,
}) => {
// On home page, resize handle should NOT be present
let resizeHandle = page.locator('[data-panel-resize-handle-id]');
await expect(resizeHandle).toBeHidden();
// Create and navigate to a document
await createDoc(page, 'doc-resize-test', browserName, 1);
// Now resize handle should be visible on document page
resizeHandle = page.locator('[data-panel-resize-handle-id]').first();
await expect(resizeHandle).toBeVisible();
const leftPanel = page.getByTestId('left-panel-desktop');
await expect(leftPanel).toBeVisible();
// Get initial panel width
const initialBox = await leftPanel.boundingBox();
expect(initialBox).not.toBeNull();
// Get handle position
const handleBox = await resizeHandle.boundingBox();
expect(handleBox).not.toBeNull();
// Test resize by dragging the handle
await page.mouse.move(
handleBox!.x + handleBox!.width / 2,
handleBox!.y + handleBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
handleBox!.x + 100,
handleBox!.y + handleBox!.height / 2,
);
await page.mouse.up();
// Wait for resize to complete
await page.waitForTimeout(200);
// Verify the panel has been resized
const newBox = await leftPanel.boundingBox();
expect(newBox).not.toBeNull();
expect(newBox!.width).toBeGreaterThan(initialBox!.width);
});
});
test.describe('Left panel mobile', () => {
@@ -47,4 +96,12 @@ test.describe('Left panel mobile', () => {
await expect(languageButton).toBeInViewport();
await expect(logoutButton).toBeInViewport();
});
test('checks resize handle is not present on mobile', async ({ page }) => {
await page.goto('/');
// Verify the resize handle is NOT present on mobile
const resizeHandle = page.locator('[data-panel-resize-handle-id]');
await expect(resizeHandle).toBeHidden();
});
});

View File

@@ -0,0 +1,37 @@
/**
* Type definitions for pdf-parse library
* The library doesn't export complete type definitions for the parsed PDF data
*/
declare module 'pdf-parse' {
export interface PdfInfo {
Title?: string;
Author?: string;
Subject?: string;
Keywords?: string;
Creator?: string;
Producer?: string;
CreationDate?: string;
ModDate?: string;
[key: string]: unknown;
}
export interface PdfData {
/** Total number of pages */
numpages: number;
/** Alias for numpages */
total?: number;
/** Extracted text content from the PDF */
text: string;
/** PDF metadata information */
info?: PdfInfo;
/** PDF metadata (alternative structure) */
metadata?: unknown;
/** PDF version */
version?: string;
}
export function pdf(buffer: Buffer): Promise<PdfData>;
export default pdf;
}

View File

@@ -48,7 +48,7 @@ export const overrideConfig = async (
export const keyCloakSignIn = async (
page: Page,
browserName: string,
fromHome: boolean = true,
fromHome = true,
) => {
if (fromHome) {
await page.getByRole('button', { name: 'Start Writing' }).first().click();
@@ -79,8 +79,8 @@ export const createDoc = async (
page: Page,
docName: string,
browserName: string,
length: number = 1,
isMobile: boolean = false,
length = 1,
isMobile = false,
) => {
const randomDocs = randomName(docName, browserName, length);

View File

@@ -9,7 +9,7 @@ export const getEditor = async ({ page }: { page: Page }) => {
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
const editor = await getEditor({ page });
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
await writeInEditor({ page, text: '/' });
return editor;
};
@@ -22,6 +22,6 @@ export const writeInEditor = async ({
text: string;
}) => {
const editor = await getEditor({ page });
editor.locator('.bn-block-outer').last().fill(text);
await editor.locator('.bn-block-outer .bn-inline-content').last().fill(text);
return editor;
};

View File

@@ -15,7 +15,7 @@ export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
fillText: string = 'user.test',
fillText = 'user.test',
) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>

View File

@@ -12,7 +12,7 @@ export const createRootSubPage = async (
page: Page,
browserName: BrowserName,
docName: string,
isMobile: boolean = false,
isMobile = false,
) => {
if (isMobile) {
await page
@@ -72,10 +72,12 @@ export const addChild = async ({
page,
browserName,
docParent,
docName,
}: {
page: Page;
browserName: BrowserName;
docParent: string;
docName: string;
}) => {
let item = page.getByTestId('doc-tree-root-item');
@@ -99,12 +101,26 @@ export const addChild = async ({
await item.hover();
await item.getByTestId('doc-tree-item-actions-add-child').click();
const [name] = randomName(docParent, browserName, 1);
const [name] = randomName(docName, browserName, 1);
await updateDocTitle(page, name);
return name;
};
export const getTreeRow = async (page: Page, title: string) => {
const docTree = page.getByTestId('doc-tree');
const row = docTree
.getByRole('treeitem')
.filter({
hasText: title,
})
.first();
await expect(row).toBeVisible();
return row;
};
export const navigateToTopParentFromTree = async ({ page }: { page: Page }) => {
await page.getByRole('link', { name: /Open root document/ }).click();
};

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.8.0",
"version": "3.8.2",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -15,7 +15,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.55.0",
"@playwright/test": "1.55.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
@@ -23,7 +23,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"pdf-parse": "1.1.1"
"pdf-parse": "2.1.7"
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.8.0",
"version": "3.8.2",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -19,47 +19,51 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.37.0",
"@blocknote/core": "0.37.0",
"@blocknote/mantine": "0.37.0",
"@blocknote/react": "0.37.0",
"@blocknote/xl-docx-exporter": "0.37.0",
"@blocknote/xl-multi-column": "0.37.0",
"@blocknote/xl-pdf-exporter": "0.37.0",
"@blocknote/code-block": "0.41.1",
"@blocknote/core": "0.41.1",
"@blocknote/mantine": "0.41.1",
"@blocknote/react": "0.41.1",
"@blocknote/xl-docx-exporter": "0.41.1",
"@blocknote/xl-multi-column": "0.41.1",
"@blocknote/xl-pdf-exporter": "0.41.1",
"@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.25",
"@fontsource/material-icons": "5.2.5",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.16.1",
"@hocuspocus/provider": "2.15.2",
"@gouvfr-lasuite/ui-kit": "0.16.2",
"@hocuspocus/provider": "3.3.0",
"@mantine/core": "8.3.4",
"@mantine/hooks": "8.3.4",
"@openfun/cunningham-react": "3.2.3",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "10.11.0",
"@tanstack/react-query": "5.87.4",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.17.0",
"@tanstack/react-query": "5.90.2",
"@tiptap/extensions": "3.4.4",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"docx": "*",
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.5.0",
"i18next": "25.5.2",
"i18next": "25.5.3",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.2",
"next": "15.5.3",
"posthog-js": "1.264.2",
"next": "15.5.4",
"posthog-js": "1.271.0",
"react": "*",
"react-aria-components": "1.12.1",
"react-aria-components": "1.13.0",
"react-dom": "*",
"react-i18next": "15.7.3",
"react-i18next": "16.0.0",
"react-intersection-observer": "9.16.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.6",
@@ -69,9 +73,9 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.87.4",
"@tanstack/react-query-devtools": "5.90.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.8.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.20",
@@ -79,22 +83,22 @@
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.0.2",
"@vitejs/plugin-react": "5.0.4",
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.0.0",
"dotenv": "17.2.2",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "26.1.0",
"jsdom": "27.0.0",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.24.0",
"stylelint-config-standard": "39.0.0",
"stylelint": "16.25.0",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.101.3",
"webpack": "5.102.0",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -18,5 +18,5 @@ export const backendUrl = () =>
* @param apiVersion - The version of the API (defaults to '1.0').
* @returns The full versioned API base URL as a string.
*/
export const baseApiUrl = (apiVersion: string = '1.0') =>
export const baseApiUrl = (apiVersion = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;

View File

@@ -1,6 +1,6 @@
import { baseApiUrl } from '@/api';
export const HOME_URL: string = '/home';
export const HOME_URL = '/home';
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';

View File

@@ -1,3 +1,8 @@
<svg width="18" height="23" viewBox="0 0 18 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.57954 5.93282C4.44486 5.79021 4.37793 5.61484 4.37793 5.41299C4.37793 5.21112 4.44495 5.03795 4.58154 4.90136C4.72456 4.75833 4.90437 4.6875 5.11367 4.6875H12.8964C13.0991 4.6875 13.2722 4.75857 13.408 4.90133C13.5508 5.03718 13.6219 5.21027 13.6219 5.41299C13.6219 5.61613 13.5506 5.7918 13.409 5.93387C13.273 6.07732 13.0996 6.14873 12.8964 6.14873H5.11367C4.90437 6.14873 4.72456 6.0779 4.58154 5.93487L4.57954 5.93282ZM4.57954 9.51144C4.44486 9.36882 4.37793 9.19346 4.37793 8.9916C4.37793 8.78973 4.44495 8.61656 4.58154 8.47997C4.72456 8.33695 4.90437 8.26611 5.11367 8.26611H12.8964C13.0991 8.26611 13.2722 8.33719 13.408 8.47995C13.5508 8.61579 13.6219 8.78888 13.6219 8.9916C13.6219 9.19475 13.5506 9.37042 13.409 9.51249C13.273 9.65593 13.0996 9.72734 12.8964 9.72734H5.11367C4.90437 9.72734 4.72456 9.65651 4.58154 9.51348L4.57954 9.51144ZM4.57954 13.1003C4.44561 12.9585 4.37793 12.7869 4.37793 12.5907C4.37793 12.3831 4.44414 12.204 4.57954 12.0606L4.58151 12.0586C4.72453 11.9155 4.90437 11.8447 5.11367 11.8447H8.79482C9.00363 11.8447 9.18092 11.9153 9.3177 12.0596C9.46006 12.2024 9.53057 12.3819 9.53057 12.5907C9.53057 12.7887 9.45812 12.9609 9.31671 13.1024C9.17936 13.2397 9.00235 13.306 8.79482 13.306H5.11367C4.90609 13.306 4.72695 13.2397 4.58358 13.1043L4.57954 13.1003ZM1.09476 0.851519C1.65317 0.285946 2.47955 0.0117188 3.55508 0.0117188H14.4447C15.52 0.0117188 16.3433 0.28583 16.895 0.851748C17.4529 1.41698 17.7234 2.24966 17.7234 3.33145V18.8866C17.7234 19.975 17.4531 20.8082 16.8945 21.3668C16.3427 21.9256 15.5196 22.1961 14.4447 22.1961H3.55508C2.47988 22.1961 1.65367 21.9255 1.09521 21.367C0.543652 20.8083 0.276367 19.9747 0.276367 18.8866V3.33145C0.276367 2.24984 0.543796 1.41679 1.09476 0.851519ZM15.5624 20.0351C15.2958 20.3085 14.8959 20.4452 14.3627 20.4452H3.63711C3.10391 20.4452 2.70059 20.3085 2.42715 20.0351L2.49875 19.9652L2.49786 19.9643L2.42715 20.0351C2.16055 19.7616 2.02725 19.3686 2.02725 18.8559V3.36221C2.02725 2.84951 2.16055 2.45645 2.42715 2.18301C2.70059 1.90273 3.10391 1.7626 3.63711 1.7626H14.3627C14.8959 1.7626 15.2958 1.90273 15.5624 2.18301C15.8358 2.45645 15.9726 2.84951 15.9726 3.36221V18.8559C15.9726 19.3686 15.8358 19.7616 15.5624 20.0351Z" fill="#3A3A3A"/>
<svg viewBox="0 0 18 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.57954 5.93282C4.44486 5.79021 4.37793 5.61484 4.37793 5.41299C4.37793 5.21112 4.44495 5.03795 4.58154 4.90136C4.72456 4.75833 4.90437 4.6875 5.11367 4.6875H12.8964C13.0991 4.6875 13.2722 4.75857 13.408 4.90133C13.5508 5.03718 13.6219 5.21027 13.6219 5.41299C13.6219 5.61613 13.5506 5.7918 13.409 5.93387C13.273 6.07732 13.0996 6.14873 12.8964 6.14873H5.11367C4.90437 6.14873 4.72456 6.0779 4.58154 5.93487L4.57954 5.93282ZM4.57954 9.51144C4.44486 9.36882 4.37793 9.19346 4.37793 8.9916C4.37793 8.78973 4.44495 8.61656 4.58154 8.47997C4.72456 8.33695 4.90437 8.26611 5.11367 8.26611H12.8964C13.0991 8.26611 13.2722 8.33719 13.408 8.47995C13.5508 8.61579 13.6219 8.78888 13.6219 8.9916C13.6219 9.19475 13.5506 9.37042 13.409 9.51249C13.273 9.65593 13.0996 9.72734 12.8964 9.72734H5.11367C4.90437 9.72734 4.72456 9.65651 4.58154 9.51348L4.57954 9.51144ZM4.57954 13.1003C4.44561 12.9585 4.37793 12.7869 4.37793 12.5907C4.37793 12.3831 4.44414 12.204 4.57954 12.0606L4.58151 12.0586C4.72453 11.9155 4.90437 11.8447 5.11367 11.8447H8.79482C9.00363 11.8447 9.18092 11.9153 9.3177 12.0596C9.46006 12.2024 9.53057 12.3819 9.53057 12.5907C9.53057 12.7887 9.45812 12.9609 9.31671 13.1024C9.17936 13.2397 9.00235 13.306 8.79482 13.306H5.11367C4.90609 13.306 4.72695 13.2397 4.58358 13.1043L4.57954 13.1003ZM1.09476 0.851519C1.65317 0.285946 2.47955 0.0117188 3.55508 0.0117188H14.4447C15.52 0.0117188 16.3433 0.28583 16.895 0.851748C17.4529 1.41698 17.7234 2.24966 17.7234 3.33145V18.8866C17.7234 19.975 17.4531 20.8082 16.8945 21.3668C16.3427 21.9256 15.5196 22.1961 14.4447 22.1961H3.55508C2.47988 22.1961 1.65367 21.9255 1.09521 21.367C0.543652 20.8083 0.276367 19.9747 0.276367 18.8866V3.33145C0.276367 2.24984 0.543796 1.41679 1.09476 0.851519ZM15.5624 20.0351C15.2958 20.3085 14.8959 20.4452 14.3627 20.4452H3.63711C3.10391 20.4452 2.70059 20.3085 2.42715 20.0351L2.49875 19.9652L2.49786 19.9643L2.42715 20.0351C2.16055 19.7616 2.02725 19.3686 2.02725 18.8559V3.36221C2.02725 2.84951 2.16055 2.45645 2.42715 2.18301C2.70059 1.90273 3.10391 1.7626 3.63711 1.7626H14.3627C14.8959 1.7626 15.2958 1.90273 15.5624 2.18301C15.8358 2.45645 15.9726 2.84951 15.9726 3.36221V18.8559C15.9726 19.3686 15.8358 19.7616 15.5624 20.0351Z"
fill="#3A3A3A"
/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,6 +1,6 @@
<svg viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.40918 4.70117C3.28613 4.70117 3.18359 4.66016 3.10156 4.57812C3.02409 4.49609 2.98535 4.39583 2.98535 4.27734C2.98535 4.15885 3.02409 4.06087 3.10156 3.9834C3.18359 3.90137 3.28613 3.86035 3.40918 3.86035H8.59766C8.71615 3.86035 8.81413 3.90137 8.8916 3.9834C8.97363 4.06087 9.01465 4.15885 9.01465 4.27734C9.01465 4.39583 8.97363 4.49609 8.8916 4.57812C8.81413 4.66016 8.71615 4.70117 8.59766 4.70117H3.40918ZM3.40918 7.08691C3.28613 7.08691 3.18359 7.0459 3.10156 6.96387C3.02409 6.88184 2.98535 6.78158 2.98535 6.66309C2.98535 6.5446 3.02409 6.44661 3.10156 6.36914C3.18359 6.28711 3.28613 6.24609 3.40918 6.24609H8.59766C8.71615 6.24609 8.81413 6.28711 8.8916 6.36914C8.97363 6.44661 9.01465 6.5446 9.01465 6.66309C9.01465 6.78158 8.97363 6.88184 8.8916 6.96387C8.81413 7.0459 8.71615 7.08691 8.59766 7.08691H3.40918ZM3.40918 9.47266C3.28613 9.47266 3.18359 9.43392 3.10156 9.35645C3.02409 9.27441 2.98535 9.17643 2.98535 9.0625C2.98535 8.93945 3.02409 8.83691 3.10156 8.75488C3.18359 8.67285 3.28613 8.63184 3.40918 8.63184H5.86328C5.98633 8.63184 6.08659 8.67285 6.16406 8.75488C6.24609 8.83691 6.28711 8.93945 6.28711 9.0625C6.28711 9.17643 6.24609 9.27441 6.16406 9.35645C6.08659 9.43392 5.98633 9.47266 5.86328 9.47266H3.40918ZM0.250977 13.2598V2.88965C0.250977 2.17871 0.426432 1.64323 0.777344 1.2832C1.13281 0.923177 1.66374 0.743164 2.37012 0.743164H9.62988C10.3363 0.743164 10.8649 0.923177 11.2158 1.2832C11.5713 1.64323 11.749 2.17871 11.749 2.88965V13.2598C11.749 13.9753 11.5713 14.5107 11.2158 14.8662C10.8649 15.2217 10.3363 15.3994 9.62988 15.3994H2.37012C1.66374 15.3994 1.13281 15.2217 0.777344 14.8662C0.426432 14.5107 0.250977 13.9753 0.250977 13.2598ZM1.35156 13.2393C1.35156 13.5811 1.44043 13.8431 1.61816 14.0254C1.80046 14.2077 2.06934 14.2988 2.4248 14.2988H9.5752C9.93066 14.2988 10.1973 14.2077 10.375 14.0254C10.5573 13.8431 10.6484 13.5811 10.6484 13.2393V2.91016C10.6484 2.56836 10.5573 2.30632 10.375 2.12402C10.1973 1.93717 9.93066 1.84375 9.5752 1.84375H2.4248C2.06934 1.84375 1.80046 1.93717 1.61816 2.12402C1.44043 2.30632 1.35156 2.56836 1.35156 2.91016V13.2393Z"
fill="#8585F6"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,6 +1,7 @@
import { codeBlock } from '@blocknote/code-block';
import { codeBlockOptions } from '@blocknote/code-block';
import {
BlockNoteSchema,
createCodeBlockSpec,
defaultBlockSpecs,
defaultInlineContentSpecs,
withPageBreak,
@@ -16,7 +17,11 @@ import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management';
import {
Doc,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import {
@@ -36,7 +41,6 @@ import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import {
AccessibleImageBlock,
CalloutBlock,
DividerBlock,
PdfBlock,
UploadLoaderBlock,
} from './custom-blocks';
@@ -53,11 +57,11 @@ const baseBlockNoteSchema = withPageBreak(
BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
image: AccessibleImageBlock,
pdf: PdfBlock,
uploadLoader: UploadLoaderBlock,
callout: CalloutBlock(),
codeBlock: createCodeBlockSpec(codeBlockOptions),
image: AccessibleImageBlock(),
pdf: PdfBlock(),
uploadLoader: UploadLoaderBlock(),
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
@@ -79,9 +83,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { user } = useAuth();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isConnectedToCollabServer = provider.isSynced;
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
const isDeletedDoc = !!doc.deleted_at;
@@ -98,7 +102,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
codeBlock,
collaboration: {
provider,
fragment: provider.document.getXmlFragment('document-store'),

View File

@@ -1,5 +1,6 @@
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
import {
DefaultReactSuggestionItem,
SuggestionMenuController,
getDefaultReactSlashMenuItems,
getPageBreakReactSlashMenuItems,
@@ -17,7 +18,6 @@ import {
import {
getCalloutReactSlashMenuItems,
getDividerReactSlashMenuItems,
getPdfReactSlashMenuItems,
} from './custom-blocks';
import { useGetInterlinkingMenuItems } from './custom-inline-content';
@@ -42,29 +42,29 @@ export const BlockNoteSuggestionMenu = () => {
const getSlashMenuItems = useMemo(() => {
// We insert it after the "Code Block" item to have the interlinking block displayed after the basic blocks
const defaultMenu = getDefaultReactSlashMenuItems(editor);
const index = defaultMenu.findIndex(
(item) => item.aliases?.includes('code') && item.aliases?.includes('pre'),
const combinedMenu = combineByGroup(
defaultMenu,
getPageBreakReactSlashMenuItems(editor),
getMultiColumnSlashMenuItems?.(editor) || [],
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
);
const index = combinedMenu.findIndex(
(item) =>
(item as DefaultReactSuggestionItem & { key: string })?.key ===
'callout',
);
const newSlashMenuItems = [
...defaultMenu.slice(0, index + 1),
...combinedMenu.slice(0, index + 1),
...getInterlinkingMenuItems(editor, t),
...defaultMenu.slice(index + 1),
...combinedMenu.slice(index + 1),
];
return async (query: string) =>
Promise.resolve(
filterSuggestionItems(
combineByGroup(
newSlashMenuItems,
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
getMultiColumnSlashMenuItems?.(editor) || [],
getPageBreakReactSlashMenuItems(editor),
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
),
query,
),
);
Promise.resolve(filterSuggestionItems(newSlashMenuItems, query));
}, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]);
return (

View File

@@ -1,9 +1,16 @@
/**
* We added some custom logic to the original Blocknote FileDownloadButton
* component to handle our file download use case.
*
* Original source:
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
*/
import {
BlockSchema,
InlineContentSchema,
StyleSchema,
checkBlockIsFileBlock,
checkBlockIsFileBlockWithPlaceholder,
blockHasType,
} from '@blocknote/core';
import {
useBlockNoteEditor,
@@ -41,7 +48,9 @@ export const FileDownloadButton = ({
const block = selectedBlocks[0];
if (checkBlockIsFileBlock(block, editor)) {
if (
blockHasType(block, editor, block.type, { url: 'string', name: 'string' })
) {
return block;
}
@@ -53,6 +62,7 @@ export const FileDownloadButton = ({
editor.focus();
const url = fileBlock.props.url as string;
const name = fileBlock.props.name as string | undefined;
/**
* If not hosted on our domain, means not a file uploaded by the user,
@@ -76,16 +86,12 @@ export const FileDownloadButton = ({
if (!url.includes('-unsafe')) {
const blob = (await exportResolveFileUrl(url)) as Blob;
downloadFile(
blob,
fileBlock.props.name || url.split('/').pop() || 'file',
);
downloadFile(blob, name || url.split('/').pop() || 'file');
} else {
const onConfirm = async () => {
const blob = (await exportResolveFileUrl(url)) as Blob;
const baseName =
fileBlock.props.name || url.split('/').pop() || 'file';
const baseName = name || url.split('/').pop() || 'file';
const regFindLastDot = /(\.[^/.]+)$/;
const unsafeName = baseName.includes('.')
@@ -100,11 +106,7 @@ export const FileDownloadButton = ({
}
}, [editor, fileBlock, open]);
if (
!fileBlock ||
checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
!Components
) {
if (!fileBlock || fileBlock.props.url === '' || !Components) {
return null;
}

View File

@@ -22,7 +22,7 @@ function isBlock(block: Block): block is Block {
);
}
const recursiveContent = (content: Block[], base: string = '') => {
const recursiveContent = (content: Block[], base = '') => {
let fullContent = base;
for (const innerContent of content) {
if (innerContent.type === 'text') {

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { Box, Loading, Text, TextErrors } from '@/components';
import { DocHeader, DocVersionHeader } from '@/docs/doc-header/';
import {
Doc,
@@ -13,6 +13,7 @@ import {
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
import { useSkeletonStore } from '@/features/skeletons';
import { useResponsiveStore } from '@/stores';
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
@@ -25,10 +26,18 @@ interface DocEditorProps {
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const isVersion = !!versionId && typeof versionId === 'string';
const { provider } = useProviderStore();
const { provider, isReady } = useProviderStore();
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider;
if (!provider) {
return null;
useEffect(() => {
if (isProviderReady) {
setIsSkeletonVisible(false);
}
}, [isProviderReady, setIsSkeletonVisible]);
if (!isProviderReady) {
return <Loading />;
}
return (

View File

@@ -9,16 +9,18 @@ interface EmojiPickerProps {
emojiData: EmojiMartData;
onClickOutside: () => void;
onEmojiSelect: ({ native }: { native: string }) => void;
withOverlay?: boolean;
}
export const EmojiPicker = ({
emojiData,
onClickOutside,
onEmojiSelect,
withOverlay = false,
}: EmojiPickerProps) => {
const { i18n } = useTranslation();
return (
const pickerContent = (
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Picker
data={emojiData}
@@ -30,4 +32,27 @@ export const EmojiPicker = ({
/>
</Box>
);
if (withOverlay) {
return (
<>
{/* Overlay transparent pour fermer en cliquant à l'extérieur */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
zIndex: 999,
backgroundColor: 'transparent',
}}
onClick={onClickOutside}
/>
{pickerContent}
</>
);
}
return pickerContent;
};

View File

@@ -1,43 +1,62 @@
/**
* AccessibleImageBlock.tsx
*
* This file defines a custom BlockNote block specification for an accessible image block.
* It extends the default image block to ensure compliance with accessibility standards,
* specifically RGAA 1.9.1, by using <figure> and <figcaption> elements when a caption is provided.
*
* The accessible image block ensures that:
* - Images with captions are wrapped in <figure> and <figcaption> elements.
* - The <img> element has an appropriate alt attribute based on the caption.
* - Accessibility attributes such as role and aria-label are added for better screen reader support.
* - Images without captions have alt="" and are marked as decorative with aria-hidden="true".
*
* This implementation leverages BlockNote's existing image block functionality while enhancing it for accessibility.
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/blocks/Image/block.ts
*/
import {
BlockFromConfig,
BlockNoteEditor,
BlockSchemaWithBlock,
ImageOptions,
InlineContentSchema,
InlineContentSchemaFromSpecs,
StyleSchema,
createBlockSpec,
imageBlockConfig,
createImageBlockConfig,
defaultInlineContentSpecs,
imageParse,
imageRender,
imageToExternalHTML,
} from '@blocknote/core';
import { t } from 'i18next';
type ImageBlockConfig = typeof imageBlockConfig;
type CreateImageBlockConfig = ReturnType<typeof createImageBlockConfig>;
export const accessibleImageRender = (
block: BlockFromConfig<ImageBlockConfig, InlineContentSchema, StyleSchema>,
editor: BlockNoteEditor<
BlockSchemaWithBlock<ImageBlockConfig['type'], ImageBlockConfig>,
InlineContentSchema,
StyleSchema
>,
) => {
const imageRenderComputed = imageRender(block, editor);
const dom = imageRenderComputed.dom;
const imgSelector = dom.querySelector('img');
const withCaption =
block.props.caption && dom.querySelector('.bn-file-caption');
const accessibleImageWithCaption = () => {
imgSelector?.setAttribute('alt', block.props.caption);
imgSelector?.removeAttribute('aria-hidden');
imgSelector?.setAttribute('tabindex', '0');
export const accessibleImageRender =
(config: ImageOptions) =>
(
block: BlockFromConfig<
CreateImageBlockConfig,
InlineContentSchema,
StyleSchema
>,
editor: BlockNoteEditor<
Record<'image', CreateImageBlockConfig>,
InlineContentSchemaFromSpecs<typeof defaultInlineContentSpecs>,
StyleSchema
>,
) => {
const imageRenderComputed = imageRender(config);
const dom = imageRenderComputed(block, editor).dom;
const imgSelector = dom.querySelector('img');
// Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists
const captionElement = dom.querySelector('.bn-file-caption');
const accessibleImageWithCaption = () => {
imgSelector?.setAttribute('alt', block.props.caption);
imgSelector?.removeAttribute('aria-hidden');
imgSelector?.setAttribute('tabindex', '0');
if (captionElement) {
const figureElement = document.createElement('figure');
// Copy all attributes from the original div
@@ -76,32 +95,36 @@ export const accessibleImageRender = (
...imageRenderComputed,
dom: figureElement,
};
}
};
const accessibleImage = () => {
imgSelector?.setAttribute('alt', '');
imgSelector?.setAttribute('role', 'presentation');
imgSelector?.setAttribute('aria-hidden', 'true');
imgSelector?.setAttribute('tabindex', '-1');
return {
...imageRenderComputed,
dom,
};
};
const withCaption =
block.props.caption && dom.querySelector('.bn-file-caption');
// Set accessibility attributes for the image
return withCaption ? accessibleImageWithCaption() : accessibleImage();
};
const accessibleImage = () => {
imgSelector?.setAttribute('alt', '');
imgSelector?.setAttribute('role', 'presentation');
imgSelector?.setAttribute('aria-hidden', 'true');
imgSelector?.setAttribute('tabindex', '-1');
};
// Set accessibility attributes for the image
const result = withCaption ? accessibleImageWithCaption() : accessibleImage();
// Return the result if accessibleImageWithCaption created a figure, otherwise return original
if (result) {
return result;
}
return {
...imageRenderComputed,
dom,
};
};
export const AccessibleImageBlock = createBlockSpec(imageBlockConfig, {
render: accessibleImageRender,
parse: imageParse,
toExternalHTML: imageToExternalHTML,
});
export const AccessibleImageBlock = createBlockSpec(
createImageBlockConfig,
(config) => ({
meta: {
fileBlockAccept: ['image/*'],
},
render: accessibleImageRender(config),
parse: imageParse(config),
toExternalHTML: imageToExternalHTML(config),
runsBefore: ['file'],
}),
);

View File

@@ -1,9 +1,16 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import {
BlockConfig,
BlockNoDefaults,
BlockNoteEditor,
InlineContentSchema,
StyleSchema,
defaultProps,
insertOrUpdateBlock,
} from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React, { useEffect, useState } from 'react';
import { css } from 'styled-components';
import { createGlobalStyle, css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
@@ -12,90 +19,131 @@ import { EmojiPicker } from '../EmojiPicker';
import emojidata from './initEmojiCallout';
const CalloutBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="callout"][data-background-color] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
`;
type CreateCalloutBlockConfig = BlockConfig<
'callout',
{
textAlignment: typeof defaultProps.textAlignment;
backgroundColor: typeof defaultProps.backgroundColor;
emoji: { default: '💡' };
},
'inline'
>;
interface CalloutComponentProps {
block: BlockNoDefaults<
Record<'callout', CreateCalloutBlockConfig>,
InlineContentSchema,
StyleSchema
>;
editor: BlockNoteEditor<
Record<'callout', CreateCalloutBlockConfig>,
InlineContentSchema,
StyleSchema
>;
contentRef: (node: HTMLElement | null) => void;
}
const CalloutComponent = ({
block,
editor,
contentRef,
}: CalloutComponentProps) => {
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
const isEditable = editor.isEditable;
const toggleEmojiPicker = (e: React.MouseEvent) => {
if (!isEditable) {
return;
}
e.preventDefault();
e.stopPropagation();
setOpenEmojiPicker(!openEmojiPicker);
};
const onClickOutside = () => setOpenEmojiPicker(false);
const onEmojiSelect = ({ native }: { native: string }) => {
editor.updateBlock(block, { props: { emoji: native } });
setOpenEmojiPicker(false);
};
// Temporary: sets a yellow background color to a callout block when added by
// the user, while keeping the colors menu on the drag handler usable for
// this custom block.
useEffect(() => {
if (!block.content.length && block.props.backgroundColor === 'default') {
// Delay the update to avoid interfering with the block insertion process
setTimeout(() => {
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
}, 0);
}
}, [block, editor]);
return (
<Box
$padding="1rem"
$gap="0.625rem"
$direction="row"
$align="center"
$css={css`
flex-grow: 1;
`}
>
<CalloutBlockStyle />
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
$css={css`
font-size: 1.125rem;
cursor: ${isEditable ? 'pointer' : 'default'};
${isEditable &&
`
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`}
`}
$align="center"
$width="28px"
$radius="4px"
>
{block.props.emoji}
</BoxButton>
{openEmojiPicker && (
<EmojiPicker
emojiData={emojidata}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
withOverlay={true}
/>
)}
<Box as="p" className="inline-content" ref={contentRef} />
</Box>
);
};
export const CalloutBlock = createReactBlockSpec(
{
type: 'callout',
propSchema: {
textAlignment: defaultProps.textAlignment,
backgroundColor: defaultProps.backgroundColor,
backgroundColor: { default: 'default' as const },
emoji: { default: '💡' },
},
content: 'inline',
},
{
render: ({ block, editor, contentRef }) => {
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
const isEditable = editor.isEditable;
const toggleEmojiPicker = (e: React.MouseEvent) => {
if (!isEditable) {
return;
}
e.preventDefault();
e.stopPropagation();
setOpenEmojiPicker(!openEmojiPicker);
};
const onClickOutside = () => setOpenEmojiPicker(false);
const onEmojiSelect = ({ native }: { native: string }) => {
editor.updateBlock(block, { props: { emoji: native } });
setOpenEmojiPicker(false);
};
// Temporary: sets a yellow background color to a callout block when added by
// the user, while keeping the colors menu on the drag handler usable for
// this custom block.
useEffect(() => {
if (
!block.content.length &&
block.props.backgroundColor === 'default'
) {
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
}
}, [block, editor]);
return (
<Box
$padding="1rem"
$gap="0.625rem"
style={{
flexGrow: 1,
flexDirection: 'row',
}}
>
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
$css={css`
font-size: 1.125rem;
cursor: ${isEditable ? 'pointer' : 'default'};
${isEditable &&
`
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`}
`}
$align="center"
$height="28px"
$width="28px"
$radius="4px"
>
{block.props.emoji}
</BoxButton>
{openEmojiPicker && (
<EmojiPicker
emojiData={emojidata}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
/>
)}
<Box as="p" className="inline-content" ref={contentRef} />
</Box>
);
},
render: ({ block, editor, contentRef }) => (
<CalloutComponent block={block} editor={editor} contentRef={contentRef} />
),
},
);
@@ -105,6 +153,7 @@ export const getCalloutReactSlashMenuItems = (
group: string,
) => [
{
key: 'callout',
title: t('Callout'),
onItemClick: () => {
insertOrUpdateBlock(editor, {

View File

@@ -1,51 +0,0 @@
import { insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import { Box, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../../types';
export const DividerBlock = createReactBlockSpec(
{
type: 'divider',
propSchema: {},
content: 'none',
},
{
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { colorsTokens } = useCunninghamTheme();
return (
<Box
as="hr"
$width="100%"
$background={colorsTokens['greyscale-300']}
$margin="1rem 0"
$css={`border: 1px solid ${colorsTokens['greyscale-300']};`}
/>
);
},
},
);
export const getDividerReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Divider'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'divider',
});
},
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
group,
icon: <Icon iconName="remove" $size="18px" />,
subtext: t('Add a horizontal line'),
},
];

View File

@@ -1,11 +1,19 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { insertOrUpdateBlock } from '@blocknote/core';
import {
BlockConfig,
BlockNoDefaults,
BlockNoteEditor,
InlineContentSchema,
StyleSchema,
insertOrUpdateBlock,
} from '@blocknote/core';
import * as locales from '@blocknote/core/locales';
import {
AddFileButton,
ResizableFileBlockWrapper,
createReactBlockSpec,
} from '@blocknote/react';
import { TFunction } from 'i18next';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
@@ -20,57 +28,106 @@ const PDFBlockStyle = createGlobalStyle`
`;
type FileBlockEditor = Parameters<typeof AddFileButton>[0]['editor'];
type FileBlockBlock = Parameters<typeof AddFileButton>[0]['block'];
type CreatePDFBlockConfig = BlockConfig<
'pdf',
{
backgroundColor: { default: 'default' };
caption: { default: '' };
name: { default: '' };
previewWidth: { default: undefined; type: 'number' };
showPreview: { default: true };
textAlignment: { default: 'left' };
url: { default: '' };
},
'none'
>;
interface PdfBlockComponentProps {
block: BlockNoDefaults<
Record<'callout', CreatePDFBlockConfig>,
InlineContentSchema,
StyleSchema
>;
contentRef: (node: HTMLElement | null) => void;
editor: BlockNoteEditor<
Record<'pdf', CreatePDFBlockConfig>,
InlineContentSchema,
StyleSchema
>;
}
const PdfBlockComponent = ({
editor,
block,
contentRef,
}: PdfBlockComponentProps) => {
const pdfUrl = block.props.url;
const { i18n, t } = useTranslation();
const lang = i18n.resolvedLanguage;
useEffect(() => {
if (lang && locales[lang as keyof typeof locales]) {
locales[lang as keyof typeof locales].file_blocks.add_button_text['pdf'] =
t('Add PDF');
(
locales[lang as keyof typeof locales].file_panel.embed
.embed_button as Record<string, string>
)['pdf'] = t('Add PDF');
(
locales[lang as keyof typeof locales].file_panel.upload
.file_placeholder as Record<string, string>
)['pdf'] = t('Upload PDF');
}
}, [lang, t]);
return (
<Box ref={contentRef} className="bn-file-block-content-wrapper">
<PDFBlockStyle />
<ResizableFileBlockWrapper
buttonIcon={
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
}
block={block as unknown as FileBlockBlock}
editor={editor as unknown as FileBlockEditor}
>
<Box
className="bn-visual-media"
role="presentation"
as="embed"
$width="100%"
$height="450px"
type="application/pdf"
src={pdfUrl}
contentEditable={false}
draggable={false}
onClick={() => editor.setTextCursorPosition(block)}
/>
</ResizableFileBlockWrapper>
</Box>
);
};
export const PdfBlock = createReactBlockSpec(
{
type: 'pdf',
content: 'none',
propSchema: {
name: { default: '' as const },
url: { default: '' as const },
backgroundColor: { default: 'default' as const },
caption: { default: '' as const },
showPreview: { default: true },
name: { default: '' as const },
previewWidth: { default: undefined, type: 'number' },
showPreview: { default: true },
textAlignment: { default: 'left' as const },
url: { default: '' as const },
},
isFileBlock: true,
fileBlockAccept: ['application/pdf'],
},
{
render: ({ editor, block, contentRef }) => {
const { t } = useTranslation();
const pdfUrl = block.props.url;
return (
<Box ref={contentRef} className="bn-file-block-content-wrapper">
<PDFBlockStyle />
<ResizableFileBlockWrapper
buttonIcon={
<Icon
iconName="upload"
$size="24px"
$css="line-height: normal;"
/>
}
block={block}
editor={editor as unknown as FileBlockEditor}
buttonText={t('Add PDF')}
>
<Box
className="bn-visual-media"
role="presentation"
as="embed"
$width="100%"
$height="450px"
type="application/pdf"
src={pdfUrl}
contentEditable={false}
draggable={false}
onClick={() => editor.setTextCursorPosition(block)}
/>
</ResizableFileBlockWrapper>
</Box>
);
meta: {
fileBlockAccept: ['application/pdf'],
},
render: (props) => <PdfBlockComponent {...props} />,
},
);

View File

@@ -1,5 +1,5 @@
export * from './AccessibleImageBlock';
export * from './CalloutBlock';
export * from './DividerBlock';
export { default as emojidata } from './initEmojiCallout';
export * from './PdfBlock';
export * from './UploadLoaderBlock';

View File

@@ -7,7 +7,7 @@ import { css } from 'styled-components';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
import { useDoc } from '@/docs/doc-management';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management';
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
{
@@ -52,11 +52,13 @@ interface LinkSelectedProps {
}
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
const { colorsTokens } = useCunninghamTheme();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
const router = useRouter();
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
router.push(url);
void router.push(url);
};
return (
@@ -78,9 +80,21 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
transition: background-color 0.2s ease-in-out;
`}
>
<SelectedPageIcon width={11.5} />
<Text $weight="500" spellCheck="false" $size="16px" $display="inline">
{title}
{emoji ? (
<Text $size="16px">{emoji}</Text>
) : (
<SelectedPageIcon width={11.5} color={colorsTokens['primary-400']} />
)}
<Text
$weight="500"
spellCheck="false"
$size="16px"
$display="inline"
$css={css`
margin-left: 2px;
`}
>
{titleWithoutEmoji}
</Text>
</BoxButton>
);

View File

@@ -47,6 +47,7 @@ export const getInterlinkinghMenuItems = (
createPage: () => void,
) => [
{
key: 'link-doc',
title: t('Link a doc'),
onItemClick: () => {
editor.insertInlineContent([
@@ -65,6 +66,7 @@ export const getInterlinkinghMenuItems = (
subtext: t('Link this doc to another doc'),
},
{
key: 'new-sub-doc',
title: t('New sub-doc'),
onItemClick: createPage,
aliases: ['new sub-doc'],

View File

@@ -25,6 +25,7 @@ import {
import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg';
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
import {
getEmojiAndTitle,
useCreateChildDocTree,
useDocStore,
useTrans,
@@ -43,17 +44,19 @@ const inputStyle = css`
`;
type SearchPageProps = {
trigger: string;
trigger: '/' | '@';
updateInlineContent: (
update: PartialCustomInlineContentFromConfig<
{
type: string;
type: 'interlinkingSearchInline';
propSchema: {
disabled: {
default: boolean;
default: false;
values: [true, false];
};
trigger: {
default: string;
default: '/';
values: ['/', '@'];
};
};
content: 'styled';
@@ -234,35 +237,56 @@ export const SearchPage = ({
editor.focus();
}}
renderElement={(doc) => (
<QuickSearchItemContent
left={
<Box
$direction="row"
$gap="0.6rem"
$align="center"
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
$width="100%"
>
<FoundPageIcon />
<Text
$size="14px"
$color="var(--c--theme--colors--greyscale-1000)"
spellCheck="false"
renderElement={(doc) => {
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
doc.title || untitledDocument,
);
return (
<QuickSearchItemContent
left={
<Box
$direction="row"
$gap="0.2rem"
$align="center"
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
$width="100%"
>
{doc.title}
</Text>
</Box>
}
right={
<Icon
iconName="keyboard_return"
$variation="600"
spellCheck="false"
/>
}
/>
)}
<Box
$css={css`
width: 24px;
flex-shrink: 0;
`}
>
{emoji ? (
<Text $size="18px">{emoji}</Text>
) : (
<FoundPageIcon
width="100%"
style={{ maxHeight: '24px' }}
/>
)}
</Box>
<Text
$size="14px"
$color="var(--c--theme--colors--greyscale-1000)"
spellCheck="false"
>
{titleWithoutEmoji}
</Text>
</Box>
}
right={
<Icon
iconName="keyboard_return"
$variation="600"
spellCheck="false"
/>
}
/>
);
}}
/>
<QuickSearchGroup
group={{

View File

@@ -1,2 +1,3 @@
export * from './DocEditor';
export * from './EmojiPicker';
export * from './custom-blocks/';

View File

@@ -16,6 +16,9 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
font-weight: 400;
}
/**
* 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;
@@ -29,14 +32,16 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
position: relative;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: #767676 !important;
}
/**
* 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;
}
@@ -87,6 +92,9 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
.bn-side-menu[data-block-type='divider'] {
height: 38px;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: #767676 !important;
}
/**
* Callout, Paragraph and Heading blocks
@@ -94,21 +102,17 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
.bn-block {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block[data-background-color] > .bn-block-content {
.bn-block > .bn-block-content[data-background-color] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
text-decoration: none;
}
h1 {
font-size: 1.875rem;
}
@@ -146,6 +150,16 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
border-left: 4px solid var(--c--theme--colors--greyscale-300);
font-style: italic;
}
/**
* Divider
*/
[data-content-type='divider'] hr {
background: #d3d2cf;
margin: 1rem 0;
width: 100%;
border: 1px solid #d3d2cf;
}
}
& .bn-block-outer:not(:first-child) {

View File

@@ -1,24 +0,0 @@
import { Paragraph } from 'docx';
import { useCunninghamTheme } from '@/cunningham';
import { DocsExporterDocx } from '../types';
export const blockMappingDividerDocx: DocsExporterDocx['mappings']['blockMapping']['divider'] =
() => {
const { colorsTokens } = useCunninghamTheme.getState();
return new Paragraph({
spacing: {
before: 200,
},
border: {
top: {
color: colorsTokens['greyscale-300'],
size: 1,
style: 'single',
space: 1,
},
},
});
};

View File

@@ -1,20 +0,0 @@
import { Text } from '@react-pdf/renderer';
import { useCunninghamTheme } from '@/cunningham';
import { DocsExporterPDF } from '../types';
export const blockMappingDividerPDF: DocsExporterPDF['mappings']['blockMapping']['divider'] =
() => {
const { colorsTokens } = useCunninghamTheme.getState();
return (
<Text
style={{
marginVertical: 10,
backgroundColor: colorsTokens['greyscale-300'],
height: '2px',
}}
/>
);
};

View File

@@ -100,16 +100,13 @@ function blockPropsToStyles(
? undefined
: {
type: ShadingType.SOLID,
color:
colors[
props.backgroundColor as keyof typeof colors
].background.slice(1),
color: colors[props.backgroundColor].background.slice(1),
},
run:
props.textColor === 'default' || !props.textColor
? undefined
: {
color: colors[props.textColor as keyof typeof colors].text.slice(1),
color: colors[props.textColor].text.slice(1),
},
alignment:
!props.textAlignment || props.textAlignment === 'left'

View File

@@ -1,7 +1,5 @@
export * from './calloutDocx';
export * from './calloutPDF';
export * from './dividerDocx';
export * from './dividerPDF';
export * from './headingPDF';
export * from './imageDocx';
export * from './imagePDF';

View File

@@ -92,15 +92,11 @@ export const blockMappingTablePDF: DocsExporterPDF['mappings']['blockMapping']['
color:
cellProps.textColor === 'default'
? undefined
: options.colors[
cellProps.textColor as keyof typeof options.colors
].text,
: options.colors[cellProps.textColor].text,
backgroundColor:
cellProps.backgroundColor === 'default'
? undefined
: options.colors[
cellProps.backgroundColor as keyof typeof options.colors
].background,
: options.colors[cellProps.backgroundColor].background,
textAlign: cellProps.textAlignment,
},
];

View File

@@ -1,9 +1,8 @@
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
import { Paragraph } from 'docx';
import { TextRun } from 'docx';
import {
blockMappingCalloutDocx,
blockMappingDividerDocx,
blockMappingImageDocx,
blockMappingQuoteDocx,
blockMappingUploadLoaderDocx,
@@ -16,9 +15,8 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
blockMapping: {
...docxDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutDocx,
divider: blockMappingDividerDocx,
// We're using the file block mapping for PDF blocks
// The types don't match exactly but the implementation is compatible
// We're reusing the file block mapping for PDF blocks; both share the same
// implementation signature, so we can reuse the handler directly.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: docxDefaultSchemaMappings.blockMapping.file as any,
quote: blockMappingQuoteDocx,
@@ -27,7 +25,7 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
},
inlineContentMapping: {
...docxDefaultSchemaMappings.inlineContentMapping,
interlinkingSearchInline: () => new Paragraph(''),
interlinkingSearchInline: () => new TextRun(''),
interlinkingLinkInline: inlineContentMappingInterlinkingLinkDocx,
},
styleMapping: {

View File

@@ -2,7 +2,6 @@ import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
import {
blockMappingCalloutPDF,
blockMappingDividerPDF,
blockMappingHeadingPDF,
blockMappingImagePDF,
blockMappingParagraphPDF,
@@ -21,7 +20,6 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
heading: blockMappingHeadingPDF,
image: blockMappingImagePDF,
paragraph: blockMappingParagraphPDF,
divider: blockMappingDividerPDF,
quote: blockMappingQuotePDF,
table: blockMappingTablePDF,
// We're using the file block mapping for PDF blocks

View File

@@ -76,16 +76,13 @@ export function docxBlockPropsToStyles(
? undefined
: {
type: ShadingType.SOLID,
color:
colors[
props.backgroundColor as keyof typeof colors
].background.slice(1),
color: colors[props.backgroundColor].background.slice(1),
},
run:
props.textColor === 'default' || !props.textColor
? undefined
: {
color: colors[props.textColor as keyof typeof colors].text.slice(1),
color: colors[props.textColor].text.slice(1),
},
alignment:
!props.textAlignment || props.textAlignment === 'left'

View File

@@ -1,4 +1,3 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Tooltip } from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,14 +7,16 @@ import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
DocIcon,
getEmojiAndTitle,
useDocStore,
useDocTitleUpdate,
useDocUtils,
useIsCollaborativeEditable,
useTrans,
useUpdateDoc,
} from '@/docs/doc-management';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
import { useResponsiveStore } from '@/stores';
interface DocTitleProps {
doc: Doc;
@@ -49,52 +50,77 @@ export const DocTitleText = () => {
);
};
const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { emoji } = getEmojiAndTitle(doc.title ?? '');
return (
<Tooltip content={t('Document emoji')} aria-hidden={true} placement="top">
<Box
$css={css`
padding: 4px;
padding-top: 3px;
cursor: pointer;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
border-radius: 4px;
}
transition: background-color 0.2s ease-in-out;
`}
>
<DocIcon
withEmojiPicker={doc.abilities.partial_update}
docId={doc.id}
title={doc.title}
emoji={emoji}
$size="25px"
defaultIcon={
<SimpleFileIcon
width="25px"
height="25px"
aria-hidden="true"
aria-label={t('Simple document icon')}
color={colorsTokens['primary-500']}
/>
}
/>
</Box>
</Tooltip>
);
};
const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const treeContext = useTreeContext<Doc>();
const { spacingsTokens } = useCunninghamTheme();
const { isTopRoot } = useDocUtils(doc);
const { untitledDocument } = useTrans();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title ?? '');
const [titleDisplay, setTitleDisplay] = useState(
isTopRoot ? doc.title : titleWithoutEmoji,
);
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(updatedDoc) {
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
if (!treeContext) {
return;
}
if (treeContext.root?.id === updatedDoc.id) {
treeContext?.setRoot(updatedDoc);
} else {
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
}
},
});
const { updateDocTitle } = useDocTitleUpdate();
const handleTitleSubmit = useCallback(
(inputText: string) => {
let sanitizedTitle = inputText.trim();
sanitizedTitle = sanitizedTitle.replace(/(\r\n|\n|\r)/gm, '');
// When blank we set to untitled
if (!sanitizedTitle) {
setTitleDisplay('');
}
// If mutation we update
if (sanitizedTitle !== doc.title) {
if (isTopRoot) {
const sanitizedTitle = updateDocTitle(doc, inputText);
setTitleDisplay(sanitizedTitle);
updateDoc({ id: doc.id, title: sanitizedTitle });
} else {
const sanitizedTitle = updateDocTitle(
doc,
emoji ? `${emoji} ${inputText}` : inputText,
);
const { titleWithoutEmoji: sanitizedTitleWithoutEmoji } =
getEmojiAndTitle(sanitizedTitle);
setTitleDisplay(sanitizedTitleWithoutEmoji);
}
},
[doc.id, doc.title, updateDoc],
[updateDocTitle, doc, emoji, isTopRoot],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -105,43 +131,62 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
};
useEffect(() => {
setTitleDisplay(doc.title);
}, [doc]);
setTitleDisplay(isTopRoot ? doc.title : titleWithoutEmoji);
}, [doc.title, isTopRoot, titleWithoutEmoji]);
return (
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
<Box
as="span"
role="textbox"
className="--docs--doc-title-input"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label={`${t('Document title')}`}
aria-multiline={false}
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
$color={colorsTokens['greyscale-1000']}
$minHeight="40px"
$padding={{ right: 'big' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
<Box
className="--docs--doc-title"
$direction="row"
$align="center"
$gap={spacingsTokens['xs']}
$minHeight="40px"
>
{isTopRoot && (
<SimpleFileIcon
width="25px"
height="25px"
aria-hidden="true"
aria-label={t('Simple document icon')}
color={colorsTokens['primary-500']}
style={{ flexShrink: '0' }}
/>
)}
{!isTopRoot && <DocTitleEmojiPicker doc={doc} />}
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
<Box
as="span"
role="textbox"
className="--docs--doc-title-input"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label={`${t('Document title')}`}
aria-multiline={false}
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
$color={colorsTokens['greyscale-1000']}
$padding={{ right: 'big' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
</Box>
);
};

View File

@@ -20,9 +20,11 @@ import {
KEY_DOC,
KEY_LIST_DOC,
ModalRemoveDoc,
getEmojiAndTitle,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
useDocTitleUpdate,
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
@@ -49,7 +51,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const treeContext = useTreeContext<Doc>();
const queryClient = useQueryClient();
const router = useRouter();
const { isChild } = useDocUtils(doc);
const { isChild, isTopRoot } = useDocUtils(doc);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -83,6 +85,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
});
}, [selectHistoryModal.isOpen, queryClient]);
// Emoji Management
const { emoji } = getEmojiAndTitle(doc.title ?? '');
const { updateDocEmoji } = useDocTitleUpdate();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
@@ -118,6 +124,17 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
...(emoji && doc.abilities.partial_update && !isTopRoot
? [
{
label: t('Remove emoji'),
icon: 'emoji_emotions',
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '');
},
},
]
: []),
{
label: t('Version history'),
icon: 'history',

View File

@@ -9,4 +9,3 @@ export * from './useDuplicateDoc';
export * from './useRestoreDoc';
export * from './useSubDocs';
export * from './useUpdateDoc';
export * from './useUpdateDocLink';

View File

@@ -20,9 +20,10 @@ export const createDoc = async (): Promise<Doc> => {
interface CreateDocProps {
onSuccess: (data: Doc) => void;
onError?: (error: APIError) => void;
}
export function useCreateDoc({ onSuccess }: CreateDocProps) {
export function useCreateDoc({ onSuccess, onError }: CreateDocProps) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError>({
mutationFn: createDoc,
@@ -32,5 +33,8 @@ export function useCreateDoc({ onSuccess }: CreateDocProps) {
});
onSuccess(data);
},
onError: (error) => {
onError?.(error);
},
});
}

View File

@@ -80,7 +80,7 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
return await duplicateDoc(variables);
},
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
@@ -89,14 +89,14 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
duration: 3000,
});
void options?.onSuccess?.(data, variables, context);
void options?.onSuccess?.(data, variables, onMutateResult, context);
},
onError: (error, variables, context) => {
onError: (error, variables, onMutateResult, context) => {
toast(t('Failed to duplicate the document...'), VariantType.ERROR, {
duration: 3000,
});
void options?.onError?.(error, variables, context);
void options?.onError?.(error, variables, onMutateResult, context);
},
});
}

View File

@@ -33,19 +33,19 @@ export const useRemoveDoc = ({
return useMutation<void, APIError, RemoveDocProps>({
mutationFn: removeDoc,
...options,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, onMutateResult, context);
}
},
onError: (error, variables, context) => {
onError: (error, variables, onMutateResult, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
void options.onError(error, variables, onMutateResult, context);
}
},
});

View File

@@ -36,19 +36,19 @@ export const useRestoreDoc = ({
return useMutation<void, APIError, RestoreDocProps>({
mutationFn: restoreDoc,
...options,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, onMutateResult, context);
}
},
onError: (error, variables, context) => {
onError: (error, variables, onMutateResult, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
void options.onError(error, variables, onMutateResult, context);
}
},
});

View File

@@ -42,7 +42,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
return useMutation<Doc, APIError, UpdateDocParams>({
mutationFn: updateDoc,
...queryConfig,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
@@ -50,10 +50,10 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
});
if (queryConfig?.onSuccess) {
void queryConfig.onSuccess(data, variables, context);
void queryConfig.onSuccess(data, variables, onMutateResult, context);
}
},
onError: (error, variables, context) => {
onError: (error, variables, onMutateResult, context) => {
// If error it means the user is probably not allowed to edit the doc
// so we invalidate the canEdit query to update the UI accordingly
void queryClient.invalidateQueries({
@@ -61,7 +61,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
});
if (queryConfig?.onError) {
queryConfig.onError(error, variables, context);
queryConfig.onError(error, variables, onMutateResult, context);
}
},
});

View File

@@ -1,6 +1,4 @@
<svg
width="33"
height="33"
viewBox="0 0 33 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -1,8 +1,18 @@
import { Text, TextType } from '@/components';
import { MouseEvent, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { BoxButton, Icon, TextType } from '@/components';
import { EmojiPicker, emojidata } from '@/docs/doc-editor/';
import { useDocTitleUpdate } from '../hooks/useDocTitleUpdate';
type DocIconProps = TextType & {
emoji?: string | null;
defaultIcon: React.ReactNode;
docId?: string;
title?: string;
onEmojiUpdate?: (emoji: string) => void;
withEmojiPicker?: boolean;
};
export const DocIcon = ({
@@ -11,22 +21,102 @@ export const DocIcon = ({
$size = 'sm',
$variation = '1000',
$weight = '400',
docId,
title,
onEmojiUpdate,
withEmojiPicker = false,
...textProps
}: DocIconProps) => {
if (!emoji) {
return <>{defaultIcon}</>;
const { updateDocEmoji } = useDocTitleUpdate();
const iconRef = useRef<HTMLDivElement>(null);
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
const [pickerPosition, setPickerPosition] = useState<{
top: number;
left: number;
}>({ top: 0, left: 0 });
if (!withEmojiPicker && !emoji) {
return defaultIcon;
}
const toggleEmojiPicker = (e: MouseEvent) => {
if (withEmojiPicker) {
e.stopPropagation();
e.preventDefault();
if (!openEmojiPicker && iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
setPickerPosition({
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX,
});
}
setOpenEmojiPicker(!openEmojiPicker);
}
};
const handleEmojiSelect = ({ native }: { native: string }) => {
setOpenEmojiPicker(false);
// Update document emoji if docId is provided
if (docId && title !== undefined) {
updateDocEmoji(docId, title ?? '', native);
}
// Call the optional callback
onEmojiUpdate?.(native);
};
const handleClickOutside = () => {
setOpenEmojiPicker(false);
};
return (
<Text
{...textProps}
$size={$size}
$variation={$variation}
$weight={$weight}
aria-hidden="true"
data-testid="doc-emoji-icon"
>
{emoji}
</Text>
<>
<BoxButton
className="--docs--doc-icon"
ref={iconRef}
onClick={toggleEmojiPicker}
color="tertiary-text"
>
{!emoji ? (
defaultIcon
) : (
<Icon
{...textProps}
iconName={emoji}
$size={$size}
$variation={$variation}
$weight={$weight}
aria-hidden="true"
data-testid="doc-emoji-icon"
>
{emoji}
</Icon>
)}
</BoxButton>
{openEmojiPicker &&
createPortal(
<div
style={{
position: 'absolute',
top: pickerPosition.top,
left: pickerPosition.left,
zIndex: 1000,
}}
>
<EmojiPicker
emojiData={emojidata}
onEmojiSelect={handleEmojiSelect}
onClickOutside={handleClickOutside}
withOverlay={true}
/>
</div>,
document.body,
)}
</>
);
};

View File

@@ -1,6 +1,7 @@
import { Button } from '@openfun/cunningham-react';
import Head from 'next/head';
import Image from 'next/image';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -8,6 +9,7 @@ import img403 from '@/assets/icons/icon-403.png';
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
import { ButtonAccessRequest } from '@/docs/doc-share';
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
import { useSkeletonStore } from '@/features/skeletons';
const StyledButton = styled(Button)`
width: fit-content;
@@ -19,6 +21,13 @@ interface DocProps {
export const DocPage403 = ({ id }: DocProps) => {
const { t } = useTranslation();
const { setIsSkeletonVisible } = useSkeletonStore();
useEffect(() => {
// Ensure the skeleton overlay is hidden on 403 page
setIsSkeletonVisible(false);
}, [setIsSkeletonVisible]);
const {
data: requests,
isLoading: isLoadingRequest,

View File

@@ -4,20 +4,13 @@ import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
getEmojiAndTitle,
useDocUtils,
useTrans,
} from '@/docs/doc-management';
import { Doc, useDocUtils, useTrans } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import ChildDocument from '../assets/child-document.svg';
import PinnedDocumentIcon from '../assets/pinned-document.svg';
import SimpleFileIcon from '../assets/simple-document.svg';
import { DocIcon } from './DocIcon';
const ItemTextCss = css`
overflow: hidden;
text-overflow: ellipsis;
@@ -45,10 +38,6 @@ export const SimpleDocItem = ({
const { untitledDocument } = useTrans();
const { isChild } = useDocUtils(doc);
const { emoji, titleWithoutEmoji: displayTitle } = getEmojiAndTitle(
doc.title || untitledDocument,
);
return (
<Box
$direction="row"
@@ -76,25 +65,19 @@ export const SimpleDocItem = ({
data-testid="doc-pinned-icon"
color={colorsTokens['primary-500']}
/>
) : isChild ? (
<ChildDocument
aria-hidden="true"
data-testid="doc-child-icon"
color={colorsTokens['primary-500']}
/>
) : (
<DocIcon
emoji={emoji}
defaultIcon={
isChild ? (
<ChildDocument
aria-hidden="true"
data-testid="doc-child-icon"
color={colorsTokens['primary-500']}
/>
) : (
<SimpleFileIcon
aria-hidden="true"
data-testid="doc-simple-icon"
color={colorsTokens['primary-500']}
/>
)
}
$size="25px"
<SimpleFileIcon
width="32px"
height="32px"
aria-hidden="true"
data-testid="doc-simple-icon"
color={colorsTokens['primary-500']}
/>
)}
</Box>
@@ -106,7 +89,7 @@ export const SimpleDocItem = ({
$css={ItemTextCss}
data-testid="doc-title"
>
{displayTitle}
{doc.title || untitledDocument}
</Text>
{(!isDesktop || showAccesses) && (
<Box

View File

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

View File

@@ -0,0 +1,274 @@
import { renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
import { Doc } from '../../types';
import { useDocTitleUpdate } from '../useDocTitleUpdate';
// Mock useBroadcastStore
vi.mock('@/stores', () => ({
useBroadcastStore: () => ({
broadcast: vi.fn(),
}),
}));
describe('useDocTitleUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.restore();
});
it('should return the correct functions and state', () => {
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
expect(result.current.updateDocTitle).toBeDefined();
expect(result.current.updateDocEmoji).toBeDefined();
expect(typeof result.current.updateDocTitle).toBe('function');
expect(typeof result.current.updateDocEmoji).toBe('function');
});
describe('updateDocTitle', () => {
it('should call updateDoc with sanitized title', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
const sanitizedTitle = result.current.updateDocTitle(
{ id: 'test-doc-id', title: '' } as Doc,
' My Document \n\r',
);
expect(sanitizedTitle).toBe('My Document');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][0]).toBe(
'http://test.jest/api/v1.0/documents/test-doc-id/',
);
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: 'My Document' }),
headers: { 'Content-Type': 'application/json' },
});
});
it('should handle empty title and not call updateDoc', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
const sanitizedTitle = result.current.updateDocTitle(
{ id: 'test-doc-id', title: '' } as Doc,
'',
);
expect(sanitizedTitle).toBe('');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
});
it('should remove newlines and carriage returns', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
const sanitizedTitle = result.current.updateDocTitle(
{ id: 'test-doc-id', title: '' } as Doc,
'Title\nwith\r\nnewlines',
);
expect(sanitizedTitle).toBe('Titlewithnewlines');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
});
});
describe('updateDocEmoji', () => {
it('should call updateDoc with emoji and title without existing emoji', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
result.current.updateDocEmoji('test-doc-id', 'My Document', '🚀');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][0]).toBe(
'http://test.jest/api/v1.0/documents/test-doc-id/',
);
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: '🚀 My Document' }),
headers: { 'Content-Type': 'application/json' },
});
});
it('should replace existing emoji with new one', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
result.current.updateDocEmoji('test-doc-id', '📝 My Document', '🚀');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: '🚀 My Document' }),
headers: { 'Content-Type': 'application/json' },
});
});
it('should handle title with only emoji', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
result.current.updateDocEmoji('test-doc-id', '📝', '🚀');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: '🚀 ' }),
headers: { 'Content-Type': 'application/json' },
});
});
it('should handle empty title', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
result.current.updateDocEmoji('test-doc-id', '', '🚀');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: '🚀 ' }),
headers: { 'Content-Type': 'application/json' },
});
});
});
describe('onSuccess callback', () => {
it('should call onSuccess when provided', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'Updated Document',
}),
});
const onSuccess = vi.fn();
const { result } = renderHook(() => useDocTitleUpdate({ onSuccess }), {
wrapper: AppWrapper,
});
result.current.updateDocTitle(
{ id: 'test-doc-id', title: 'Old Document' } as Doc,
'Updated Document',
);
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(onSuccess).toHaveBeenCalledWith({
id: 'test-doc-id',
title: 'Updated Document',
});
});
});
describe('onError callback', () => {
it('should call onError when provided', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
throws: new Error('Update failed'),
});
const onError = vi.fn();
const { result } = renderHook(() => useDocTitleUpdate({ onError }), {
wrapper: AppWrapper,
});
try {
result.current.updateDocTitle(
{ id: 'test-doc-id', title: 'Old Document' } as Doc,
'Updated Document',
);
} catch {
expect(fetchMock.calls().length).toBe(1);
expect(onError).toHaveBeenCalledWith(new Error('Update failed'));
}
});
});
});

View File

@@ -1,6 +1,7 @@
export * from './useCollaboration';
export * from './useCopyDocLink';
export * from './useCreateChildDocTree';
export * from './useDocTitleUpdate';
export * from './useDocUtils';
export * from './useIsCollaborativeEditable';
export * from './useTrans';

View File

@@ -0,0 +1,76 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { useCallback } from 'react';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
getEmojiAndTitle,
useUpdateDoc,
} from '@/docs/doc-management';
import { useBroadcastStore } from '@/stores';
interface UseDocUpdateOptions {
onSuccess?: (updatedDoc: Doc) => void;
onError?: (error: Error) => void;
}
export const useDocTitleUpdate = (options?: UseDocUpdateOptions) => {
const { broadcast } = useBroadcastStore();
const treeContext = useTreeContext<Doc>();
const { mutate: updateDoc, ...mutationResult } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess: (updatedDoc) => {
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
if (treeContext) {
if (treeContext.root?.id === updatedDoc.id) {
treeContext?.setRoot(updatedDoc);
} else {
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
}
}
options?.onSuccess?.(updatedDoc);
},
onError: (error) => {
options?.onError?.(error);
},
});
const updateDocTitle = useCallback(
(doc: Doc, title: string) => {
const sanitizedTitle = title.trim().replace(/(\r\n|\n|\r)/gm, '');
// When blank we set to untitled
if (!sanitizedTitle) {
updateDoc({ id: doc.id, title: '' });
return '';
}
// If mutation we update
if (sanitizedTitle !== doc.title) {
updateDoc({ id: doc.id, title: sanitizedTitle });
}
return sanitizedTitle;
},
[updateDoc],
);
const updateDocEmoji = useCallback(
(docId: string, title: string, emoji: string) => {
const { titleWithoutEmoji } = getEmojiAndTitle(title);
updateDoc({ id: docId, title: `${emoji} ${titleWithoutEmoji}` });
},
[updateDoc],
);
return {
...mutationResult,
updateDocTitle,
updateDocEmoji,
};
};

View File

@@ -1,3 +1,4 @@
import { CloseEvent } from '@hocuspocus/common';
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -13,6 +14,8 @@ export interface UseCollaborationStore {
destroyProvider: () => void;
provider: HocuspocusProvider | undefined;
isConnected: boolean;
isReady: boolean;
isSynced: boolean;
hasLostConnection: boolean;
resetLostConnection: () => void;
}
@@ -20,9 +23,13 @@ export interface UseCollaborationStore {
const defaultValues = {
provider: undefined,
isConnected: false,
isReady: false,
isSynced: false,
hasLostConnection: false,
};
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
...defaultValues,
createProvider: (wsUrl, storeId, initialDoc) => {
@@ -38,11 +45,21 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
url: wsUrl,
name: storeId,
document: doc,
onDisconnect(data) {
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
if ((data.event as ExtendedCloseEvent).wasClean) {
void provider.connect();
}
},
onAuthenticationFailed() {
set({ isReady: true });
},
onStatus: ({ status }) => {
set((state) => {
const nextConnected = status === WebSocketStatus.Connected;
return {
isConnected: nextConnected,
isReady: state.isReady || status === WebSocketStatus.Disconnected,
hasLostConnection:
state.isConnected && !nextConnected
? true
@@ -50,6 +67,21 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
};
});
},
onSynced: ({ state }) => {
set({ isSynced: state, isReady: true });
},
onClose(data) {
/**
* Handle the "Reset Connection" event from the server
* This is triggered when the server wants to reset the connection
* for clients in the room.
* A disconnect is made automatically but it takes time to be triggered,
* so we force the disconnection here.
*/
if (data.event.code === 1000) {
provider.disconnect();
}
},
});
set({

View File

@@ -1,9 +1,10 @@
export * from './useDeleteDocAccess';
export * from './useDocAccesses';
export * from './useUpdateDocAccess';
export * from './useCreateDocAccess';
export * from './useUsers';
export * from './useCreateDocInvitation';
export * from './useDeleteDocAccess';
export * from './useDeleteDocInvitation';
export * from './useDocAccesses';
export * from './useDocAccessRequest';
export * from './useDocInvitations';
export * from './useUpdateDocAccess';
export * from './useUpdateDocInvitation';
export * from './useUsers';

View File

@@ -45,7 +45,7 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
return useMutation<void, APIError, DeleteDocAccessProps>({
mutationFn: deleteDocAccess,
...options,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
@@ -63,7 +63,7 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
queryKey: [KEY_LIST_USER],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});

View File

@@ -53,17 +53,17 @@ export const useDeleteDocInvitation = (
>({
mutationFn: deleteDocInvitation,
...options,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, onMutateResult, context);
}
},
onError: (error, variables, context) => {
onError: (error, variables, onMutateResult, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
void options.onError(error, variables, onMutateResult, context);
}
},
});

View File

@@ -59,12 +59,12 @@ export function useCreateDocAccessRequest(
return useMutation<void, APIError, CreateDocAccessRequestParams>({
mutationFn: createDocAccessRequest,
...options,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
});
void options?.onSuccess?.(data, variables, context);
void options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}
@@ -169,7 +169,7 @@ export const useAcceptDocAccessRequest = (
return useMutation<void, APIError, acceptDocAccessRequestsParams>({
mutationFn: acceptDocAccessRequests,
...options,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
@@ -179,7 +179,7 @@ export const useAcceptDocAccessRequest = (
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});
@@ -223,13 +223,13 @@ export const useDeleteDocAccessRequest = (
return useMutation<void, APIError, DeleteDocAccessRequestParams>({
mutationFn: deleteDocAccessRequest,
...options,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});

View File

@@ -6,7 +6,6 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Access, KEY_DOC, KEY_LIST_DOC, Role } from '@/docs/doc-management';
import { useBroadcastStore } from '@/stores';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
@@ -45,12 +44,11 @@ type UseUpdateDocAccessOptions = UseMutationOptions<
export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Access, APIError, UpdateDocAccessProps>({
mutationFn: updateDocAccess,
...options,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
@@ -58,14 +56,12 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
queryKey: [KEY_DOC],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variables.docId}`);
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});

View File

@@ -62,17 +62,17 @@ export const useUpdateDocInvitation = (
>({
mutationFn: updateDocInvitation,
...options,
onSuccess: (data, variables, context) => {
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, onMutateResult, context);
}
},
onError: (error, variables, context) => {
onError: (error, variables, onMutateResult, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
void options.onError(error, variables, onMutateResult, context);
}
},
});

View File

@@ -3,11 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { useBroadcastStore } from '@/stores';
import { Doc } from '../types';
import { KEY_DOC } from './useDoc';
import { Doc } from '@/docs/doc-management';
export type UpdateDocLinkParams = Pick<Doc, 'id' | 'link_reach'> &
Partial<Pick<Doc, 'link_role'>>;
@@ -43,22 +39,18 @@ export function useUpdateDocLink({
listInvalideQueries,
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
const { toast } = useToastProvider();
const { t } = useTranslation();
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data, variable) => {
onSuccess: (data) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.id}`);
toast(
t('The document visibility has been updated.'),
VariantType.SUCCESS,

View File

@@ -4,12 +4,9 @@ import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
useUpdateDocLink,
} from '@/docs/doc-management';
import { Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
import { useUpdateDocLink } from '../api/useUpdateDocLink';
import Desync from './../assets/desynchro.svg';
import Undo from './../assets/undo.svg';

View File

@@ -112,7 +112,7 @@ export const QuickSearchGroupMember = ({
elements: members,
endActions: undefined,
};
}, [membersQuery, t]);
}, [membersQuery.data, t]);
return (
<Box aria-label={t('List members card')} $padding={{ bottom: '3xs' }}>

View File

@@ -1,5 +1,6 @@
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { useMemo, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
@@ -15,7 +16,14 @@ import { User } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
import { isValidEmail } from '@/utils';
import { KEY_LIST_USER, useDocAccesses, useUsers } from '../api';
import {
KEY_LIST_DOC_ACCESSES,
KEY_LIST_DOC_ACCESS_REQUESTS,
KEY_LIST_DOC_INVITATIONS,
KEY_LIST_USER,
useDocAccesses,
useUsers,
} from '../api';
import { DocInheritedShareContent } from './DocInheritedShareContent';
import {
@@ -48,6 +56,7 @@ type Props = {
export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const { t } = useTranslation();
const selectedUsersRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { isDesktop } = useResponsiveStore();
@@ -128,6 +137,19 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const showInheritedShareContent =
inheritedAccesses.length > 0 && showMemberSection && !isRootDoc;
// Invalidate relevant queries to ensure fresh data on modal open
useEffect(() => {
[
KEY_LIST_DOC_INVITATIONS,
KEY_LIST_DOC_ACCESS_REQUESTS,
KEY_LIST_DOC_ACCESSES,
].forEach((key) => {
void queryClient.invalidateQueries({
queryKey: [key],
});
});
}, [queryClient]);
return (
<>
<Modal

View File

@@ -19,10 +19,10 @@ import {
getDocLinkReach,
getDocLinkRole,
useDocUtils,
useUpdateDocLink,
} from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useUpdateDocLink } from '../api/useUpdateDocLink';
import { useTranslatedShareSettings } from '../hooks/';
import { DocDesynchronized } from './DocDesynchronized';

View File

@@ -12,10 +12,10 @@ import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
DocIcon,
getEmojiAndTitle,
useTrans,
} from '@/features/docs/doc-management';
import { DocIcon } from '@/features/docs/doc-management/components/DocIcon';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
@@ -163,13 +163,17 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
aria-label={`${t('Open document {{title}}', { title: docTitle })}`}
$css={css`
text-align: left;
min-width: 0;
`}
>
<Box $width="16px" $height="16px">
<Box>
<DocIcon
emoji={emoji}
withEmojiPicker={doc.abilities.partial_update}
defaultIcon={<SubPageIcon color={colorsTokens['primary-400']} />}
$size="sm"
docId={doc.id}
title={doc.title}
/>
</Box>
@@ -180,8 +184,10 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
display: flex;
flex-direction: row;
width: 100%;
min-width: 0;
gap: 0.5rem;
align-items: center;
overflow: hidden;
`}
>
<Text $css={ItemTextCss} $size="sm" $variation="1000">

View File

@@ -184,7 +184,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
/* Remove outline from TreeViewItem wrapper elements */
.c__tree-view--row {
outline: none !important;
&:focus-visible {
outline: none !important;
}
@@ -241,7 +240,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}
}
&:hover,
&:focus-within {
&:focus-visible {
.doc-tree-root-item-actions {
opacity: 1;
}

View File

@@ -13,8 +13,10 @@ import {
Doc,
ModalRemoveDoc,
Role,
getEmojiAndTitle,
useCopyDocLink,
useCreateChildDoc,
useDocTitleUpdate,
useDuplicateDoc,
} from '@/docs/doc-management';
@@ -44,6 +46,7 @@ export const DocTreeItemActions = ({
const copyLink = useCopyDocLink(doc.id);
const { mutate: detachDoc } = useDetachDoc();
const treeContext = useTreeContext<Doc | null>();
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: (duplicatedDoc) => {
// Reset the tree context root will reset the full tree view.
@@ -52,6 +55,13 @@ export const DocTreeItemActions = ({
},
});
// Emoji Management
const { emoji } = getEmojiAndTitle(doc.title ?? '');
const { updateDocEmoji } = useDocTitleUpdate();
const removeEmoji = () => {
updateDocEmoji(doc.id, doc.title ?? '', '');
};
const handleDetachDoc = () => {
if (!treeContext?.root) {
return;
@@ -82,6 +92,15 @@ export const DocTreeItemActions = ({
},
...(!isRoot
? [
...(emoji && doc.abilities.partial_update
? [
{
label: t('Remove emoji'),
icon: <Icon iconName="emoji_emotions" $size="24px" />,
callback: removeEmoji,
},
]
: []),
{
label: t('Move to my docs'),
isDisabled: doc.user_role !== Role.OWNER,
@@ -152,7 +171,6 @@ export const DocTreeItemActions = ({
options={options}
isOpen={isOpen}
onOpenChange={onOpenChange}
aria-label={t('Open document actions menu')}
>
<Icon
onClick={(e) => {
@@ -164,7 +182,7 @@ export const DocTreeItemActions = ({
variant="filled"
$theme="primary"
$variation="600"
aria-hidden="true"
aria-label={t('More options')}
/>
</DropdownMenu>
{doc.abilities.children_create && (
@@ -178,6 +196,7 @@ export const DocTreeItemActions = ({
});
}}
color="primary"
aria-label={t('Add a sub page')}
data-testid="doc-tree-item-actions-add-child"
>
<Icon

View File

@@ -1,4 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
import { css } from 'styled-components';
@@ -36,7 +37,19 @@ export const DocsGrid = ({
hasNextPage,
} = useDocsQuery(target);
const docs = data?.pages.flatMap((page) => page.results) ?? [];
const docs = useMemo(() => {
const allDocs = data?.pages.flatMap((page) => page.results) ?? [];
// Deduplicate documents by ID to prevent the same doc appearing multiple times
// This can happen when a multiple users are impacting the docs list (creation, update, ...)
const seenIds = new Set<string>();
return allDocs.filter((doc) => {
if (seenIds.has(doc.id)) {
return false;
}
seenIds.add(doc.id);
return true;
});
}, [data?.pages]);
const loading = isFetching || isLoading;
const hasDocs = data?.pages.some((page) => page.results.length > 0);

View File

@@ -31,7 +31,10 @@ describe('DocsGridItemDate', () => {
});
[
{ updated_at: DateTime.now().toISO(), rendered: '0 seconds ago' },
{
updated_at: DateTime.now().minus({ minutes: 1 }).toISO(),
rendered: '1 minute ago',
},
{
updated_at: DateTime.now().minus({ days: 1 }).toISO(),
rendered: '1 day ago',
@@ -64,8 +67,8 @@ describe('DocsGridItemDate', () => {
});
});
it(`should render rendered the updated_at field in the correct language`, () => {
i18next.changeLanguage('fr');
it(`should render rendered the updated_at field in the correct language`, async () => {
await i18next.changeLanguage('fr');
render(
<DocsGridItemDate
@@ -83,7 +86,7 @@ describe('DocsGridItemDate', () => {
expect(screen.getByRole('link')).toBeInTheDocument();
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
i18next.changeLanguage('en');
await i18next.changeLanguage('en');
});
[
@@ -100,10 +103,10 @@ describe('DocsGridItemDate', () => {
updated_at: DateTime.now().toISO(),
},
{
deleted_at: DateTime.now().toISO(),
rendered: '0 seconds ago',
deleted_at: DateTime.now().minus({ minutes: 1 }).toISO(),
rendered: '1 minute ago',
trashbin_cutoff_days: 0,
updated_at: DateTime.now().toISO(),
updated_at: DateTime.now().minus({ minutes: 1 }).toISO(),
},
].forEach(({ deleted_at, rendered, trashbin_cutoff_days, updated_at }) => {
it(`should render "${rendered}" when we are in the trashbin`, async () => {

View File

@@ -68,7 +68,7 @@ export const Header = () => {
className="c__image-system-filter"
data-testid="header-icon-docs"
src={logo?.src || '/assets/icon-docs.svg'}
alt={logo?.alt || t('Docs')}
alt=""
width={0}
height={0}
style={{

View File

@@ -3,6 +3,5 @@ export interface HeaderType {
src?: string;
width?: string;
height?: string;
alt?: string;
};
}

View File

@@ -39,12 +39,10 @@ export const LeftPanel = () => {
{isDesktop && (
<Box
data-testid="left-panel-desktop"
$css={`
$css={css`
height: calc(100vh - ${HEADER_HEIGHT}px);
width: 300px;
min-width: 300px;
width: 100%;
overflow: hidden;
border-right: 1px solid ${colorsTokens['greyscale-200']};
background-color: ${colorsTokens['greyscale-000']};
`}
className="--docs--left-panel-desktop"

View File

@@ -1,9 +1,11 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '@/components';
import { useCreateDoc } from '@/features/docs/doc-management';
import { useSkeletonStore } from '@/features/skeletons';
import { useLeftPanelStore } from '../stores';
@@ -11,19 +13,47 @@ export const LeftPanelHeaderButton = () => {
const router = useRouter();
const { t } = useTranslation();
const { togglePanel } = useLeftPanelStore();
const { setIsSkeletonVisible } = useSkeletonStore();
const [isNavigating, setIsNavigating] = useState(false);
const { mutate: createDoc, isPending: isDocCreating } = useCreateDoc({
onSuccess: (doc) => {
void router.push(`/docs/${doc.id}`);
togglePanel();
setIsNavigating(true);
// Wait for navigation to complete
router
.push(`/docs/${doc.id}`)
.then(() => {
// The skeleton will be disabled by the [id] page once the data is loaded
setIsNavigating(false);
togglePanel();
})
.catch(() => {
// In case of navigation error, disable the skeleton
setIsSkeletonVisible(false);
setIsNavigating(false);
});
},
onError: () => {
// If there's an error, disable the skeleton
setIsSkeletonVisible(false);
setIsNavigating(false);
},
});
const handleClick = () => {
setIsSkeletonVisible(true);
createDoc();
};
const isLoading = isDocCreating || isNavigating;
return (
<Button
data-testid="new-doc-button"
color="primary"
onClick={() => createDoc()}
onClick={handleClick}
icon={<Icon $variation="000" iconName="add" aria-hidden="true" />}
disabled={isDocCreating}
disabled={isLoading}
>
{t('New doc')}
</Button>

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