Compare commits

...

12 Commits

Author SHA1 Message Date
Cyril 5cccc1ae6f (e2e) update tests for list semantics and add keyboard nav test
Adapt selectors from grid/row to list/listitem and add a Tab+Enter test
2026-03-25 18:03:57 +01:00
Cyril de9259f636 ️(frontend) localize dnd-kit screen reader instructions
Pass i18n strings for screenReaderInstructions and announcements.
2026-03-25 18:03:43 +01:00
Cyril 0f49230231 ️(frontend) replace ARIA grid pattern with list in docs grid
Use list/listitem roles, mark column headers as aria-hidden
2026-03-25 18:03:33 +01:00
Anthony LC cbe6a67704 🔧(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:22:32 +01:00
Manuel Raynaud f91223fe4a 🔊(backend) add some log to trace conversion made on docs creation
We added logs on the conversion made when a doc is created.
2026-03-25 17:22:32 +01:00
Manuel Raynaud 330096eb47 🐛(backend) move lock table closer to the insert operation targeted
We want to lock the table just before the insert we want to protect is
made. In the case of the perform_create action in the Document viewset,
an http call is made after the lock and can take a very long time,
blocking for nothing the table.
2026-03-25 15:43:49 +01:00
Paul Vernin ff995c6cd9 🚨(backend) fix lint on test file
Signed-off-by: Paul Vernin <paul.vernin@gmail.com>
2026-03-25 15:14:13 +01:00
Paul Vernin 2e4a1b8ff9 📝(changelog) add fix to CHANGELOG.md
Signed-off-by: Paul Vernin <paul.vernin@gmail.com>
2026-03-25 15:14:09 +01:00
Paul Vernin 004d637c8b 🐛(backend) use ancestors_deleted_at to filter out deleted docs
Filter by ancestors_deleted_at__isnull=True instead of deleted_at__isnull=True
to be more accurate

Signed-off-by: Paul Vernin <paul.vernin@gmail.com>
2026-03-25 15:13:59 +01:00
Paul Vernin 8a0330a30f (backend) add favorite list test for sub-doc
Add test_api_document_favorite_list_with_deleted_child to verify favorite_list
endpoint does not include deleted sub documents

Signed-off-by: Paul Vernin <paul.vernin@gmail.com>
2026-03-25 15:13:47 +01:00
Paul Vernin 677392b89b 🐛(backend) Fix favorite_list result for deleted sub docs
filters out deleted documents from the favorite_list query

Signed-off-by: Paul Vernin <paul.vernin@gmail.com>
2026-03-25 15:13:36 +01:00
Cyril b8e1d12aea ️(frontend) add aria-hidden to decorative icons in dropdown menu
Mark decorative SVG icons with aria-hidden.
2026-03-25 14:15:48 +01:00
16 changed files with 215 additions and 92 deletions
+4
View File
@@ -15,10 +15,14 @@ and this project adheres to
- 💄(frontend) improve comments highlights #1961
- ♿️(frontend) improve BoxButton a11y and native button semantics #2103
- ♿️(frontend) improve language picker accessibility #2069
- ♿️(frontend) add aria-hidden to decorative icons in dropdown menu #2093
- 🐛(backend) move lock table closer to the insert operation targeted
- ♿️(frontend) replace ARIA grid pattern with list in docs grid #2131
### Fixed
- 🐛(y-provider) destroy Y.Doc instances after each convert request #2129
- 🐛(backend) remove deleted sub documents in favorite_list endpoint #2083
## [v4.8.3] - 2026-03-23
+15 -13
View File
@@ -674,17 +674,8 @@ class DocumentViewSet(
return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)
# Remove file from validated_data as it's not a model field
# Process it if present
uploaded_file = serializer.validated_data.pop("file", None)
@@ -702,15 +693,25 @@ class DocumentViewSet(
)
serializer.validated_data["content"] = converted_content
serializer.validated_data["title"] = uploaded_file.name
logger.info("conversion ended successfully")
except ConversionError as err:
logger.error("could not convert file content with error: %s", err)
raise drf.exceptions.ValidationError(
{"file": ["Could not convert file content"]}
) from err
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
)
with transaction.atomic():
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
)
serializer.instance = obj
models.DocumentAccess.objects.create(
document=obj,
@@ -827,6 +828,7 @@ class DocumentViewSet(
queryset = self.queryset.filter(path_list)
queryset = queryset.filter(id__in=favorite_documents_ids)
queryset = queryset.filter(ancestors_deleted_at__isnull=True)
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate(
is_favorite=db.Value(True, output_field=db.BooleanField())
+10 -11
View File
@@ -267,6 +267,16 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
if settings.USER_ONBOARDING_SANDBOX_DOCUMENT:
# transaction.atomic is used in a context manager to avoid a transaction if
# the settings USER_ONBOARDING_SANDBOX_DOCUMENT is unused
sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT
try:
template_document = Document.objects.get(id=sandbox_id)
except Document.DoesNotExist:
logger.warning(
"Onboarding sandbox document with id %s does not exist. Skipping.",
sandbox_id,
)
return
with transaction.atomic():
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
@@ -274,17 +284,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
f'LOCK TABLE "{Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)
sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT
try:
template_document = Document.objects.get(id=sandbox_id)
except Document.DoesNotExist:
logger.warning(
"Onboarding sandbox document with id %s does not exist. Skipping.",
sandbox_id,
)
return
sandbox_document = Document.add_root(
title=template_document.title,
content=template_document.content,
@@ -45,6 +45,8 @@ class Converter:
def convert(self, data, content_type, accept):
"""Convert input into other formats using external microservices."""
logger.info("converting content from %s to %s", content_type, accept)
if content_type == mime_types.DOCX and accept == mime_types.YJS:
blocknote_data = self.docspec.convert(
data, mime_types.DOCX, mime_types.BLOCKNOTE
@@ -114,3 +114,29 @@ def test_api_document_favorite_list_with_favorite_children():
assert content[0]["id"] == str(children[0].id)
assert content[1]["id"] == str(children[1].id)
assert content[2]["id"] == str(access.document.id)
def test_api_document_favorite_list_with_deleted_child():
"""
Authenticated users should not see deleted documents in their favorite list.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
root = factories.DocumentFactory(creator=user, users=[user], favorited_by=[user])
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=root, favorited_by=[user]
)
child1.delete()
response = client.get("/api/v1.0/documents/favorite_list/")
assert response.status_code == 200
assert response.json()["count"] == 2
content = response.json()["results"]
assert content[0]["id"] == str(root.id)
assert content[1]["id"] == str(child2.id)
@@ -400,7 +400,7 @@ test.describe('Doc grid dnd mobile', () => {
await expect(page.getByTestId('docs-grid')).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(docsGrid.getByRole('row').first()).toBeVisible();
await expect(docsGrid.getByRole('listitem').first()).toBeVisible();
await expect(docsGrid.locator('.--docs--grid-droppable')).toHaveCount(0);
await createDoc(page, 'Draggable doc mobile', browserName, 1, true);
@@ -76,7 +76,7 @@ test.describe('Documents Grid mobile', () => {
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const rows = docsGrid.getByRole('row');
const rows = docsGrid.getByRole('listitem');
const row = rows.filter({
hasText: 'My mocked document',
});
@@ -289,6 +289,29 @@ test.describe('Documents Grid', () => {
);
});
test('opens a document with keyboard (Tab + Enter)', async ({
page,
browserName,
}) => {
await page.goto('/');
const [docTitle] = await createDoc(page, 'keyboard-nav-test', browserName);
await page.goto('/');
await expect(page.getByTestId('grid-loader')).toBeHidden();
const row = await getGridRow(page, docTitle);
const link = row.getByRole('link').first();
await link.focus();
await expect(link).toBeFocused();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/\/docs\//);
await verifyDocName(page, docTitle);
});
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[];
const responsePromisePage1 = page.waitForResponse((response) => {
@@ -183,11 +183,11 @@ export const verifyDocName = async (page: Page, docName: string) => {
};
export const getGridRow = async (page: Page, title: string) => {
const docsGrid = page.getByRole('grid');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const rows = docsGrid.getByRole('row');
const rows = docsGrid.getByRole('listitem');
const row = rows
.filter({
@@ -215,7 +215,7 @@ export const goToGridDoc = async (
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const rows = docsGrid.getByRole('row');
const rows = docsGrid.getByRole('listitem');
const row = title
? rows.filter({
@@ -275,7 +275,6 @@ export const DropdownMenu = ({
<Box
$theme="neutral"
$variation={isDisabled ? 'tertiary' : 'primary'}
aria-hidden="true"
>
{option.icon}
</Box>
@@ -118,13 +118,13 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const options: DropdownMenuOption[] = [
{
label: t('Share'),
icon: <GroupSVG width={24} height={24} />,
icon: <GroupSVG width={24} height={24} aria-hidden="true" />,
callback: modalShare.open,
show: isSmallMobile,
},
{
label: t('Export'),
icon: <DownloadSVG width={24} height={24} />,
icon: <DownloadSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsModalExportOpen(true);
},
@@ -133,9 +133,9 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: doc.is_favorite ? (
<KeepOffSVG width={24} height={24} />
<KeepOffSVG width={24} height={24} aria-hidden="true" />
) : (
<KeepSVG width={24} height={24} />
<KeepSVG width={24} height={24} aria-hidden="true" />
),
callback: () => {
if (doc.is_favorite) {
@@ -148,7 +148,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
{
label: t('Version history'),
icon: <HistorySVG width={24} height={24} />,
icon: <HistorySVG width={24} height={24} aria-hidden="true" />,
disabled: !doc.abilities.versions_list,
callback: () => {
selectHistoryModal.open();
@@ -158,7 +158,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
{
label: t('Remove emoji'),
icon: <RemoveEmojiSVG width={24} height={24} />,
icon: <RemoveEmojiSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '');
},
@@ -167,7 +167,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
{
label: t('Add emoji'),
icon: <AddEmojiSVG width={24} height={24} />,
icon: <AddEmojiSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '📄');
},
@@ -176,12 +176,12 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
{
label: t('Copy link'),
icon: <AddLinkSVG width={24} height={24} />,
icon: <AddLinkSVG width={24} height={24} aria-hidden="true" />,
callback: copyDocLink,
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: <MarkdownCopySVG width={24} height={24} />,
icon: <MarkdownCopySVG width={24} height={24} aria-hidden="true" />,
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
@@ -189,7 +189,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
{
label: t('Duplicate'),
icon: <ContentCopySVG width={24} height={24} />,
icon: <ContentCopySVG width={24} height={24} aria-hidden="true" />,
disabled: !doc.abilities.duplicate,
callback: () => {
duplicateDoc({
@@ -202,7 +202,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
{
label: isChild ? t('Delete sub-document') : t('Delete document'),
icon: <DeleteSVG width={24} height={24} />,
icon: <DeleteSVG width={24} height={24} aria-hidden="true" />,
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
@@ -1,4 +1,9 @@
import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core';
import {
DndContext,
DragOverlay,
Modifier,
UniqueIdentifier,
} from '@dnd-kit/core';
import { getEventCoordinates } from '@dnd-kit/utilities';
import { useModal } from '@gouvfr-lasuite/cunningham-react';
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
@@ -107,6 +112,59 @@ export const DraggableDocGridContentList = ({
const { t } = useTranslation();
const dndAccessibility = useMemo(
() => ({
screenReaderInstructions: {
draggable: t(
'To pick up a draggable item, press space or enter. While dragging, use the arrow keys to move the item. Press space or enter again to drop the item in its new position, or press escape to cancel.',
),
},
announcements: {
onDragStart({ active }: { active: { id: UniqueIdentifier } }) {
return t('Picked up document {{id}}.', { id: active.id });
},
onDragOver({
active,
over,
}: {
active: { id: UniqueIdentifier };
over: { id: UniqueIdentifier } | null;
}) {
if (over) {
return t('Document {{activeId}} is over document {{overId}}.', {
activeId: active.id,
overId: over.id,
});
}
return t('Document {{id}} is no longer over a droppable area.', {
id: active.id,
});
},
onDragEnd({
active,
over,
}: {
active: { id: UniqueIdentifier };
over: { id: UniqueIdentifier } | null;
}) {
if (over) {
return t(
'Document {{activeId}} was dropped over document {{overId}}.',
{ activeId: active.id, overId: over.id },
);
}
return t('Document {{id}} was dropped.', { id: active.id });
},
onDragCancel({ active }: { active: { id: UniqueIdentifier } }) {
return t('Dragging was cancelled. Document {{id}} was dropped.', {
id: active.id,
});
},
},
}),
[t],
);
const overlayText = useMemo(() => {
if (!canDrag) {
return t('You must be the owner to move the document');
@@ -147,6 +205,7 @@ export const DraggableDocGridContentList = ({
modifiers={[snapToTopLeft]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
accessibility={dndAccessibility}
>
{docs.map((doc) => (
<DraggableDocGridItem
@@ -140,36 +140,30 @@ export const DocsGrid = ({
$overflow="auto"
$padding={{ vertical: 'sm', horizontal: isDesktop ? 'md' : 'xs' }}
>
<Box role="grid" aria-label={t('Documents grid')}>
<Box role="rowgroup">
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
$gap="10px"
data-testid="docs-grid-header"
role="row"
>
<Box $flex={flexLeft} $padding="3xs" role="columnheader">
<Text $size="xs" $variation="secondary" $weight="500">
{t('Name')}
<Box aria-label={t('Documents grid')}>
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
$gap="10px"
data-testid="docs-grid-header"
aria-hidden="true"
>
<Box $flex={flexLeft} $padding="3xs">
<Text $size="xs" $variation="secondary" $weight="500">
{t('Name')}
</Text>
</Box>
{isDesktop && (
<Box $flex={flexRight} $padding={{ vertical: '3xs' }}>
<Text $size="xs" $weight="500" $variation="secondary">
{DocDefaultFilter.TRASHBIN === target
? t('Days remaining')
: t('Updated at')}
</Text>
</Box>
{isDesktop && (
<Box
$flex={flexRight}
$padding={{ vertical: '3xs' }}
role="columnheader"
>
<Text $size="xs" $weight="500" $variation="secondary">
{DocDefaultFilter.TRASHBIN === target
? t('Days remaining')
: t('Updated at')}
</Text>
</Box>
)}
</Box>
)}
</Box>
<Box role="rowgroup">
<Box role="list">
<DocGridContentList docs={docs} />
</Box>
</Box>
@@ -72,9 +72,9 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: doc.is_favorite ? (
<KeepOffSVG width={24} height={24} />
<KeepOffSVG width={24} height={24} aria-hidden="true" />
) : (
<KeepSVG width={24} height={24} />
<KeepSVG width={24} height={24} aria-hidden="true" />
),
callback: () => {
if (doc.is_favorite) {
@@ -88,7 +88,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
},
{
label: t('Share'),
icon: <GroupSVG width={24} height={24} />,
icon: <GroupSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
shareModal.open();
},
@@ -97,7 +97,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
},
{
label: t('Move into a doc'),
icon: <DocMoveInSVG width={24} height={24} />,
icon: <DocMoveInSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
importModal.open();
},
@@ -106,7 +106,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
},
{
label: t('Duplicate'),
icon: <ContentCopySVG width={24} height={24} />,
icon: <ContentCopySVG width={24} height={24} aria-hidden="true" />,
disabled: !doc.abilities.duplicate,
callback: () => {
duplicateDoc({
@@ -119,7 +119,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
},
{
label: t('Delete'),
icon: <DeleteSVG width={24} height={24} />,
icon: <DeleteSVG width={24} height={24} aria-hidden="true" />,
callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`,
@@ -32,6 +32,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const { isDesktop } = useResponsiveStore();
const { flexLeft, flexRight } = useResponsiveDocGrid();
const { spacingsTokens } = useCunninghamTheme();
const dateToDisplay = useDateToDisplay(doc, isInTrashbin);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -46,7 +47,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
$direction="row"
$width="100%"
$align="center"
role="row"
role="listitem"
$gap="20px"
$padding={{ vertical: '4xs', horizontal: isDesktop ? 'base' : 'xs' }}
$css={css`
@@ -65,7 +66,6 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
>
<Box
$flex={flexLeft}
role="gridcell"
$css={css`
align-items: center;
min-width: 0;
@@ -90,9 +90,15 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
$align="center"
$justify={isDesktop ? 'space-between' : 'flex-end'}
$gap="32px"
role="gridcell"
>
<StyledLink href={`/docs/${doc.id}`} tabIndex={-1}>
<StyledLink
href={`/docs/${doc.id}`}
tabIndex={-1}
aria-label={t('{{title}}, updated {{date}}', {
title: doc.title || untitledDocument,
date: dateToDisplay,
})}
>
<DocsGridItemDate
doc={doc}
isDesktop={isDesktop}
@@ -196,23 +202,11 @@ const IconPublic = ({ isPublic }: { isPublic: boolean }) => {
);
};
export const DocsGridItemDate = ({
doc,
isDesktop,
isInTrashbin,
}: {
doc: Doc;
isDesktop: boolean;
isInTrashbin: boolean;
}) => {
const useDateToDisplay = (doc: Doc, isInTrashbin: boolean) => {
const { data: config } = useConfig();
const { t } = useTranslation();
const { relativeDate, calculateDaysLeft } = useDate();
if (!isDesktop) {
return null;
}
let dateToDisplay = relativeDate(doc.updated_at);
if (isInTrashbin && config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) {
@@ -224,6 +218,24 @@ export const DocsGridItemDate = ({
dateToDisplay = `${daysLeft} ${t('days', { count: daysLeft })}`;
}
return dateToDisplay;
};
export const DocsGridItemDate = ({
doc,
isDesktop,
isInTrashbin,
}: {
doc: Doc;
isDesktop: boolean;
isInTrashbin: boolean;
}) => {
const dateToDisplay = useDateToDisplay(doc, isInTrashbin);
if (!isDesktop) {
return null;
}
return (
<Text
$size="xs"
@@ -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}
@@ -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