Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46000bed33 | |||
| cbe6a67704 | |||
| f91223fe4a | |||
| 330096eb47 | |||
| ff995c6cd9 | |||
| 2e4a1b8ff9 | |||
| 004d637c8b | |||
| 8a0330a30f | |||
| 677392b89b | |||
| b8e1d12aea |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user