Compare commits

...

10 Commits

Author SHA1 Message Date
Cyril 46000bed33 ️(frontend) add contextual browser tab titles for docs routes
Each page sets its own tab title instead of generic "Docs"
2026-03-26 09:21:14 +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
15 changed files with 168 additions and 63 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) add contextual browser tab titles for docs routes #2120
### 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)
@@ -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,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Role } from '../types';
import { DocDefaultFilter, Role } from '../types';
export const useTrans = () => {
const { t } = useTranslation();
@@ -12,11 +12,22 @@ export const useTrans = () => {
[Role.OWNER]: t('Owner'),
};
const translatedFilters = {
[DocDefaultFilter.ALL_DOCS]: t('All docs'),
[DocDefaultFilter.MY_DOCS]: t('My docs'),
[DocDefaultFilter.SHARED_WITH_ME]: t('Shared with me'),
[DocDefaultFilter.TRASHBIN]: t('Trashbin'),
};
return {
transRole: (role: Role) => {
return translatedRoles[role];
},
transFilter: (filter: DocDefaultFilter) => {
return translatedFilters[filter];
},
untitledDocument: t('Untitled document'),
translatedRoles,
translatedFilters,
};
};
@@ -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}`,
@@ -1,16 +1,35 @@
import Head from 'next/head';
import { useSearchParams } from 'next/navigation';
import type { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { DocDefaultFilter } from '@/docs/doc-management';
import { DocDefaultFilter, useTrans } from '@/docs/doc-management';
import { DocsGrid } from '@/docs/docs-grid';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const { transFilter } = useTrans();
const searchParams = useSearchParams();
const target = searchParams.get('target');
const target =
(searchParams.get('target') as DocDefaultFilter) ??
DocDefaultFilter.ALL_DOCS;
const pageTitle = transFilter(target);
return <DocsGrid target={target as DocDefaultFilter} />;
return (
<>
<Head>
<title>{`${pageTitle} - ${t('Docs')}`}</title>
<meta
property="og:title"
content={`${pageTitle} - ${t('Docs')}`}
key="title"
/>
</Head>
<DocsGrid target={target} />
</>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
@@ -3,6 +3,7 @@ import Head from 'next/head';
import { useSearchParams } from 'next/navigation';
import { useRouter } from 'next/router';
import { ReactElement, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Loading } from '@/components';
import { LOGIN_URL, setAuthUrl, useAuth } from '@/features/auth';
@@ -17,6 +18,7 @@ import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const { setIsSkeletonVisible } = useSkeletonStore();
const router = useRouter();
const searchParams = useSearchParams();
@@ -96,7 +98,19 @@ const Page: NextPageWithLayout = () => {
updateDocLinkAsync,
]);
return <Loading />;
return (
<>
<Head>
<title>{`${t('New document')} - ${t('Docs')}`}</title>
<meta
property="og:title"
content={`${t('New document')} - ${t('Docs')}`}
key="title"
/>
</Head>
<Loading />
</>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
@@ -1,5 +1,7 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Loading } from '@/components';
import { useAuth } from '@/features/auth';
@@ -7,6 +9,7 @@ import { HomeContent } from '@/features/home';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const { authenticated } = useAuth();
const { replace } = useRouter();
@@ -25,7 +28,19 @@ const Page: NextPageWithLayout = () => {
return <Loading $height="100vh" $width="100vw" />;
}
return <HomeContent />;
return (
<>
<Head>
<title>{`${t('Home')} - ${t('Docs')}`}</title>
<meta
property="og:title"
content={`${t('Home')} - ${t('Docs')}`}
key="title"
/>
</Head>
<HomeContent />
</>
);
};
export default Page;
@@ -1,4 +1,5 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import Head from 'next/head';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -18,25 +19,35 @@ const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<Box $align="center" $margin="auto" $height="70vh" $gap="2rem">
<Icon404 aria-label="Image 404" role="img" />
<>
<Head>
<title>{`${t('Offline')} - ${t('Docs')}`}</title>
<meta
property="og:title"
content={`${t('Offline')} - ${t('Docs')}`}
key="title"
/>
</Head>
<Box $align="center" $margin="auto" $height="70vh" $gap="2rem">
<Icon404 aria-label="Image 404" role="img" />
<Text $size="h2" $weight="700">
{t('Offline ?!')}
</Text>
<Text $size="h2" $weight="700">
{t('Offline ?!')}
</Text>
<Text as="p" $textAlign="center" $maxWidth="400px" $size="m">
{t("Can't load this page, please check your internet connection.")}
</Text>
<Text as="p" $textAlign="center" $maxWidth="400px" $size="m">
{t("Can't load this page, please check your internet connection.")}
</Text>
<Box $margin={{ top: 'large' }}>
<StyledLink href="/">
<StyledButton icon={<Icon iconName="house" $color="white" />}>
{t('Home')}
</StyledButton>
</StyledLink>
<Box $margin={{ top: 'large' }}>
<StyledLink href="/">
<StyledButton icon={<Icon iconName="house" $color="white" />}>
{t('Home')}
</StyledButton>
</StyledLink>
</Box>
</Box>
</Box>
</>
);
};
@@ -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