Compare commits

...

12 Commits

Author SHA1 Message Date
Anthony LC
2cbd43caae 🔧(y-provider) increase Node.js memory limit
By default, Node.js has a memory limit of
around 512MB, which can lead to out-of-memory
errors when processing large documents.
This commit increases the memory limit to
2GB for the y-provider server, allowing
it to handle larger documents without crashing.
2026-03-25 17:14:27 +01:00
Anthony LC
525d8c8417 🐛(y-provider) destroy Y.Doc instances after each convert request
The Yjs reader and writer in `convertHandler.ts`
were creating `Y.Doc`instances on every request
without calling `.destroy()`, causing a slow heap
leak that could crash the server.

Fixed by wrapping both sites in `try/finally`
blocks that call `ydoc.destroy()`.
Regression tests added to assert `destroy` is
called the expected number of times per request path.
2026-03-25 12:03:12 +01:00
Cyril
c886cbb41d ️(frontend) fix language dropdown ARIA for screen readers
Add missing attributes for language picker.
2026-03-25 11:08:17 +01:00
Cyril
98f3ca2763 ️(frontend) improve BoxButton a11y and native button semantics
Add type="button", aria-disabled, and align refs with HTMLButtonElement.
2026-03-25 10:05:49 +01:00
Anthony LC
fb92a43755 🚸(frontend) hint min char search users
We give a hint to the user about the minimum
number of characters required to perform a search
in the quick search input of the doc share modal.
This is to improve the user experience.
2026-03-25 09:33:14 +01:00
Anthony LC
03fd1fe50e (frontend) fix vitest tests
We upgraded vitest recently, we need to adapt
some of our tests to the new version.
We brought some modules improvments as well,
problemes that was highlighted by the new version
of vitest.
2026-03-24 16:48:40 +01:00
Anthony LC
fc803226ac 🔒️(js) fix security warning
Force the upgrade of some dependencies to fix
security warnings.
2026-03-24 15:54:34 +01:00
Anthony LC
fb725edda3 🚨(frontend) fix eslint errors
Recent upgrade of eslint-plugin-playwright
highlighted some errors.
This commit fixes those errors.
2026-03-24 13:01:52 +01:00
Anthony LC
6838b387a2 (linter) replace eslint-plugin-import by eslint-plugin-import-x
"eslint-plugin-import" is not well maintained anymore
better to use "eslint-plugin-import-x" which is a fork
of "eslint-plugin-import" and is actively maintained.
2026-03-24 13:01:51 +01:00
Anthony LC
87f570582f ⬇️(frontend) downgrade @react-pdf/renderer and pin it
@react-pdf/renderer is not compatible with the
Blocknote version. We need to downgrade it to a
compatible version and pin it to avoid future issues.
When Blocknote updates to a compatible version,
we can upgrade @react-pdf/renderer again.
2026-03-24 13:01:51 +01:00
Anthony LC
37f56fcc22 📌(frontend) blocked upgrade stylelint
stylelint introduces lot of breaking changes
in its latest version, and since
we use it only for linting css files,
so we can block its upgrade for now and upgrade
it later when we will have more time to handle
the breaking changes.
2026-03-24 13:00:46 +01:00
renovate[bot]
19aa3a36bc ⬆️(dependencies) update js dependencies 2026-03-24 13:00:04 +01:00
59 changed files with 2821 additions and 2383 deletions

View File

@@ -6,9 +6,19 @@ 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
## [v4.8.3] - 2026-03-23

View File

@@ -60,10 +60,13 @@
"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"
]
}

View File

@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
mockedDocument,
overrideConfig,
verifyDocName,
@@ -47,9 +46,9 @@ test.describe('Doc AI feature', () => {
await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText();
expect(
await page.locator('button[data-test="convertMarkdown"]').count(),
).toBe(1);
await expect(
page.locator('button[data-test="convertMarkdown"]'),
).toHaveCount(1);
await expect(
page.getByRole('button', { name: config.selector, exact: true }),
).toBeHidden();
@@ -179,18 +178,32 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
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 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 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: '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, 'German', { exact: true }).click();
await page.getByRole('menuitem', { name: 'German', exact: true }).click();
await expect(editor.getByText('Hallo Welt')).toBeVisible();
});
@@ -256,15 +269,23 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
if (ai_transform) {
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
} else {
await expect(getMenuItem(page, 'Use as prompt')).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
}
if (ai_translate) {
await expect(getMenuItem(page, 'Language')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
} else {
await expect(getMenuItem(page, 'Language')).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
});
});

View File

@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
closeHeaderMenu,
createDoc,
getMenuItem,
getOtherBrowserName,
verifyDocName,
} from './utils-common';
@@ -152,7 +151,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 getMenuItem(thread, 'Edit comment').click();
await thread.getByRole('menuitem', { name: '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();
@@ -177,7 +176,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 getMenuItem(thread, 'Delete comment').click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await expect(
thread.getByText('This is a second comment').first(),
).toBeHidden();
@@ -210,7 +209,7 @@ test.describe('Doc Comments', () => {
await thread.getByText('This is a new comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await getMenuItem(thread, 'Delete comment').click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS(

View File

@@ -5,7 +5,6 @@ import cs from 'convert-stream';
import {
createDoc,
getMenuItem,
goToGridDoc,
overrideConfig,
verifyDocName,
@@ -148,20 +147,18 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
webSocket = await 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();
@@ -578,12 +575,10 @@ test.describe('Doc Editor', () => {
await page.reload();
responseCanEditPromise = page.waitForResponse(
responseCanEdit = await 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 };
@@ -609,7 +604,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, 'Reading').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();

View File

@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
getOtherBrowserName,
mockedListDocs,
toggleHeaderMenu,
@@ -207,7 +206,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Move into a doc').click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -295,7 +294,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Move into a doc').click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -342,7 +341,9 @@ test.describe('Doc grid move', () => {
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await getMenuItem(otherPage, 'Administrator').click();
await otherPage
.getByRole('menuitemradio', { name: 'Administrator' })
.click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(otherPage.getByText('Access Requests')).toBeHidden();
@@ -353,7 +354,7 @@ test.describe('Doc grid move', () => {
await page.reload();
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Move into a doc').click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),

View File

@@ -1,11 +1,6 @@
import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
verifyDocName,
} from './utils-common';
import { createDoc, getGridRow, verifyDocName } from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
type SmallDoc = {
@@ -104,7 +99,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(
page.getByRole('dialog').getByText('Share the document'),
@@ -120,7 +115,7 @@ test.describe('Document grid item options', () => {
// Pin
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Pin').click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
// Check is pinned
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
@@ -147,7 +142,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Delete').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),

View File

@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -79,7 +78,7 @@ test.describe('Doc Header', () => {
await page.getByTestId('doc-visibility').click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await page.getByRole('button', { name: 'close' }).first().click();
@@ -153,8 +152,10 @@ test.describe('Doc Header', () => {
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = getMenuItem(page, 'Add emoji');
const removeEmojiMenuItem = getMenuItem(page, 'Remove emoji');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
});
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
@@ -208,7 +209,7 @@ test.describe('Doc Header', () => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Delete document').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -236,7 +237,7 @@ test.describe('Doc Header', () => {
hasText: randomDoc,
});
expect(await row.count()).toBe(0);
await expect(row).toHaveCount(0);
});
test('it checks the options available if administrator', async ({ page }) => {
@@ -270,10 +271,12 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -293,7 +296,7 @@ test.describe('Doc Header', () => {
await invitationRole.click();
await getMenuItem(page, 'Remove access').click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
@@ -305,7 +308,9 @@ test.describe('Doc Header', () => {
await expect(roles).toBeVisible();
await roles.click();
await expect(getMenuItem(page, 'Remove access')).toBeEnabled();
await expect(
page.getByRole('menuitemradio', { name: 'Remove access' }),
).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -345,10 +350,12 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -415,10 +422,12 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
@@ -473,7 +482,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Copy as Markdown').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await expect(
page.getByText('Copied as Markdown to clipboard'),
).toBeVisible();
@@ -537,7 +546,7 @@ test.describe('Doc Header', () => {
.click();
// Pin
await getMenuItem(page, 'Pin').click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
@@ -558,11 +567,11 @@ test.describe('Doc Header', () => {
.click();
// Unpin
await getMenuItem(page, 'Unpin').click();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(getMenuItem(page, 'Pin')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await page.goto('/');
@@ -580,7 +589,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Duplicate').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await expect(
page.getByText('Document duplicated successfully!'),
).toBeVisible();
@@ -595,7 +604,7 @@ test.describe('Doc Header', () => {
await expect(row.getByText(duplicateTitle)).toBeVisible();
await row.getByText(`more_horiz`).click();
await getMenuItem(page, 'Duplicate').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
await page.getByText(duplicateDuplicateTitle).click();
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
@@ -628,7 +637,7 @@ test.describe('Doc Header', () => {
const currentUrl = page.url();
await getMenuItem(page, 'Duplicate').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await expect(page).not.toHaveURL(new RegExp(currentUrl));
@@ -667,8 +676,10 @@ test.describe('Documents Header mobile', () => {
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Copy link')).toBeVisible();
await getMenuItem(page, 'Share').click();
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
});
@@ -691,7 +702,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', {
name: 'Share the document',

View File

@@ -177,5 +177,5 @@ const dragAndDropFiles = async (
return dt;
}, filesData);
await page.dispatchEvent(selector, 'drop', { dataTransfer });
await page.locator(selector).dispatchEvent('drop', { dataTransfer });
};

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { createDoc, verifyDocName } from './utils-common';
import { updateShareLink } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -53,17 +53,19 @@ test.describe('Inherited share accesses', () => {
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
await docVisibilityCard.getByText('Reading').click();
await getMenuItem(page, 'Editing').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
// Verify inherited link
await docVisibilityCard.getByText('Connected').click();
await expect(getMenuItem(page, 'Private')).toBeDisabled();
await expect(
page.getByRole('menuitemradio', { name: 'Private' }),
).toBeDisabled();
// Update child link
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect(

View File

@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
BROWSERS,
createDoc,
getMenuItem,
keyCloakSignIn,
randomName,
verifyDocName,
@@ -17,6 +16,41 @@ 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(
@@ -76,13 +110,21 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByTestId('doc-role-dropdown').click();
await expect(getMenuItem(page, 'Reader')).toBeVisible();
await expect(getMenuItem(page, 'Editor')).toBeVisible();
await expect(getMenuItem(page, 'Owner')).toBeVisible();
await expect(getMenuItem(page, 'Administrator')).toBeVisible();
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();
// Validate
await getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await page.getByTestId('doc-share-invite-button').click();
// Check invitation added
@@ -128,7 +170,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 getMenuItem(page, 'Owner').click();
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -146,7 +188,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, 'Owner').click();
await page.getByRole('menuitemradio', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -183,7 +225,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 getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -210,13 +252,13 @@ test.describe('Document create member', () => {
);
await userInvitation.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, 'Reader').click();
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
await userInvitation.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, 'Remove access').click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(userInvitation).toBeHidden();
});
@@ -268,7 +310,7 @@ test.describe('Document create member', () => {
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden();

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { createDoc, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => {
@@ -160,7 +160,9 @@ 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(getMenuItem(page, 'Administrator')).toBeDisabled();
await expect(
page.getByRole('menuitemradio', { name: 'Administrator' }),
).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
@@ -183,18 +185,20 @@ test.describe('Document list members', () => {
});
await currentUserRole.click();
await getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await newUserRoles.click();
await expect(getMenuItem(page, 'Owner')).toBeDisabled();
await expect(
page.getByRole('menuitemradio', { name: 'Owner' }),
).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
});
await currentUserRole.click();
await getMenuItem(page, 'Reader').click();
await page.getByRole('menuitemradio', { name: 'Reader' }).click();
await list.click({
force: true, // Force click to close the dropdown
});
@@ -234,11 +238,11 @@ test.describe('Document list members', () => {
await expect(userReader).toBeVisible();
await userReaderRole.click();
await getMenuItem(page, 'Remove access').click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(userReader).toBeHidden();
await mySelfRole.click();
await getMenuItem(page, 'Remove access').click();
await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible();

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { createDoc, verifyDocName } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -136,9 +136,13 @@ test.describe('Document search', () => {
await filters.click();
await filters.getByRole('button', { name: 'Current doc' }).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('menuitemcheckbox', { name: 'All docs' }),
).toBeVisible();
await expect(
page.getByRole('menuitemcheckbox', { name: 'Current doc' }),
).toBeVisible();
await page.getByRole('menuitemcheckbox', { name: 'All docs' }).click();
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
});

View File

@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
updateDocTitle,
verifyDocName,
@@ -43,15 +42,12 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages
const allSubPageItems = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
const allSubPageItems = docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItems).toHaveCount(2);
// Check that elements are in the correct order
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
await expect(allSubPageItems.nth(0).getByText('first move')).toBeVisible();
await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible();
// Will move the first sub page to the second position
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
@@ -91,17 +87,15 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible();
// Check that elements are in the correct order
const allSubPageItemsAfterReload = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItemsAfterReload.length).toBe(2);
const allSubPageItemsAfterReload =
docTree.getByTestId(/^doc-sub-page-item/);
await expect(allSubPageItemsAfterReload).toHaveCount(2);
await expect(
allSubPageItemsAfterReload[0].getByText('second move'),
allSubPageItemsAfterReload.nth(0).getByText('second move'),
).toBeVisible();
await expect(
allSubPageItemsAfterReload[1].getByText('first move'),
allSubPageItemsAfterReload.nth(1).getByText('first move'),
).toBeVisible();
});
@@ -163,7 +157,7 @@ test.describe('Doc Tree', () => {
);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await getMenuItem(page, 'Administrator').click();
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
@@ -193,10 +187,9 @@ test.describe('Doc Tree', () => {
const menu = child.getByText(`more_horiz`);
await menu.click();
await expect(getMenuItem(page, 'Move to my docs')).toHaveAttribute(
'aria-disabled',
'true',
);
await expect(
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
});
test('keyboard navigation with Enter key opens documents', async ({
@@ -340,7 +333,9 @@ test.describe('Doc Tree', () => {
await row.hover();
const menu = row.getByText(`more_horiz`);
await menu.click();
await expect(getMenuItem(page, 'Remove emoji')).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Remove emoji' }),
).toBeHidden();
// Close the menu
await page.keyboard.press('Escape');
@@ -360,7 +355,7 @@ test.describe('Doc Tree', () => {
// Now remove the emoji using the new action
await row.hover();
await menu.click();
await getMenuItem(page, 'Remove emoji').click();
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).toBeHidden();
@@ -390,7 +385,7 @@ test.describe('Doc Tree: Inheritance', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(
page.getByText('The document visibility has been updated.'),

View File

@@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -21,7 +20,7 @@ test.describe('Doc Version', () => {
// Initially, there is no version
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByRole('dialog', { name: 'Version history' });
@@ -75,14 +74,14 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(2);
await items[1].click();
const items = panel.locator('.version-item');
await expect(items).toHaveCount(2);
await items.nth(1).click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -90,7 +89,7 @@ test.describe('Doc Version', () => {
modal.locator('div[data-content-type="callout"]').first(),
).toBeHidden();
await items[0].click();
await items.nth(0).click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeVisible();
@@ -101,7 +100,7 @@ test.describe('Doc Version', () => {
modal.getByText('It will create a second version'),
).toBeHidden();
await items[1].click();
await items.nth(1).click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -125,7 +124,9 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(getMenuItem(page, 'Version history')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -152,7 +153,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Version history').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
const modal = page.getByRole('dialog', { name: 'Version history' });
const panel = modal.getByLabel('Version list');

View File

@@ -4,7 +4,6 @@ import {
BROWSERS,
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
@@ -47,17 +46,21 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(getMenuItem(page, 'Read only')).toBeHidden();
await expect(getMenuItem(page, 'Can read and edit')).toBeHidden();
await expect(
page.getByRole('menuitemradio', { name: 'Read only' }),
).toBeHidden();
await expect(
page.getByRole('menuitemradio', { name: 'Can read and edit' }),
).toBeHidden();
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
@@ -202,7 +205,7 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -210,7 +213,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, 'Reading').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -296,14 +299,14 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Public').click();
await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, 'Editing').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -387,7 +390,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -435,7 +438,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -533,7 +536,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await getMenuItem(page, 'Connected').click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -541,7 +544,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, 'Editing').click();
await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { getMenuItem, overrideConfig } from './utils-common';
import { 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 getMenuItem(page, 'Français').click();
await page.getByRole('menuitemradio', { name: '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 getMenuItem(page, 'Français').click();
await page.getByRole('menuitemradio', { name: 'Français' }).click();
await expect(
page

View File

@@ -2,7 +2,6 @@ import { expect, test } from '@playwright/test';
import {
TestLanguage,
getMenuItem,
overrideConfig,
waitForLanguageSwitch,
} from './utils-common';
@@ -45,7 +44,7 @@ test.describe('Help feature', () => {
await page.getByRole('button', { name: 'Open help menu' }).click();
await getMenuItem(page, 'Onboarding').click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible();
@@ -88,7 +87,7 @@ test.describe('Help feature', () => {
test('closes modal with Skip button', async ({ page }) => {
await page.getByRole('button', { name: 'Open help menu' }).click();
await getMenuItem(page, 'Onboarding').click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible();
@@ -109,7 +108,7 @@ test.describe('Help feature', () => {
await page.getByRole('button', { name: "Ouvrir le menu d'aide" }).click();
await getMenuItem(page, 'Premiers pas').click();
await page.getByRole('menuitem', { name: 'Premiers pas' }).click();
const modal = page.getByLabel('Apprenez les principes fondamentaux');

View File

@@ -75,7 +75,7 @@ test.describe('Language', () => {
await expect(page.locator('[role="menu"]')).toBeVisible();
const menuItems = page.locator('[role="menuitem"], [role="menuitemradio"]');
const menuItems = page.locator('[role="menuitemradio"]');
await expect(menuItems.first()).toBeVisible();
await menuItems.first().click();

View File

@@ -3,16 +3,6 @@ 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';
@@ -392,12 +382,12 @@ export async function waitForLanguageSwitch(
await languagePicker.click();
await getMenuItem(page, lang.label).click();
await page.getByRole('menuitemradio', { name: lang.label }).click();
}
export const clickInEditorMenu = async (page: Page, textButton: string) => {
await page.getByRole('button', { name: 'Open the document options' }).click();
await getMenuItem(page, textButton).click();
await page.getByRole('menuitem', { name: textButton }).click();
};
export const clickInGridMenu = async (
@@ -408,7 +398,7 @@ export const clickInGridMenu = async (
await row
.getByRole('button', { name: /Open the menu of actions for the document/ })
.click();
await getMenuItem(page, textButton).click();
await page.getByRole('menuitem', { name: textButton }).click();
};
export const writeReport = async (

View File

@@ -2,7 +2,6 @@ import { Page, chromium, expect } from '@playwright/test';
import {
BrowserName,
getMenuItem,
getOtherBrowserName,
keyCloakSignIn,
verifyDocName,
@@ -40,7 +39,7 @@ export const addNewMember = async (
// Choose a role
await page.getByTestId('doc-role-dropdown').click();
await getMenuItem(page, role).click();
await page.getByRole('menuitemradio', { name: role }).click();
await page.getByTestId('doc-share-invite-button').click();
return users[index].email;
@@ -52,7 +51,7 @@ export const updateShareLink = async (
linkRole?: LinkRole | null,
) => {
await page.getByTestId('doc-visibility').click();
await getMenuItem(page, linkReach).click();
await page.getByRole('menuitemradio', { name: linkReach }).click();
const visibilityUpdatedText = page
.getByText('The document visibility has been updated')
@@ -62,7 +61,7 @@ export const updateShareLink = async (
if (linkRole) {
await page.getByTestId('doc-access-mode').click();
await getMenuItem(page, linkRole).click();
await page.getByRole('menuitemradio', { name: linkRole }).click();
await expect(visibilityUpdatedText).toBeVisible();
}
};
@@ -77,7 +76,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 getMenuItem(page, role).click();
await page.getByRole('menuitemradio', { name: role }).click();
await list.click();
};

View File

@@ -23,7 +23,7 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@ai-sdk/openai": "3.0.19",
"@ai-sdk/openai": "3.0.45",
"@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.35",
"@fontsource-variable/material-symbols-outlined": "5.2.38",
"@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.6",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "8.3.14",
"@mantine/hooks": "8.3.14",
"@mantine/core": "8.3.17",
"@mantine/hooks": "8.3.17",
"@react-aria/live-announcer": "3.4.4",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.38.0",
"@sentry/nextjs": "10.43.0",
"@tanstack/react-query": "5.90.21",
"@tiptap/extensions": "*",
"ai": "6.0.49",
"ai": "6.0.128",
"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.12",
"i18next": "25.8.18",
"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.347.2",
"posthog-js": "1.360.2",
"react": "*",
"react-aria-components": "1.15.1",
"react-aria-components": "1.16.0",
"react-dom": "*",
"react-dropzone": "15.0.0",
"react-i18next": "16.5.4",
"react-intersection-observer": "10.0.2",
"react-i18next": "16.5.8",
"react-intersection-observer": "10.0.3",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.3.9",
"styled-components": "6.3.11",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zod": "3.25.28",
"zustand": "5.0.11"
"zod": "4.3.6",
"zustand": "5.0.12"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
@@ -89,26 +89,25 @@
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.23",
"@types/lodash": "4.17.24",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.1.4",
"@vitejs/plugin-react": "6.0.1",
"cross-env": "10.1.0",
"dotenv": "17.3.1",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "28.1.0",
"jsdom": "29.0.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": "*",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"webpack": "5.105.2",
"vitest": "4.1.0",
"webpack": "5.105.4",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -1,17 +1,17 @@
import { forwardRef } from 'react';
import { Ref, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = BoxType & {
export type BoxButtonType = Omit<BoxType, 'ref'> & {
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 = BoxType & {
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, ...props }, ref) => {
const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
({ $css, disabled, ...props }, ref) => {
const theme = props.$theme || 'gray';
const variation = props.$variation || 'primary';
@@ -31,16 +31,18 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
<Box
ref={ref}
as="button"
type="button"
$background="none"
$margin="none"
$padding="none"
$hasTransition
aria-disabled={disabled || undefined}
$css={css`
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
cursor: ${disabled ? 'not-allowed' : 'pointer'};
border: none;
outline: none;
font-family: inherit;
color: ${props.disabled &&
color: ${disabled &&
`var(--c--contextuals--content--semantic--disabled--primary)`};
&:focus-visible {
transition: none;
@@ -53,11 +55,11 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
`}
{...props}
className={`--docs--box-button ${props.className || ''}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) {
return;
}
props.onClick?.(event);
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
}}
/>
);

View File

@@ -26,6 +26,7 @@ import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
export type DropdownMenuOption = {
icon?: ReactNode;
label: string;
lang?: string;
testId?: string;
value?: string;
callback?: () => void | Promise<unknown>;
@@ -69,7 +70,10 @@ export const DropdownMenu = ({
const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null);
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const isSingleSelectable = options.some(
(option) => option.isSelected !== undefined,
);
const onOpenChange = useCallback(
(isOpen: boolean) => {
@@ -110,10 +114,6 @@ export const DropdownMenu = ({
[onOpenChange],
);
const hasSelectable =
selectedValues !== undefined ||
options.some((option) => option.isSelected !== undefined);
if (disabled) {
return children;
}
@@ -176,20 +176,25 @@ export const DropdownMenu = ({
}
const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;
const ariaChecked = hasSelectable
? option.isSelected ||
selectedValues?.includes(option.value ?? '') ||
false
: undefined;
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}`;
return (
<Fragment key={option.label}>
<Fragment key={optionKey}>
<BoxButton
ref={(el) => {
menuItemRefs.current[index] = el;
}}
role={hasSelectable ? 'menuitemradio' : 'menuitem'}
aria-checked={ariaChecked}
role={itemRole}
aria-checked={itemRole === 'menuitem' ? undefined : isSelected}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
@@ -200,7 +205,6 @@ export const DropdownMenu = ({
triggerOption(option);
}}
onKeyDown={keyboardAction(() => triggerOption(option))}
key={option.label}
$align="center"
$justify="space-between"
$background="var(--c--contextuals--background--surface--primary)"
@@ -271,16 +275,16 @@ export const DropdownMenu = ({
<Box
$theme="neutral"
$variation={isDisabled ? 'tertiary' : 'primary'}
aria-hidden="true"
>
{option.icon}
</Box>
)}
<Text $variation={isDisabled ? 'tertiary' : 'primary'}>
{option.label}
<span lang={option.lang}>{option.label}</span>
</Text>
</Box>
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
{isSelected && (
<Icon
iconName="check"
$size="20px"

View File

@@ -58,7 +58,7 @@ describe('<DropdownMenu />', () => {
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
});
test('renders menuitemradio role with aria-checked when selectedValues is provided', async () => {
test('renders menuitemcheckbox 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 radios = screen.getAllByRole('menuitemradio');
expect(radios).toHaveLength(3);
const checkboxes = screen.getAllByRole('menuitemcheckbox');
expect(checkboxes).toHaveLength(3);
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
expect(radios[1]).toHaveAttribute('aria-checked', 'true');
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
expect(checkboxes[0]).toHaveAttribute('aria-checked', 'false');
expect(checkboxes[1]).toHaveAttribute('aria-checked', 'true');
expect(checkboxes[2]).toHaveAttribute('aria-checked', 'false');
});
test('trigger button has aria-haspopup and aria-expanded', async () => {

View File

@@ -6,7 +6,7 @@ type UseDropdownKeyboardNavProps = {
isOpen: boolean;
focusedIndex: number;
options: DropdownMenuOption[];
menuItemRefs: RefObject<(HTMLDivElement | null)[]>;
menuItemRefs: RefObject<(HTMLButtonElement | null)[]>;
setFocusedIndex: (index: number) => void;
onOpenChange: (isOpen: boolean) => void;
};

View File

@@ -48,7 +48,7 @@ export const QuickSearchInput = ({
$direction="row"
$align="center"
className="quick-search-input"
$gap={spacingsTokens['2xs']}
$gap={spacingsTokens['xxs']}
$padding={{ horizontal: 'base', vertical: 'xxs' }}
>
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
@@ -62,6 +62,7 @@ export const QuickSearchInput = ({
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
maxLength={254}
minLength={6}
data-testid="quick-search-input"
/>
</Box>

View File

@@ -18,14 +18,15 @@ export const QuickSearchStyle = createGlobalStyle`
[cmdk-input] {
border: none;
width: 100%;
font-size: 17px;
font-size: 16px;
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--globals--colors--gray-500);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
}

View File

@@ -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';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
import { toBase64 } from '@/utils/string';
import { isFirefox } from '@/utils/userAgent';

View File

@@ -1,4 +1,9 @@
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', () => {
@@ -16,12 +21,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);
});
});

View File

@@ -1,6 +1,4 @@
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';
@@ -40,17 +38,11 @@ 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(
screen.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
await screen.findByLabelText('Export the document'),
).toBeInTheDocument();
}, 10000);
}, 15000);
test('The export button is not rendered when MIT version is activated', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
@@ -68,5 +60,5 @@ describe('DocToolBox - Licence', () => {
expect(
screen.queryByLabelText('Export the document'),
).not.toBeInTheDocument();
});
}, 15000);
});

View File

@@ -1,9 +1,8 @@
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 { useEffect, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -39,7 +38,6 @@ 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';
@@ -88,7 +86,6 @@ 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);
@@ -114,16 +111,6 @@ 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();

View File

@@ -4,7 +4,7 @@ import {
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../doc-editor';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
export const useCopyCurrentEditorToClipboard = () => {
const { editor } = useEditorStore();
@@ -21,8 +21,8 @@ export const useCopyCurrentEditorToClipboard = () => {
try {
const editorContentFormatted =
asFormat === 'html'
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
? editor.blocksToHTMLLossy()
: editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
const successMessage =
asFormat === 'markdown'

View File

@@ -44,7 +44,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
void queryClient.resetQueries({
queryKey: [queryKey],
});
});

View File

@@ -44,7 +44,7 @@ export const DocIcon = ({
const { t } = useTranslation();
const { addLastFocus, restoreFocus } = useFocusStore();
const iconRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<HTMLButtonElement>(null);
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
const [pickerPosition, setPickerPosition] = useState<{

View File

@@ -14,6 +14,11 @@ vi.mock('@/stores', () => ({
}),
}));
vi.mock('@gouvfr-lasuite/ui-kit', async () => ({
...(await vi.importActual('@gouvfr-lasuite/ui-kit')),
useTreeContext: () => null,
}));
describe('useDocTitleUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -124,7 +124,8 @@ export const DocShareAddMemberList = ({
$scope="surface"
$theme="tertiary"
$variation=""
$border="1px solid var(--c--contextuals--border--semantic--contextual--primary)"
$border="1px solid var(--c--contextuals--border--surface--primary)"
$margin={{ bottom: 'sm' }}
>
<Box
$direction="row"

View File

@@ -289,7 +289,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
/>
)}
{showMemberSection && isRootDoc && (
<Box $padding={{ horizontal: 'base' }}>
<Box $padding={{ horizontal: 'base', top: 'base' }}>
<QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} />
@@ -301,6 +301,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
searchUsersRawData={searchUsersQuery.data}
onSelect={onSelect}
userQuery={userQuery}
minLength={API_USERS_SEARCH_QUERY_MIN_LENGTH}
/>
)}
</QuickSearch>
@@ -321,14 +322,35 @@ 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 || [];
@@ -347,7 +369,7 @@ const QuickSearchInviteInputSection = ({
);
return {
groupName: t('Search user result'),
groupName: hint,
elements: users,
endActions:
isEmail && !hasEmailInUsers
@@ -359,12 +381,12 @@ const QuickSearchInviteInputSection = ({
]
: undefined,
};
}, [onSelect, searchUsersRawData, t, userQuery]);
}, [searchUsersRawData, userQuery, hint, onSelect]);
return (
<Box
aria-label={t('List search user result card')}
$padding={{ horizontal: 'base', bottom: '3xs' }}
$padding={{ horizontal: 'base', bottom: '3xs', top: 'base' }}
>
<QuickSearchGroup
group={searchUserData}

View File

@@ -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<HTMLDivElement | null>(null);
const buttonOptionRef = useRef<HTMLButtonElement | null>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement | null;

View File

@@ -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<HTMLDivElement | null>(null);
const rootButtonOptionRef = useRef<HTMLButtonElement | null>(null);
const { t } = useTranslation();

View File

@@ -33,7 +33,7 @@ type DocTreeItemActionsProps = {
onOpenChange?: (isOpen: boolean) => void;
parentId?: string | null;
actionsRef?: React.RefObject<HTMLDivElement | null>;
buttonOptionRef?: React.RefObject<HTMLDivElement | null>;
buttonOptionRef?: React.RefObject<HTMLButtonElement | 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<HTMLDivElement | null>(null);
const internalButtonRef = useRef<HTMLButtonElement | null>(null);
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
const router = useRouter();
const { t } = useTranslation();

View File

@@ -4,9 +4,12 @@ import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { BlockNoteReader, DocEditorContainer } from '@/docs/doc-editor/';
import { BlockNoteReader } from '@/docs/doc-editor/components/BlockNoteEditor';
import { DocEditorContainer } from '@/docs/doc-editor/components/DocEditor';
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
import { useDocVersion } from '../api/useDocVersion';
import { Versions } from '../types';
import { DocVersionHeader } from './DocVersionHeader';

View File

@@ -1,4 +1,4 @@
import { Doc } from '../doc-management';
import { Doc } from '../doc-management/types';
export interface APIListVersions {
count: number;

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import i18next from 'i18next';
import { DateTime } from 'luxon';
@@ -73,7 +73,9 @@ describe('DocsGridItemDate', () => {
});
it(`should render rendered the updated_at field in the correct language`, async () => {
await i18next.changeLanguage('fr');
await act(async () => {
await i18next.changeLanguage('fr');
});
render(
<DocsGridItemDate
@@ -90,7 +92,9 @@ describe('DocsGridItemDate', () => {
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
await i18next.changeLanguage('en');
await act(async () => {
await i18next.changeLanguage('en');
});
});
[

View File

@@ -1,3 +1,4 @@
import { announce } from '@react-aria/live-announcer';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -17,23 +18,35 @@ 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: () => changeLanguageSynchronized(backendLocale, user),
callback: async () => {
await changeLanguageSynchronized(backendLocale, user);
announce(
t('Language changed to {{language}}', {
language: backendLabel,
defaultValue: `Language changed to ${backendLabel}`,
}),
'polite',
);
},
};
});
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, t, user]);
// Extract current language label for display
const currentLanguageLabel =
conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
)?.[1] || language;
const [currentLanguageCode, currentLanguageLabel] = conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
) ?? [language, language];
return (
<DropdownMenu
@@ -65,7 +78,9 @@ export const LanguagePicker = () => {
$align="center"
>
<Icon iconName="translate" $color="inherit" $size="xl" />
{currentLanguageLabel}
<span lang={toLangTag(currentLanguageCode)}>
{currentLanguageLabel}
</span>
</Box>
</DropdownMenu>
);

View File

@@ -1,12 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/../package.json', () => ({
default: { version: '0.0.0' },
}));
import { describe, expect, it, vi } from 'vitest';
describe('DocsDB', () => {
afterEach(() => {
vi.clearAllMocks();
beforeEach(() => {
vi.resetModules();
});
@@ -20,17 +15,16 @@ describe('DocsDB', () => {
{ version: '3.0.0', expected: 3000000 },
{ version: '10.20.30', expected: 10020030 },
].forEach(({ version, expected }) => {
it(`correctly computes version for ${version}`, () => {
it(`correctly computes version for ${version}`, async () => {
vi.doMock('@/../package.json', () => ({
default: { version },
}));
return vi.importActual('../DocsDB').then((module: any) => {
const result = module.getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
});
const module = await import('../DocsDB');
const result = (module as any).getCurrentVersion();
expect(result).toBe(expected);
expect(result).toBeGreaterThan(previousExpected);
previousExpected = result;
});
});
});

View File

@@ -1,16 +1,9 @@
/// <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(),
tsconfigPaths({
root: '.',
projects: ['./tsconfig.json'],
}),
],
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
@@ -22,4 +15,7 @@ export default defineConfig({
define: {
'process.env.NODE_ENV': 'test',
},
resolve: {
tsconfigPaths: true,
},
});

View File

@@ -32,17 +32,17 @@
},
"resolutions": {
"@tiptap/extensions": "3.19.0",
"@types/node": "24.10.13",
"@types/node": "24.12.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "10.0.1",
"eslint": "10.0.3",
"glob": "13.0.6",
"prosemirror-view": "1.41.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"typescript": "5.9.3",
"wrap-ansi": "9.0.2",
"yjs": "13.6.29"
"wrap-ansi": "10.0.0",
"yjs": "13.6.30"
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -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');
const importPlugin = require('eslint-plugin-import-x');
const jsxA11y = require('eslint-plugin-jsx-a11y');
const prettier = require('eslint-plugin-prettier');
const react = require('eslint-plugin-react');

View File

@@ -18,22 +18,22 @@
},
"dependencies": {
"@eslint/js": "10.0.1",
"@next/eslint-plugin-next": "16.1.6",
"@next/eslint-plugin-next": "16.1.7",
"@tanstack/eslint-plugin-query": "5.91.4",
"@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",
"@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",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-jest": "29.15.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.5.1",
"eslint-plugin-playwright": "2.10.0",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-testing-library": "7.15.4",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-testing-library": "7.16.0",
"prettier": "3.8.1"
},
"packageManager": "yarn@1.22.22"

View File

@@ -19,8 +19,8 @@
"@types/node": "*",
"eslint-plugin-docs": "*",
"eslint-plugin-import": "2.32.0",
"i18next-parser": "9.3.0",
"jest": "30.2.0",
"i18next-parser": "9.4.0",
"jest": "30.3.0",
"ts-jest": "29.4.6",
"typescript": "*",
"yargs": "18.0.0"

View File

@@ -51,6 +51,8 @@ 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}

View File

@@ -1,6 +1,6 @@
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import request from 'supertest';
import { describe, expect, test, vi } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
vi.mock('../src/env', async (importOriginal) => {
@@ -62,7 +62,11 @@ const expectedBlocks = [
console.error = vi.fn();
describe('Server Tests', () => {
describe('Conversion Testing', () => {
afterEach(() => {
vi.clearAllMocks();
});
test('POST /api/convert with incorrect API key responds with 401', async () => {
const app = initApp();
@@ -170,6 +174,7 @@ describe('Server Tests', () => {
});
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);
@@ -192,6 +197,7 @@ describe('Server Tests', () => {
const decodedBlocks = editor.yDocToBlocks(ydoc, 'document-store');
expect(decodedBlocks).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
test('POST /api/convert BlockNote to HTML', async () => {
@@ -253,6 +259,7 @@ describe('Server Tests', () => {
});
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);
@@ -272,6 +279,7 @@ describe('Server Tests', () => {
);
expect(response.body).toBeInstanceOf(Array);
expect(response.body).toStrictEqual(expectedBlocks);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
test('POST /api/convert Markdown to JSON', async () => {
@@ -293,6 +301,7 @@ describe('Server Tests', () => {
});
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')
@@ -304,5 +313,6 @@ describe('Server Tests', () => {
expect(response.status).toBe(400);
expect(response.body).toStrictEqual({ error: 'Invalid content' });
expect(destroySpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -18,10 +18,10 @@
"dependencies": {
"@blocknote/server-util": "0.47.1",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.38.0",
"@sentry/profiling-node": "10.38.0",
"@sentry/node": "10.43.0",
"@sentry/profiling-node": "10.43.0",
"@tiptap/extensions": "*",
"axios": "1.13.5",
"axios": "1.13.6",
"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": "6.0.3",
"@types/supertest": "7.2.0",
"@types/ws": "8.18.1",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.11",
"nodemon": "3.1.14",
"supertest": "7.2.2",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"typescript": "*",
"vitest": "4.0.18",
"vitest": "4.1.0",
"vitest-mock-extended": "3.1.0",
"ws": "8.19.0"
},

View File

@@ -60,8 +60,12 @@ const readers: InputReader[] = [
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
read: async (data) => {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
try {
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
} finally {
ydoc.destroy();
}
},
},
{
@@ -77,7 +81,14 @@ const writers: OutputWriter[] = [
},
{
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
write: async (blocks) => Y.encodeStateAsUpdate(createYDocument(blocks)),
write: async (blocks) => {
const ydoc = createYDocument(blocks);
try {
return Y.encodeStateAsUpdate(ydoc);
} finally {
ydoc.destroy();
}
},
},
{
supportedContentTypes: [ContentTypes.Markdown, ContentTypes.XMarkdown],

File diff suppressed because it is too large Load Diff

View File

@@ -145,6 +145,7 @@ 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

View File

@@ -7,6 +7,9 @@
"@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",

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.1:
brace-expansion@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
@@ -562,19 +562,12 @@ 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:
version "9.0.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
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==
dependencies:
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"
brace-expansion "^2.0.2"
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
version "7.1.2"