Compare commits

..

1 Commits

Author SHA1 Message Date
Sylvain Boissel d786e9703e 🐛(backend) do not filter deleted docs for the owner in the tree view
Fixes the bug described in #1254 (Delete document request conflict)

In the tree request, deleted documents for
which the user is the owner are not filtered anymore.
2026-03-24 17:02:43 +01:00
61 changed files with 2453 additions and 2819 deletions
+1 -7
View File
@@ -6,19 +6,13 @@ and this project adheres to
## [Unreleased]
### Added
- 🚸(frontend) hint min char search users #2064
### Changed
- 💄(frontend) improve comments highlights #1961
- ♿️(frontend) improve BoxButton a11y and native button semantics #2103
- ♿️(frontend) improve language picker accessibility #2069
### Fixed
- 🐛(y-provider) destroy Y.Doc instances after each convert request #2129
- 🐛(backend) do not filter deleted documents for the owner in the tree #2121
## [v4.8.3] - 2026-03-23
-3
View File
@@ -60,13 +60,10 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@react-pdf/renderer",
"fetch-mock",
"node",
"node-fetch",
"react-resizable-panels",
"stylelint",
"stylelint-config-standard",
"workbox-webpack-plugin"
]
}
+14 -1
View File
@@ -1209,7 +1209,20 @@ class DocumentViewSet(
path__startswith=ancestor.path, depth=ancestor.depth + 1
)
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
# Include deleted documents owned by the user, if any.
if request.user.is_authenticated:
owned_document_ids = models.DocumentAccess.objects.filter(
user=user,
role=models.RoleChoices.OWNER,
document__deleted_at__isnull=False,
).values("document_id")
children = self.queryset.filter(
children_clause,
db.Q(deleted_at__isnull=True) | db.Q(id__in=owned_document_ids),
)
else:
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
queryset = (
ancestors.select_related("creator").filter(
@@ -1258,3 +1258,58 @@ def test_api_documents_tree_list_deleted_document_owner(django_assert_num_querie
assert content["children"][0][
"deleted_at"
] == child.ancestors_deleted_at.isoformat().replace("+00:00", "Z")
def test_api_documents_tree_deleted_child_visible_to_owner():
"""
A deleted child document should appear in the tree for users who are its direct owner,
even when they are not the owner of the parent.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(link_reach="public")
owned_child = factories.DocumentFactory(parent=parent, users=[(user, "owner")])
other_child = factories.DocumentFactory(parent=parent)
owned_child.soft_delete()
owned_child.refresh_from_db()
response = client.get(f"/api/v1.0/documents/{parent.id!s}/tree/")
assert response.status_code == 200
content = response.json()
child_ids = [c["id"] for c in content["children"]]
assert str(owned_child.id) in child_ids
assert str(other_child.id) in child_ids
owned_child_data = next(
c for c in content["children"] if c["id"] == str(owned_child.id)
)
assert owned_child_data["deleted_at"] == owned_child.deleted_at.isoformat().replace(
"+00:00", "Z"
)
def test_api_documents_tree_deleted_child_hidden_from_non_owner():
"""
A deleted child document should not appear in the tree for users who are not its owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(link_reach="public")
deleted_child = factories.DocumentFactory(parent=parent)
visible_child = factories.DocumentFactory(parent=parent)
deleted_child.soft_delete()
response = client.get(f"/api/v1.0/documents/{parent.id!s}/tree/")
assert response.status_code == 200
content = response.json()
child_ids = [c["id"] for c in content["children"]]
assert str(deleted_child.id) not in child_ids
assert str(visible_child.id) in child_ids
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
mockedDocument,
overrideConfig,
verifyDocName,
@@ -46,9 +47,9 @@ test.describe('Doc AI feature', () => {
await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText();
await expect(
page.locator('button[data-test="convertMarkdown"]'),
).toHaveCount(1);
expect(
await page.locator('button[data-test="convertMarkdown"]').count(),
).toBe(1);
await expect(
page.getByRole('button', { name: config.selector, exact: true }),
).toBeHidden();
@@ -178,32 +179,18 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Rephrase' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Summarize' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
await expect(getMenuItem(page, 'Rephrase')).toBeVisible();
await expect(getMenuItem(page, 'Summarize')).toBeVisible();
await expect(getMenuItem(page, 'Correct')).toBeVisible();
await expect(getMenuItem(page, 'Language')).toBeVisible();
await page.getByRole('menuitem', { name: 'Language' }).hover();
await expect(
page.getByRole('menuitem', { name: 'English', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await getMenuItem(page, 'Language').hover();
await expect(getMenuItem(page, 'English', { exact: true })).toBeVisible();
await expect(getMenuItem(page, 'French', { exact: true })).toBeVisible();
await expect(getMenuItem(page, 'German', { exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: 'German', exact: true }).click();
await getMenuItem(page, 'German', { exact: true }).click();
await expect(editor.getByText('Hallo Welt')).toBeVisible();
});
@@ -269,23 +256,15 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
if (ai_transform) {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
await expect(getMenuItem(page, 'Use as prompt')).toBeHidden();
}
if (ai_translate) {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await expect(getMenuItem(page, 'Language')).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
await expect(getMenuItem(page, 'Language')).toBeHidden();
}
});
});
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
closeHeaderMenu,
createDoc,
getMenuItem,
getOtherBrowserName,
verifyDocName,
} from './utils-common';
@@ -151,7 +152,7 @@ test.describe('Doc Comments', () => {
// Edit Comment
await thread.getByText('This is a comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
await getMenuItem(thread, 'Edit comment').click();
const commentEditor = thread.getByText('This is a comment').first();
await commentEditor.fill('This is an edited comment');
const saveBtn = thread.locator('button[data-test="save"]').first();
@@ -176,7 +177,7 @@ test.describe('Doc Comments', () => {
// Delete second comment
await thread.getByText('This is a second comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await getMenuItem(thread, 'Delete comment').click();
await expect(
thread.getByText('This is a second comment').first(),
).toBeHidden();
@@ -209,7 +210,7 @@ test.describe('Doc Comments', () => {
await thread.getByText('This is a new comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await getMenuItem(thread, 'Delete comment').click();
await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS(
@@ -5,6 +5,7 @@ import cs from 'convert-stream';
import {
createDoc,
getMenuItem,
goToGridDoc,
overrideConfig,
verifyDocName,
@@ -147,18 +148,20 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:4444/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
@@ -575,10 +578,12 @@ test.describe('Doc Editor', () => {
await page.reload();
responseCanEdit = await page.waitForResponse(
responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
@@ -604,7 +609,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
await getMenuItem(page, 'Reading').click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
getOtherBrowserName,
mockedListDocs,
toggleHeaderMenu,
@@ -206,7 +207,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await getMenuItem(page, 'Move into a doc').click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -294,7 +295,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await getMenuItem(page, 'Move into a doc').click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -341,9 +342,7 @@ test.describe('Doc grid move', () => {
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await otherPage
.getByRole('menuitemradio', { name: 'Administrator' })
.click();
await getMenuItem(otherPage, 'Administrator').click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(otherPage.getByText('Access Requests')).toBeHidden();
@@ -354,7 +353,7 @@ test.describe('Doc grid move', () => {
await page.reload();
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await getMenuItem(page, 'Move into a doc').click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { createDoc, getGridRow, verifyDocName } from './utils-common';
import {
createDoc,
getGridRow,
getMenuItem,
verifyDocName,
} from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
type SmallDoc = {
@@ -99,7 +104,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await getMenuItem(page, 'Share').click();
await expect(
page.getByRole('dialog').getByText('Share the document'),
@@ -115,7 +120,7 @@ test.describe('Document grid item options', () => {
// Pin
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await getMenuItem(page, 'Pin').click();
// Check is pinned
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
@@ -142,7 +147,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await getMenuItem(page, 'Delete').click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -78,7 +79,7 @@ test.describe('Doc Header', () => {
await page.getByTestId('doc-visibility').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await page.getByRole('button', { name: 'close' }).first().click();
@@ -152,10 +153,8 @@ test.describe('Doc Header', () => {
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
});
const addEmojiMenuItem = getMenuItem(page, 'Add emoji');
const removeEmojiMenuItem = getMenuItem(page, 'Remove emoji');
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
@@ -209,7 +208,7 @@ test.describe('Doc Header', () => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await getMenuItem(page, 'Delete document').click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -237,7 +236,7 @@ test.describe('Doc Header', () => {
hasText: randomDoc,
});
await expect(row).toHaveCount(0);
expect(await row.count()).toBe(0);
});
test('it checks the options available if administrator', async ({ page }) => {
@@ -271,12 +270,10 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -296,7 +293,7 @@ test.describe('Doc Header', () => {
await invitationRole.click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
@@ -308,9 +305,7 @@ test.describe('Doc Header', () => {
await expect(roles).toBeVisible();
await roles.click();
await expect(
page.getByRole('menuitemradio', { name: 'Remove access' }),
).toBeEnabled();
await expect(getMenuItem(page, 'Remove access')).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -350,12 +345,10 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -422,12 +415,10 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -482,7 +473,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await getMenuItem(page, 'Copy as Markdown').click();
await expect(
page.getByText('Copied as Markdown to clipboard'),
).toBeVisible();
@@ -546,7 +537,7 @@ test.describe('Doc Header', () => {
.click();
// Pin
await page.getByRole('menuitem', { name: 'Pin' }).click();
await getMenuItem(page, 'Pin').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
@@ -567,11 +558,11 @@ test.describe('Doc Header', () => {
.click();
// Unpin
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await getMenuItem(page, 'Unpin').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await expect(getMenuItem(page, 'Pin')).toBeVisible();
await page.goto('/');
@@ -589,7 +580,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await getMenuItem(page, 'Duplicate').click();
await expect(
page.getByText('Document duplicated successfully!'),
).toBeVisible();
@@ -604,7 +595,7 @@ test.describe('Doc Header', () => {
await expect(row.getByText(duplicateTitle)).toBeVisible();
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await getMenuItem(page, 'Duplicate').click();
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
await page.getByText(duplicateDuplicateTitle).click();
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
@@ -637,7 +628,7 @@ test.describe('Doc Header', () => {
const currentUrl = page.url();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await getMenuItem(page, 'Duplicate').click();
await expect(page).not.toHaveURL(new RegExp(currentUrl));
@@ -676,10 +667,8 @@ test.describe('Documents Header mobile', () => {
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(getMenuItem(page, 'Copy link')).toBeVisible();
await getMenuItem(page, 'Share').click();
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
});
@@ -702,7 +691,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await getMenuItem(page, 'Share').click();
const shareModal = page.getByRole('dialog', {
name: 'Share the document',
@@ -177,5 +177,5 @@ const dragAndDropFiles = async (
return dt;
}, filesData);
await page.locator(selector).dispatchEvent('drop', { dataTransfer });
await page.dispatchEvent(selector, 'drop', { dataTransfer });
};
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { updateShareLink } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -53,19 +53,17 @@ test.describe('Inherited share accesses', () => {
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
await docVisibilityCard.getByText('Reading').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await getMenuItem(page, 'Editing').click();
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
// Verify inherited link
await docVisibilityCard.getByText('Connected').click();
await expect(
page.getByRole('menuitemradio', { name: 'Private' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Private')).toBeDisabled();
// Update child link
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect(
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
BROWSERS,
createDoc,
getMenuItem,
keyCloakSignIn,
randomName,
verifyDocName,
@@ -16,41 +17,6 @@ test.describe('Document create member', () => {
await page.goto('/');
});
test('it checks search hints', async ({ page, browserName }) => {
await createDoc(page, 'select-multi-users', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share the document');
await expect(shareModal.getByText('Document owner')).toBeVisible();
const inputSearch = page.getByTestId('quick-search-input');
await inputSearch.fill('u');
await expect(shareModal.getByText('Document owner')).toBeHidden();
await expect(
shareModal.getByText('Type at least 3 characters to display user names'),
).toBeVisible();
await inputSearch.fill('user');
await expect(
shareModal.getByText('Type at least 3 characters to display user names'),
).toBeHidden();
await expect(shareModal.getByText('Choose a user')).toBeVisible();
await inputSearch.fill('anything');
await expect(shareModal.getByText('Choose a user')).toBeHidden();
await expect(
shareModal.getByText(
'No results. Type a full email address to invite someone.',
),
).toBeVisible();
await inputSearch.fill('anything@test.com');
await expect(
shareModal.getByText(
'No results. Type a full email address to invite someone.',
),
).toBeHidden();
await expect(shareModal.getByText('Choose the email')).toBeVisible();
});
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const inputFill = 'user.test';
const responsePromise = page.waitForResponse(
@@ -110,21 +76,13 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByTestId('doc-role-dropdown').click();
await expect(
page.getByRole('menuitemradio', { name: 'Reader' }),
).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Editor' }),
).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Owner' }),
).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Administrator' }),
).toBeVisible();
await expect(getMenuItem(page, 'Reader')).toBeVisible();
await expect(getMenuItem(page, 'Editor')).toBeVisible();
await expect(getMenuItem(page, 'Owner')).toBeVisible();
await expect(getMenuItem(page, 'Administrator')).toBeVisible();
// Validate
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await page.getByTestId('doc-share-invite-button').click();
// Check invitation added
@@ -170,7 +128,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
await getMenuItem(page, 'Owner').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -188,7 +146,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
await getMenuItem(page, 'Owner').click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -225,7 +183,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -252,13 +210,13 @@ test.describe('Document create member', () => {
);
await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
await getMenuItem(page, 'Reader').click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(userInvitation).toBeHidden();
});
@@ -310,7 +268,7 @@ test.describe('Document create member', () => {
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => {
@@ -160,9 +160,7 @@ test.describe('Document list members', () => {
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soloOwner).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Administrator' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Administrator')).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
@@ -185,20 +183,18 @@ test.describe('Document list members', () => {
});
await currentUserRole.click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await list.click();
await expect(currentUserRole).toBeVisible();
await newUserRoles.click();
await expect(
page.getByRole('menuitemradio', { name: 'Owner' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Owner')).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
});
await currentUserRole.click();
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
await getMenuItem(page, 'Reader').click();
await list.click({
force: true, // Force click to close the dropdown
});
@@ -238,11 +234,11 @@ test.describe('Document list members', () => {
await expect(userReader).toBeVisible();
await userReaderRole.click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(userReader).toBeHidden();
await mySelfRole.click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -136,13 +136,9 @@ test.describe('Document search', () => {
await filters.click();
await filters.getByRole('button', { name: 'Current doc' }).click();
await expect(
page.getByRole('menuitemcheckbox', { name: 'All docs' }),
).toBeVisible();
await expect(
page.getByRole('menuitemcheckbox', { name: 'Current doc' }),
).toBeVisible();
await page.getByRole('menuitemcheckbox', { name: 'All docs' }).click();
await expect(getMenuItem(page, 'All docs')).toBeVisible();
await expect(getMenuItem(page, 'Current doc')).toBeVisible();
await getMenuItem(page, 'All docs').click();
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
});
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
updateDocTitle,
verifyDocName,
@@ -42,12 +43,15 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages
const allSubPageItems = docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItems).toHaveCount(2);
const allSubPageItems = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
// Check that elements are in the correct order
await expect(allSubPageItems.nth(0).getByText('first move')).toBeVisible();
await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible();
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
// Will move the first sub page to the second position
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
@@ -87,15 +91,17 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check that elements are in the correct order
const allSubPageItemsAfterReload =
docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItemsAfterReload).toHaveCount(2);
const allSubPageItemsAfterReload = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItemsAfterReload.length).toBe(2);
await expect(
allSubPageItemsAfterReload.nth(0).getByText('second move'),
allSubPageItemsAfterReload[0].getByText('second move'),
).toBeVisible();
await expect(
allSubPageItemsAfterReload.nth(1).getByText('first move'),
allSubPageItemsAfterReload[1].getByText('first move'),
).toBeVisible();
});
@@ -157,7 +163,7 @@ test.describe('Doc Tree', () => {
);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
@@ -187,9 +193,10 @@ test.describe('Doc Tree', () => {
const menu = child.getByText(`more_horiz`);
await menu.click();
await expect(
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
await expect(getMenuItem(page, 'Move to my docs')).toHaveAttribute(
'aria-disabled',
'true',
);
});
test('keyboard navigation with Enter key opens documents', async ({
@@ -333,9 +340,7 @@ test.describe('Doc Tree', () => {
await row.hover();
const menu = row.getByText(`more_horiz`);
await menu.click();
await expect(
page.getByRole('menuitem', { name: 'Remove emoji' }),
).toBeHidden();
await expect(getMenuItem(page, 'Remove emoji')).toBeHidden();
// Close the menu
await page.keyboard.press('Escape');
@@ -355,7 +360,7 @@ test.describe('Doc Tree', () => {
// Now remove the emoji using the new action
await row.hover();
await menu.click();
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await getMenuItem(page, 'Remove emoji').click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).toBeHidden();
@@ -385,7 +390,7 @@ test.describe('Doc Tree: Inheritance', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -20,7 +21,7 @@ test.describe('Doc Version', () => {
// Initially, there is no version
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await getMenuItem(page, 'Version history').click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByRole('dialog', { name: 'Version history' });
@@ -74,14 +75,14 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await getMenuItem(page, 'Version history').click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = panel.locator('.version-item');
await expect(items).toHaveCount(2);
await items.nth(1).click();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(2);
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -89,7 +90,7 @@ test.describe('Doc Version', () => {
modal.locator('div[data-content-type="callout"]').first(),
).toBeHidden();
await items.nth(0).click();
await items[0].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeVisible();
@@ -100,7 +101,7 @@ test.describe('Doc Version', () => {
modal.getByText('It will create a second version'),
).toBeHidden();
await items.nth(1).click();
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -124,9 +125,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Version history')).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -153,7 +152,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await getMenuItem(page, 'Version history').click();
const modal = page.getByRole('dialog', { name: 'Version history' });
const panel = modal.getByLabel('Version list');
@@ -4,6 +4,7 @@ import {
BROWSERS,
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
@@ -46,21 +47,17 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Read only' }),
).toBeHidden();
await expect(
page.getByRole('menuitemradio', { name: 'Can read and edit' }),
).toBeHidden();
await expect(getMenuItem(page, 'Read only')).toBeHidden();
await expect(getMenuItem(page, 'Can read and edit')).toBeHidden();
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
@@ -205,7 +202,7 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -213,7 +210,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
await getMenuItem(page, 'Reading').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -299,14 +296,14 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await getMenuItem(page, 'Editing').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -390,7 +387,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -438,7 +435,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -536,7 +533,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -544,7 +541,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await getMenuItem(page, 'Editing').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { overrideConfig } from './utils-common';
import { getMenuItem, overrideConfig } from './utils-common';
test.describe('Footer', () => {
test.use({ storageState: { cookies: [], origins: [] } });
@@ -47,7 +47,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByRole('menuitemradio', { name: 'Français' }).click();
await getMenuItem(page, 'Français').click();
await expect(
page.locator('footer').getByText('Mentions légales'),
@@ -131,7 +131,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByRole('menuitemradio', { name: 'Français' }).click();
await getMenuItem(page, 'Français').click();
await expect(
page
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import {
TestLanguage,
getMenuItem,
overrideConfig,
waitForLanguageSwitch,
} from './utils-common';
@@ -44,7 +45,7 @@ test.describe('Help feature', () => {
await page.getByRole('button', { name: 'Open help menu' }).click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
await getMenuItem(page, 'Onboarding').click();
const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible();
@@ -87,7 +88,7 @@ test.describe('Help feature', () => {
test('closes modal with Skip button', async ({ page }) => {
await page.getByRole('button', { name: 'Open help menu' }).click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
await getMenuItem(page, 'Onboarding').click();
const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible();
@@ -108,7 +109,7 @@ test.describe('Help feature', () => {
await page.getByRole('button', { name: "Ouvrir le menu d'aide" }).click();
await page.getByRole('menuitem', { name: 'Premiers pas' }).click();
await getMenuItem(page, 'Premiers pas').click();
const modal = page.getByLabel('Apprenez les principes fondamentaux');
@@ -75,7 +75,7 @@ test.describe('Language', () => {
await expect(page.locator('[role="menu"]')).toBeVisible();
const menuItems = page.locator('[role="menuitemradio"]');
const menuItems = page.locator('[role="menuitem"], [role="menuitemradio"]');
await expect(menuItems.first()).toBeVisible();
await menuItems.first().click();
@@ -3,6 +3,16 @@ import path from 'path';
import { Locator, Page, TestInfo, expect } from '@playwright/test';
/** Returns a locator for a menu item (handles both menuitem and menuitemradio roles) */
export const getMenuItem = (
context: Page | Locator,
name: string,
options?: { exact?: boolean },
): Locator =>
context
.getByRole('menuitem', { name, exact: options?.exact })
.or(context.getByRole('menuitemradio', { name, exact: options?.exact }));
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
@@ -382,12 +392,12 @@ export async function waitForLanguageSwitch(
await languagePicker.click();
await page.getByRole('menuitemradio', { name: lang.label }).click();
await getMenuItem(page, lang.label).click();
}
export const clickInEditorMenu = async (page: Page, textButton: string) => {
await page.getByRole('button', { name: 'Open the document options' }).click();
await page.getByRole('menuitem', { name: textButton }).click();
await getMenuItem(page, textButton).click();
};
export const clickInGridMenu = async (
@@ -398,7 +408,7 @@ export const clickInGridMenu = async (
await row
.getByRole('button', { name: /Open the menu of actions for the document/ })
.click();
await page.getByRole('menuitem', { name: textButton }).click();
await getMenuItem(page, textButton).click();
};
export const writeReport = async (
@@ -2,6 +2,7 @@ import { Page, chromium, expect } from '@playwright/test';
import {
BrowserName,
getMenuItem,
getOtherBrowserName,
keyCloakSignIn,
verifyDocName,
@@ -39,7 +40,7 @@ export const addNewMember = async (
// Choose a role
await page.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitemradio', { name: role }).click();
await getMenuItem(page, role).click();
await page.getByTestId('doc-share-invite-button').click();
return users[index].email;
@@ -51,7 +52,7 @@ export const updateShareLink = async (
linkRole?: LinkRole | null,
) => {
await page.getByTestId('doc-visibility').click();
await page.getByRole('menuitemradio', { name: linkReach }).click();
await getMenuItem(page, linkReach).click();
const visibilityUpdatedText = page
.getByText('The document visibility has been updated')
@@ -61,7 +62,7 @@ export const updateShareLink = async (
if (linkRole) {
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: linkRole }).click();
await getMenuItem(page, linkRole).click();
await expect(visibilityUpdatedText).toBeVisible();
}
};
@@ -76,7 +77,7 @@ export const updateRoleUser = async (
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitemradio', { name: role }).click();
await getMenuItem(page, role).click();
await list.click();
};
+21 -20
View File
@@ -23,7 +23,7 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@ai-sdk/openai": "3.0.45",
"@ai-sdk/openai": "3.0.19",
"@blocknote/code-block": "0.47.1",
"@blocknote/core": "0.47.1",
"@blocknote/mantine": "0.47.1",
@@ -38,20 +38,20 @@
"@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.38",
"@fontsource-variable/material-symbols-outlined": "5.2.35",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@gouvfr-lasuite/ui-kit": "0.19.6",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "8.3.17",
"@mantine/hooks": "8.3.17",
"@mantine/core": "8.3.14",
"@mantine/hooks": "8.3.14",
"@react-aria/live-announcer": "3.4.4",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.43.0",
"@sentry/nextjs": "10.38.0",
"@tanstack/react-query": "5.90.21",
"@tiptap/extensions": "*",
"ai": "6.0.128",
"ai": "6.0.49",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -59,28 +59,28 @@
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.8.18",
"i18next": "25.8.12",
"i18next-browser-languagedetector": "8.2.1",
"idb": "8.0.3",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "16.1.7",
"posthog-js": "1.360.2",
"posthog-js": "1.347.2",
"react": "*",
"react-aria-components": "1.16.0",
"react-aria-components": "1.15.1",
"react-dom": "*",
"react-dropzone": "15.0.0",
"react-i18next": "16.5.8",
"react-intersection-observer": "10.0.3",
"react-i18next": "16.5.4",
"react-intersection-observer": "10.0.2",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.3.11",
"styled-components": "6.3.9",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zod": "4.3.6",
"zustand": "5.0.12"
"zod": "3.25.28",
"zustand": "5.0.11"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
@@ -89,25 +89,26 @@
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.24",
"@types/lodash": "4.17.23",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "6.0.1",
"@vitejs/plugin-react": "5.1.4",
"cross-env": "10.1.0",
"dotenv": "17.3.1",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "29.0.0",
"jsdom": "28.1.0",
"node-fetch": "2.7.0",
"prettier": "3.8.1",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vitest": "4.1.0",
"webpack": "5.105.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"webpack": "5.105.2",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"
@@ -1,17 +1,17 @@
import { Ref, forwardRef } from 'react';
import { forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = Omit<BoxType, 'ref'> & {
export type BoxButtonType = BoxType & {
disabled?: boolean;
ref?: Ref<HTMLButtonElement>;
};
/**
/**
* Styleless button that extends the Box component.
* Good to wrap around SVGs or other elements that need to be clickable.
* Uses aria-disabled instead of native disabled to preserve keyboard focusability.
* @param props - @see BoxType props
* @param ref
* @see Box
@@ -22,8 +22,8 @@ export type BoxButtonType = Omit<BoxType, 'ref'> & {
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
({ $css, disabled, ...props }, ref) => {
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, ...props }, ref) => {
const theme = props.$theme || 'gray';
const variation = props.$variation || 'primary';
@@ -31,18 +31,16 @@ const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
<Box
ref={ref}
as="button"
type="button"
$background="none"
$margin="none"
$padding="none"
$hasTransition
aria-disabled={disabled || undefined}
$css={css`
cursor: ${disabled ? 'not-allowed' : 'pointer'};
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
border: none;
outline: none;
font-family: inherit;
color: ${disabled &&
color: ${props.disabled &&
`var(--c--contextuals--content--semantic--disabled--primary)`};
&:focus-visible {
transition: none;
@@ -55,11 +53,11 @@ const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
`}
{...props}
className={`--docs--box-button ${props.className || ''}`}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) {
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;
}
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
props.onClick?.(event);
}}
/>
);
@@ -26,7 +26,6 @@ import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
export type DropdownMenuOption = {
icon?: ReactNode;
label: string;
lang?: string;
testId?: string;
value?: string;
callback?: () => void | Promise<unknown>;
@@ -70,10 +69,7 @@ export const DropdownMenu = ({
const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null);
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const isSingleSelectable = options.some(
(option) => option.isSelected !== undefined,
);
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onOpenChange = useCallback(
(isOpen: boolean) => {
@@ -114,6 +110,10 @@ export const DropdownMenu = ({
[onOpenChange],
);
const hasSelectable =
selectedValues !== undefined ||
options.some((option) => option.isSelected !== undefined);
if (disabled) {
return children;
}
@@ -176,25 +176,20 @@ export const DropdownMenu = ({
}
const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;
const isSelected =
option.isSelected === true ||
(selectedValues?.includes(option.value ?? '') ?? false);
const itemRole =
selectedValues !== undefined
? 'menuitemcheckbox'
: isSingleSelectable
? 'menuitemradio'
: 'menuitem';
const optionKey = option.value ?? option.testId ?? `option-${index}`;
const ariaChecked = hasSelectable
? option.isSelected ||
selectedValues?.includes(option.value ?? '') ||
false
: undefined;
return (
<Fragment key={optionKey}>
<Fragment key={option.label}>
<BoxButton
ref={(el) => {
menuItemRefs.current[index] = el;
}}
role={itemRole}
aria-checked={itemRole === 'menuitem' ? undefined : isSelected}
role={hasSelectable ? 'menuitemradio' : 'menuitem'}
aria-checked={ariaChecked}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
@@ -205,6 +200,7 @@ export const DropdownMenu = ({
triggerOption(option);
}}
onKeyDown={keyboardAction(() => triggerOption(option))}
key={option.label}
$align="center"
$justify="space-between"
$background="var(--c--contextuals--background--surface--primary)"
@@ -275,16 +271,16 @@ export const DropdownMenu = ({
<Box
$theme="neutral"
$variation={isDisabled ? 'tertiary' : 'primary'}
aria-hidden="true"
>
{option.icon}
</Box>
)}
<Text $variation={isDisabled ? 'tertiary' : 'primary'}>
<span lang={option.lang}>{option.label}</span>
{option.label}
</Text>
</Box>
{isSelected && (
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
<Icon
iconName="check"
$size="20px"
@@ -58,7 +58,7 @@ describe('<DropdownMenu />', () => {
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
});
test('renders menuitemcheckbox role with aria-checked when selectedValues is provided', async () => {
test('renders menuitemradio role with aria-checked when selectedValues is provided', async () => {
const optionsWithValues: DropdownMenuOption[] = [
{ label: 'English', value: 'en', callback: vi.fn() },
{ label: 'Français', value: 'fr', callback: vi.fn() },
@@ -77,12 +77,12 @@ describe('<DropdownMenu />', () => {
{ wrapper: AppWrapper },
);
const checkboxes = screen.getAllByRole('menuitemcheckbox');
expect(checkboxes).toHaveLength(3);
const radios = screen.getAllByRole('menuitemradio');
expect(radios).toHaveLength(3);
expect(checkboxes[0]).toHaveAttribute('aria-checked', 'false');
expect(checkboxes[1]).toHaveAttribute('aria-checked', 'true');
expect(checkboxes[2]).toHaveAttribute('aria-checked', 'false');
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
expect(radios[1]).toHaveAttribute('aria-checked', 'true');
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
});
test('trigger button has aria-haspopup and aria-expanded', async () => {
@@ -6,7 +6,7 @@ type UseDropdownKeyboardNavProps = {
isOpen: boolean;
focusedIndex: number;
options: DropdownMenuOption[];
menuItemRefs: RefObject<(HTMLButtonElement | null)[]>;
menuItemRefs: RefObject<(HTMLDivElement | null)[]>;
setFocusedIndex: (index: number) => void;
onOpenChange: (isOpen: boolean) => void;
};
@@ -48,7 +48,7 @@ export const QuickSearchInput = ({
$direction="row"
$align="center"
className="quick-search-input"
$gap={spacingsTokens['xxs']}
$gap={spacingsTokens['2xs']}
$padding={{ horizontal: 'base', vertical: 'xxs' }}
>
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
@@ -62,7 +62,6 @@ export const QuickSearchInput = ({
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
maxLength={254}
minLength={6}
data-testid="quick-search-input"
/>
</Box>
@@ -18,15 +18,14 @@ export const QuickSearchStyle = createGlobalStyle`
[cmdk-input] {
border: none;
width: 100%;
font-size: 16px;
font-size: 17px;
background: white;
outline: none;
color: var(--c--contextuals--content--semantic--neutral--primary);
border-radius: var(--c--globals--spacings--0);
font-family: var(--c--globals--font--families--base);
&::placeholder {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
color: var(--c--globals--colors--gray-500);
}
}
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { toBase64 } from '@/utils/string';
import { isFirefox } from '@/utils/userAgent';
@@ -1,9 +1,4 @@
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/docs/doc-export/components/ModalExport', () => ({
ModalExport: vi.fn(),
}));
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
describe('useModuleExport', () => {
@@ -21,12 +16,12 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toBeUndefined();
});
}, 15000);
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toHaveProperty('ModalExport');
});
}, 15000);
});
@@ -1,4 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
@@ -38,11 +40,17 @@ describe('DocToolBox - Licence', () => {
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = await screen.findByLabelText('Export the document');
await userEvent.click(optionsButton);
// Wait for the export modal to be visible, then assert on its content text.
await screen.findByTestId('modal-export-title');
expect(
await screen.findByLabelText('Export the document'),
screen.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeInTheDocument();
}, 15000);
}, 10000);
test('The export button is not rendered when MIT version is activated', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
@@ -60,5 +68,5 @@ describe('DocToolBox - Licence', () => {
expect(
screen.queryByLabelText('Export the document'),
).not.toBeInTheDocument();
}, 15000);
});
});
@@ -1,8 +1,9 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -38,6 +39,7 @@ import {
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { useFocusStore, useResponsiveStore } from '@/stores';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -86,6 +88,7 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const treeContext = useTreeContext<Doc>();
const queryClient = useQueryClient();
const router = useRouter();
const { isChild, isTopRoot } = useDocUtils(doc);
@@ -111,6 +114,16 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
});
useEffect(() => {
if (selectHistoryModal.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [selectHistoryModal.isOpen, queryClient]);
// Emoji Management
const { emoji } = getEmojiAndTitle(doc.title ?? '');
const { updateDocEmoji } = useDocTitleUpdate();
@@ -4,7 +4,7 @@ import {
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { useEditorStore } from '../../doc-editor';
export const useCopyCurrentEditorToClipboard = () => {
const { editor } = useEditorStore();
@@ -21,8 +21,8 @@ export const useCopyCurrentEditorToClipboard = () => {
try {
const editorContentFormatted =
asFormat === 'html'
? editor.blocksToHTMLLossy()
: editor.blocksToMarkdownLossy();
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
const successMessage =
asFormat === 'markdown'
@@ -44,7 +44,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.resetQueries({
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
@@ -44,7 +44,7 @@ export const DocIcon = ({
const { t } = useTranslation();
const { addLastFocus, restoreFocus } = useFocusStore();
const iconRef = useRef<HTMLButtonElement>(null);
const iconRef = useRef<HTMLDivElement>(null);
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
const [pickerPosition, setPickerPosition] = useState<{
@@ -14,11 +14,6 @@ vi.mock('@/stores', () => ({
}),
}));
vi.mock('@gouvfr-lasuite/ui-kit', async () => ({
...(await vi.importActual('@gouvfr-lasuite/ui-kit')),
useTreeContext: () => null,
}));
describe('useDocTitleUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -124,8 +124,7 @@ export const DocShareAddMemberList = ({
$scope="surface"
$theme="tertiary"
$variation=""
$border="1px solid var(--c--contextuals--border--surface--primary)"
$margin={{ bottom: 'sm' }}
$border="1px solid var(--c--contextuals--border--semantic--contextual--primary)"
>
<Box
$direction="row"
@@ -289,7 +289,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
/>
)}
{showMemberSection && isRootDoc && (
<Box $padding={{ horizontal: 'base', top: 'base' }}>
<Box $padding={{ horizontal: 'base' }}>
<QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} />
@@ -301,7 +301,6 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
searchUsersRawData={searchUsersQuery.data}
onSelect={onSelect}
userQuery={userQuery}
minLength={API_USERS_SEARCH_QUERY_MIN_LENGTH}
/>
)}
</QuickSearch>
@@ -322,35 +321,14 @@ interface QuickSearchInviteInputSectionProps {
onSelect: (usr: User) => void;
searchUsersRawData: User[] | undefined;
userQuery: string;
minLength: number;
}
const QuickSearchInviteInputSection = ({
onSelect,
searchUsersRawData,
userQuery,
minLength,
}: QuickSearchInviteInputSectionProps) => {
const { t } = useTranslation();
const hint = useMemo(() => {
if (userQuery.length < minLength) {
return t('Type at least {{minLength}} characters to display user names', {
minLength,
});
}
if (isValidEmail(userQuery)) {
return t('Choose the email');
}
if (!searchUsersRawData?.length) {
return t('No results. Type a full email address to invite someone.');
}
return t('Choose a user');
}, [minLength, searchUsersRawData?.length, t, userQuery]);
useEffect(() => {
announce(hint, 'polite');
}, [hint]);
const searchUserData: QuickSearchData<User> = useMemo(() => {
const users = searchUsersRawData || [];
@@ -369,7 +347,7 @@ const QuickSearchInviteInputSection = ({
);
return {
groupName: hint,
groupName: t('Search user result'),
elements: users,
endActions:
isEmail && !hasEmailInUsers
@@ -381,12 +359,12 @@ const QuickSearchInviteInputSection = ({
]
: undefined,
};
}, [searchUsersRawData, userQuery, hint, onSelect]);
}, [onSelect, searchUsersRawData, t, userQuery]);
return (
<Box
aria-label={t('List search user result card')}
$padding={{ horizontal: 'base', bottom: '3xs', top: 'base' }}
$padding={{ horizontal: 'base', bottom: '3xs' }}
>
<QuickSearchGroup
group={searchUserData}
@@ -96,7 +96,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const ariaLabel = docTitle;
const isDisabled = !!doc.deleted_at;
const actionsRef = useRef<HTMLDivElement>(null);
const buttonOptionRef = useRef<HTMLButtonElement | null>(null);
const buttonOptionRef = useRef<HTMLDivElement | null>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement | null;
@@ -44,7 +44,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
const rootItemRef = useRef<HTMLDivElement>(null);
const rootActionsRef = useRef<HTMLDivElement>(null);
const rootButtonOptionRef = useRef<HTMLButtonElement | null>(null);
const rootButtonOptionRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation();
@@ -33,7 +33,7 @@ type DocTreeItemActionsProps = {
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
actionsRef?: React.RefObject<HTMLDivElement | null>;
buttonOptionRef?: React.RefObject<HTMLButtonElement | null>;
buttonOptionRef?: React.RefObject<HTMLDivElement | null>;
};
export const DocTreeItemActions = ({
@@ -48,7 +48,7 @@ export const DocTreeItemActions = ({
}: DocTreeItemActionsProps) => {
const internalActionsRef = useRef<HTMLDivElement | null>(null);
const targetActionsRef = actionsRef ?? internalActionsRef;
const internalButtonRef = useRef<HTMLButtonElement | null>(null);
const internalButtonRef = useRef<HTMLDivElement | null>(null);
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
const router = useRouter();
const { t } = useTranslation();
@@ -4,12 +4,9 @@ import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { BlockNoteReader } from '@/docs/doc-editor/components/BlockNoteEditor';
import { DocEditorContainer } from '@/docs/doc-editor/components/DocEditor';
import { BlockNoteReader, DocEditorContainer } from '@/docs/doc-editor/';
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
import { useDocVersion } from '../api/useDocVersion';
import { Versions } from '../types';
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
import { DocVersionHeader } from './DocVersionHeader';
@@ -1,4 +1,4 @@
import { Doc } from '../doc-management/types';
import { Doc } from '../doc-management';
export interface APIListVersions {
count: number;
@@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import i18next from 'i18next';
import { DateTime } from 'luxon';
@@ -73,9 +73,7 @@ describe('DocsGridItemDate', () => {
});
it(`should render rendered the updated_at field in the correct language`, async () => {
await act(async () => {
await i18next.changeLanguage('fr');
});
await i18next.changeLanguage('fr');
render(
<DocsGridItemDate
@@ -92,9 +90,7 @@ describe('DocsGridItemDate', () => {
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
await act(async () => {
await i18next.changeLanguage('en');
});
await i18next.changeLanguage('en');
});
[
@@ -1,4 +1,3 @@
import { announce } from '@react-aria/live-announcer';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -18,35 +17,23 @@ export const LanguagePicker = () => {
const { changeLanguageSynchronized } = useSynchronizedLanguage();
const language = i18n.language;
const toLangTag = (locale: string) => locale.replace('_', '-');
// Compute options for dropdown
const optionsPicker = useMemo(() => {
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
return backendOptions.map(([backendLocale, backendLabel]) => {
return {
label: backendLabel,
lang: toLangTag(backendLocale),
value: backendLocale,
isSelected: getMatchingLocales([backendLocale], [language]).length > 0,
callback: async () => {
await changeLanguageSynchronized(backendLocale, user);
announce(
t('Language changed to {{language}}', {
language: backendLabel,
defaultValue: `Language changed to ${backendLabel}`,
}),
'polite',
);
},
callback: () => changeLanguageSynchronized(backendLocale, user),
};
});
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, t, user]);
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
// Extract current language label for display
const [currentLanguageCode, currentLanguageLabel] = conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
) ?? [language, language];
const currentLanguageLabel =
conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
)?.[1] || language;
return (
<DropdownMenu
@@ -78,9 +65,7 @@ export const LanguagePicker = () => {
$align="center"
>
<Icon iconName="translate" $color="inherit" $size="xl" />
<span lang={toLangTag(currentLanguageCode)}>
{currentLanguageLabel}
</span>
{currentLanguageLabel}
</Box>
</DropdownMenu>
);
@@ -1,7 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/../package.json', () => ({
default: { version: '0.0.0' },
}));
describe('DocsDB', () => {
beforeEach(() => {
afterEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
@@ -15,16 +20,17 @@ describe('DocsDB', () => {
{ version: '3.0.0', expected: 3000000 },
{ version: '10.20.30', expected: 10020030 },
].forEach(({ version, expected }) => {
it(`correctly computes version for ${version}`, async () => {
it(`correctly computes version for ${version}`, () => {
vi.doMock('@/../package.json', () => ({
default: { version },
}));
const module = await import('../DocsDB');
const result = (module as any).getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
return vi.importActual('../DocsDB').then((module: any) => {
const result = module.getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
});
});
});
});
+8 -4
View File
@@ -1,9 +1,16 @@
/// <reference types="vitest" />
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
plugins: [
react(),
tsconfigPaths({
root: '.',
projects: ['./tsconfig.json'],
}),
],
test: {
globals: true,
environment: 'jsdom',
@@ -15,7 +22,4 @@ export default defineConfig({
define: {
'process.env.NODE_ENV': 'test',
},
resolve: {
tsconfigPaths: true,
},
});
+4 -4
View File
@@ -32,17 +32,17 @@
},
"resolutions": {
"@tiptap/extensions": "3.19.0",
"@types/node": "24.12.0",
"@types/node": "24.10.13",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "10.0.3",
"eslint": "10.0.1",
"glob": "13.0.6",
"prosemirror-view": "1.41.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "5.9.3",
"wrap-ansi": "10.0.0",
"yjs": "13.6.30"
"wrap-ansi": "9.0.2",
"yjs": "13.6.29"
},
"packageManager": "yarn@1.22.22"
}
@@ -2,7 +2,7 @@ const js = require('@eslint/js');
const nextPlugin = require('@next/eslint-plugin-next');
const tanstackQuery = require('@tanstack/eslint-plugin-query');
const { defineConfig } = require('eslint/config');
const importPlugin = require('eslint-plugin-import-x');
const importPlugin = require('eslint-plugin-import');
const jsxA11y = require('eslint-plugin-jsx-a11y');
const prettier = require('eslint-plugin-prettier');
const react = require('eslint-plugin-react');
@@ -18,22 +18,22 @@
},
"dependencies": {
"@eslint/js": "10.0.1",
"@next/eslint-plugin-next": "16.1.7",
"@next/eslint-plugin-next": "16.1.6",
"@tanstack/eslint-plugin-query": "5.91.4",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/utils": "8.57.1",
"@vitest/eslint-plugin": "1.6.12",
"eslint-config-next": "16.1.7",
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"@typescript-eslint/utils": "8.56.0",
"@vitest/eslint-plugin": "1.6.9",
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jest": "29.15.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.10.0",
"eslint-plugin-playwright": "2.5.1",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-testing-library": "7.16.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-testing-library": "7.15.4",
"prettier": "3.8.1"
},
"packageManager": "yarn@1.22.22"
+2 -2
View File
@@ -19,8 +19,8 @@
"@types/node": "*",
"eslint-plugin-docs": "*",
"eslint-plugin-import": "2.32.0",
"i18next-parser": "9.4.0",
"jest": "30.3.0",
"i18next-parser": "9.3.0",
"jest": "30.2.0",
"ts-jest": "29.4.6",
"typescript": "*",
"yargs": "18.0.0"
@@ -51,8 +51,6 @@ RUN NODE_ENV=production yarn install --frozen-lockfile
# Remove npm, contains CVE related to cross-spawn and we don't use it.
RUN rm -rf /usr/local/bin/npm /usr/local/lib/node_modules/npm
ENV NODE_OPTIONS="--max-old-space-size=2048"
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
@@ -1,6 +1,6 @@
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import request from 'supertest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
vi.mock('../src/env', async (importOriginal) => {
@@ -62,11 +62,7 @@ const expectedBlocks = [
console.error = vi.fn();
describe('Conversion Testing', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('Server Tests', () => {
test('POST /api/convert with incorrect API key responds with 401', async () => {
const app = initApp();
@@ -174,7 +170,6 @@ describe('Conversion Testing', () => {
});
test('POST /api/convert BlockNote to Yjs', async () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp();
const editor = ServerBlockNoteEditor.create();
const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown);
@@ -197,7 +192,6 @@ describe('Conversion Testing', () => {
const decodedBlocks = editor.yDocToBlocks(ydoc, 'document-store');
expect(decodedBlocks).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
test('POST /api/convert BlockNote to HTML', async () => {
@@ -259,7 +253,6 @@ describe('Conversion Testing', () => {
});
test('POST /api/convert Yjs to JSON', async () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp();
const editor = ServerBlockNoteEditor.create();
const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown);
@@ -279,7 +272,6 @@ describe('Conversion Testing', () => {
);
expect(response.body).toBeInstanceOf(Array);
expect(response.body).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
test('POST /api/convert Markdown to JSON', async () => {
@@ -301,7 +293,6 @@ describe('Conversion Testing', () => {
});
test('POST /api/convert with invalid Yjs content returns 400', async () => {
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
const app = initApp();
const response = await request(app)
.post('/api/convert')
@@ -313,6 +304,5 @@ describe('Conversion Testing', () => {
expect(response.status).toBe(400);
expect(response.body).toStrictEqual({ error: 'Invalid content' });
expect(destroySpy).toHaveBeenCalledTimes(1);
});
});
+6 -6
View File
@@ -18,10 +18,10 @@
"dependencies": {
"@blocknote/server-util": "0.47.1",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.43.0",
"@sentry/profiling-node": "10.43.0",
"@sentry/node": "10.38.0",
"@sentry/profiling-node": "10.38.0",
"@tiptap/extensions": "*",
"axios": "1.13.6",
"axios": "1.13.5",
"cors": "2.8.6",
"express": "5.2.1",
"express-ws": "5.0.2",
@@ -36,16 +36,16 @@
"@types/express": "5.0.6",
"@types/express-ws": "3.0.6",
"@types/node": "*",
"@types/supertest": "7.2.0",
"@types/supertest": "6.0.3",
"@types/ws": "8.18.1",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.14",
"nodemon": "3.1.11",
"supertest": "7.2.2",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"typescript": "*",
"vitest": "4.1.0",
"vitest": "4.0.18",
"vitest-mock-extended": "3.1.0",
"ws": "8.19.0"
},
@@ -60,12 +60,8 @@ const readers: InputReader[] = [
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
read: async (data) => {
const ydoc = new Y.Doc();
try {
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
} finally {
ydoc.destroy();
}
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
},
},
{
@@ -81,14 +77,7 @@ const writers: OutputWriter[] = [
},
{
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
write: async (blocks) => {
const ydoc = createYDocument(blocks);
try {
return Y.encodeStateAsUpdate(ydoc);
} finally {
ydoc.destroy();
}
},
write: async (blocks) => Y.encodeStateAsUpdate(createYDocument(blocks)),
},
{
supportedContentTypes: [ContentTypes.Markdown, ContentTypes.XMarkdown],
+2037 -2350
View File
File diff suppressed because it is too large Load Diff
@@ -145,7 +145,6 @@ yProvider:
COLLABORATION_SERVER_ORIGIN: https://{{ .Values.feature }}-docs.{{ .Values.domain }}
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
NODE_OPTIONS: "--max-old-space-size=1024"
docSpec:
enabled: true
-3
View File
@@ -7,9 +7,6 @@
"@html-to/text-cli": "0.5.4",
"mjml": "4.18.0"
},
"resolutions": {
"minimatch": "^9.0.7"
},
"private": true,
"scripts": {
"build-mjml-to-html": "bash ./bin/mjml-to-html",
+13 -6
View File
@@ -110,7 +110,7 @@ boolbase@^1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
brace-expansion@^2.0.2:
brace-expansion@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
@@ -562,12 +562,19 @@ mime@^2.4.6:
resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
minimatch@9.0.1, minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.7:
version "9.0.9"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
minimatch@9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
dependencies:
brace-expansion "^2.0.2"
brace-expansion "^2.0.1"
minimatch@^9.0.3, minimatch@^9.0.4:
version "9.0.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
dependencies:
brace-expansion "^2.0.1"
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
version "7.1.2"