Compare commits

...

12 Commits

Author SHA1 Message Date
Cyril ef1679968d fixup! ️(frontend) fix empty heading before section titles in HTML export 2026-03-25 14:14:51 +01:00
Cyril a42c35dda0 ️(frontend) fix list merging across headings in HTML export
Lists separated by a heading were merged into a single <ul>
2026-03-25 13:08:58 +01:00
Cyril b2192b9594 fixup! ️(frontend) fix empty heading before section titles in HTML export 2026-03-25 12:59:05 +01:00
Cyril 75c649be39 ️(frontend) fix empty heading before section titles in HTML export
Avoid nested headings: full HTML already wraps content in h1–h6; unwrap
2026-03-25 10:03:08 +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
ZouicheOmar 0d09f761dc 💄(frontend) improve comments highlights
Updated comments styles to respect design proposal,
adding distinguishable highlighting, click and hover
style interactions.
2026-03-24 09:38:31 +01:00
34 changed files with 2817 additions and 2207 deletions
+5
View File
@@ -6,6 +6,11 @@ and this project adheres to
## [Unreleased]
### Changed
- 💄(frontend) improve comments highlights #1961
- ♿️(frontend) fix empty heading before section titles in HTML export #2125
## [v4.8.3] - 2026-03-23
### Changed
+3
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"
]
}
@@ -47,9 +47,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();
@@ -131,12 +131,13 @@ test.describe('Doc Comments', () => {
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
// Check background color changed
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
await editor.first().click();
await editor.getByText('Hello').click();
@@ -185,6 +186,7 @@ test.describe('Doc Comments', () => {
await thread.getByText('This is an edited comment').first().hover();
await thread.locator('[data-test="resolve"]').click();
await expect(thread).toBeHidden();
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
@@ -196,11 +198,13 @@ test.describe('Doc Comments', () => {
await thread.getByRole('paragraph').first().fill('This is a new comment');
await thread.locator('[data-test="save"]').click();
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
await editor.first().click();
await editor.getByText('Hello').click();
@@ -208,6 +212,7 @@ test.describe('Doc Comments', () => {
await thread.locator('[data-test="moreactions"]').first().click();
await getMenuItem(thread, 'Delete comment').click();
await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
@@ -263,7 +268,7 @@ test.describe('Doc Comments', () => {
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
// We change the role of the second user to reader
@@ -298,7 +303,7 @@ test.describe('Doc Comments', () => {
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
await otherEditor.getByText('Hello').click();
await expect(
@@ -344,7 +349,7 @@ test.describe('Doc Comments', () => {
await expect(editor1.getByText('Document One')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
await editor1.getByText('Document One').click();
@@ -155,13 +155,11 @@ test.describe('Doc Editor', () => {
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 +576,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 };
@@ -236,7 +236,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 }) => {
@@ -273,7 +273,7 @@ test.describe('Doc Header', () => {
await expect(getMenuItem(page, '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();
@@ -348,7 +348,7 @@ test.describe('Doc Header', () => {
await expect(getMenuItem(page, '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();
@@ -418,7 +418,7 @@ test.describe('Doc Header', () => {
await expect(getMenuItem(page, '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();
@@ -177,5 +177,5 @@ const dragAndDropFiles = async (
return dt;
}, filesData);
await page.dispatchEvent(selector, 'drop', { dataTransfer });
await page.locator(selector).dispatchEvent('drop', { dataTransfer });
};
@@ -43,15 +43,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 +88,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();
});
@@ -80,9 +80,9 @@ test.describe('Doc Version', () => {
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 +90,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 +101,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();
@@ -3,6 +3,11 @@ import path from 'path';
import { Locator, Page, TestInfo, expect } from '@playwright/test';
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
/** Returns a locator for a menu item (handles both menuitem and menuitemradio roles) */
export const getMenuItem = (
context: Page | Locator,
@@ -13,11 +18,6 @@ export const getMenuItem = (
.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';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
export const CONFIG = {
AI_BOT: {
name: 'Docs AI',
+20 -21
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"
@@ -8,12 +8,37 @@ export const cssComments = (
& .--docs--main-editor .ProseMirror {
// Comments marks in the editor
.bn-editor {
.bn-thread-mark:not([data-orphan='true']),
.bn-thread-mark-selected:not([data-orphan='true']) {
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
color: var(--c--globals--colors--gray-700);
// Resets blocknote comments styles
.bn-thread-mark,
.bn-thread-mark-selected {
background-color: transparent;
}
${canSeeComment &&
css`
.bn-thread-mark:not([data-orphan='true']) {
background-color: color-mix(
in srgb,
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
transparent
);
border-bottom: 2px solid
var(--c--contextuals--background--palette--yellow--secondary);
mix-blend-mode: multiply;
transition:
background-color var(--c--globals--transitions--duration),
border-bottom-color var(--c--globals--transitions--duration);
&:has(.bn-thread-mark-selected) {
background-color: var(
--c--contextuals--background--palette--yellow--tertiary
);
}
}
`}
[data-show-selection] {
color: HighlightText;
}
@@ -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';
@@ -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);
});
});
@@ -0,0 +1,267 @@
import { improveHtmlAccessibility } from '../utils_html';
const parse = (html: string): Document => {
const parser = new DOMParser();
return parser.parseFromString(html, 'text/html');
};
const bodyHtml = (doc: Document) =>
doc.body.innerHTML.replace(/\s+/g, ' ').trim();
describe('improveHtmlAccessibility', () => {
// Headings
describe('headings', () => {
it('converts heading blocks without inner h tag to semantic h1h6', () => {
const doc = parse(`
<div data-content-type="heading" data-level="3"><span>Title</span></div>
`);
improveHtmlAccessibility(doc, 'Doc');
const h3 = doc.querySelector('h3');
expect(h3).not.toBeNull();
expect(h3!.textContent.trim()).toBe('Title');
});
it('does not nest a second heading when BlockNote already outputs h1h6', () => {
const doc = parse(`
<div data-content-type="heading" data-level="2">
<h2 class="bn-inline-content">Section</h2>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
const h2 = doc.querySelectorAll('h2');
expect(h2).toHaveLength(1);
expect(h2[0].textContent).toBe('Section');
expect(h2[0].className).toBe('bn-inline-content');
});
it('uses data-level when inner heading tag has a different level', () => {
const doc = parse(`
<div data-content-type="heading" data-level="3">
<h2 class="bn-inline-content">Mismatch</h2>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelector('h3')).not.toBeNull();
expect(doc.querySelector('h2')).toBeNull();
});
it('inserts an h1 with the document title when none exists', () => {
const doc = parse(`<p>Content</p>`);
improveHtmlAccessibility(doc, 'My Doc');
const h1 = doc.querySelector('h1');
expect(h1).not.toBeNull();
expect(h1!.id).toBe('doc-title');
expect(h1!.textContent).toBe('My Doc');
});
it('does not insert h1 when the document already has one', () => {
const doc = parse(`
<div data-content-type="heading" data-level="1"><span>Existing</span></div>
`);
improveHtmlAccessibility(doc, 'Ignored');
expect(doc.querySelectorAll('h1')).toHaveLength(1);
});
});
// Lists
describe('lists', () => {
it('converts bullet list items to ul > li', () => {
const doc = parse(`
<div class="bn-block-group">
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>A</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>B</span></div>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
const items = doc.querySelectorAll('ul > li');
expect(items).toHaveLength(2);
expect(items[0].textContent).toBe('A');
expect(items[1].textContent).toBe('B');
});
it('converts numbered list items to ol > li', () => {
const doc = parse(`
<div class="bn-block-group">
<div class="bn-block-outer">
<div data-content-type="numberedListItem"><span>1st</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="numberedListItem"><span>2nd</span></div>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelectorAll('ol > li')).toHaveLength(2);
});
it('splits lists when a heading sits between them', () => {
const doc = parse(`
<div class="bn-block-group">
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>A</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>B</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="heading" data-level="2"><span>Next</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>C</span></div>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
const uls = doc.querySelectorAll('ul');
expect(uls).toHaveLength(2);
expect(uls[0].querySelectorAll('li')).toHaveLength(2);
expect(uls[1].querySelectorAll('li')).toHaveLength(1);
const html = bodyHtml(doc);
expect(html.indexOf('<ul')).toBeLessThan(html.indexOf('<h2'));
expect(html.indexOf('<h2')).toBeLessThan(
html.indexOf('<ul', html.indexOf('<ul') + 1),
);
});
it('keeps consecutive same-type items in a single list', () => {
const doc = parse(`
<div class="bn-block-group">
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>A</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>B</span></div>
</div>
<div class="bn-block-outer">
<div data-content-type="bulletListItem"><span>C</span></div>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelectorAll('ul')).toHaveLength(1);
expect(doc.querySelectorAll('ul > li')).toHaveLength(3);
});
});
// Quotes
it('converts quote blocks to blockquote', () => {
const doc = parse(`
<div data-content-type="quote"><span>Wise words</span></div>
`);
improveHtmlAccessibility(doc, 'Doc');
const bq = doc.querySelector('blockquote');
expect(bq).not.toBeNull();
expect(bq!.textContent).toBe('Wise words');
});
// Callouts
it('converts callout blocks to aside with role="note"', () => {
const doc = parse(`
<div data-content-type="callout"><span>Note</span></div>
`);
improveHtmlAccessibility(doc, 'Doc');
const aside = doc.querySelector('aside');
expect(aside).not.toBeNull();
expect(aside!.getAttribute('role')).toBe('note');
});
// Checklists
it('wraps check list items in a ul with role="list" and adds aria-checked', () => {
const doc = parse(`
<div>
<div data-content-type="checkListItem">
<input type="checkbox" />
<span>Todo</span>
</div>
</div>
`);
improveHtmlAccessibility(doc, 'Doc');
const ul = doc.querySelector('ul.checklist');
expect(ul).not.toBeNull();
expect(ul!.getAttribute('role')).toBe('list');
expect(doc.querySelector('input')!.getAttribute('aria-checked')).toBe(
'false',
);
});
// Code blocks
it('converts code blocks to pre > code and preserves data attributes', () => {
const doc = parse(`
<div data-content-type="codeBlock" class="hl-theme" data-language="js"><span>const x = 1;</span></div>
`);
improveHtmlAccessibility(doc, 'Doc');
const pre = doc.querySelector('pre');
expect(pre).not.toBeNull();
expect(pre!.className).toBe('hl-theme');
expect(pre!.getAttribute('data-language')).toBe('js');
expect(pre!.querySelector('code')!.textContent.trim()).toBe('const x = 1;');
});
// Images
it('adds empty alt to images without one', () => {
const doc = parse(`<img src="photo.png" />`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelector('img')!.getAttribute('alt')).toBe('');
});
it('does not overwrite an existing alt', () => {
const doc = parse(`<img src="photo.png" alt="A photo" />`);
improveHtmlAccessibility(doc, 'Doc');
expect(doc.querySelector('img')!.getAttribute('alt')).toBe('A photo');
});
// Article wrapper
it('wraps body in article with role="document"', () => {
const doc = parse(`<p>Hello</p>`);
improveHtmlAccessibility(doc, 'Doc');
const article = doc.querySelector('article');
expect(article).not.toBeNull();
expect(article!.getAttribute('role')).toBe('document');
expect(article!.getAttribute('aria-labelledby')).toBe('doc-title');
});
});
@@ -145,8 +145,20 @@ export const improveHtmlAccessibility = (
headingBlocks.forEach((block) => {
const rawLevel = Number(block.getAttribute('data-level')) || 1;
const level = Math.min(Math.max(rawLevel, 1), 6);
const heading = parsedDocument.createElement(`h${level}`);
moveChildNodes(block, heading);
const tag = `h${level}`;
const existingHeading = block.querySelector('h1, h2, h3, h4, h5, h6');
const heading = parsedDocument.createElement(tag);
if (existingHeading) {
if (existingHeading.className) {
heading.className = existingHeading.className;
}
moveChildNodes(existingHeading, heading);
} else {
moveChildNodes(block, heading);
}
block.replaceWith(heading);
});
@@ -178,10 +190,11 @@ export const improveHtmlAccessibility = (
listItem: HTMLElement;
contentType: string;
level: number;
blockOuterIndex: number;
}
const listItemsInfo: ListItemInfo[] = [];
allBlockOuters.forEach((blockOuter) => {
allBlockOuters.forEach((blockOuter, index) => {
const listItem = blockOuter.querySelector<HTMLElement>(listItemSelector);
if (listItem) {
const contentType = listItem.getAttribute('data-content-type');
@@ -192,6 +205,7 @@ export const improveHtmlAccessibility = (
listItem,
contentType,
level,
blockOuterIndex: index,
});
}
}
@@ -206,13 +220,19 @@ export const improveHtmlAccessibility = (
const isBullet = contentType === 'bulletListItem';
const listTag = isBullet ? 'ul' : 'ol';
// Check if previous item continues the same list (same type and level)
// Check if previous item continues the same list (same type, level, and
// no non-list block between them in the DOM : e.g. a heading separates lists).
const previousInfo = idx > 0 ? listItemsInfo[idx - 1] : null;
const isAdjacentBlock = previousInfo && info.blockOuterIndex === previousInfo.blockOuterIndex + 1;
const continuesPreviousList =
previousInfo &&
isAdjacentBlock &&
previousInfo.contentType === contentType &&
previousInfo.level === level;
if (previousInfo && !isAdjacentBlock) {
listStack.length = 0;
}
// Find or create the appropriate list
let targetList: HTMLElement | null = null;
@@ -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);
});
@@ -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();
@@ -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'
@@ -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],
});
});
@@ -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();
@@ -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';
@@ -1,4 +1,4 @@
import { Doc } from '../doc-management';
import { Doc } from '../doc-management/types';
export interface APIListVersions {
count: number;
@@ -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');
});
});
[
@@ -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;
});
});
});
+4 -8
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,
},
});
+4 -4
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"
}
@@ -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');
@@ -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"
+2 -2
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"
+6 -6
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"
},
+2350 -2037
View File
File diff suppressed because it is too large Load Diff
+3
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",
+6 -13
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"