Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to
### Added

- ♿️(frontend) restore skip to content link after header redesign #2510
- ✨(frontend) warn the user when trying to upload a file size that exceeds the limit #2522

## [v5.4.1] - 2026-07-09

Expand Down
1 change: 1 addition & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3085,6 +3085,7 @@ def get(self, request):
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
"CONVERSION_FILE_MAX_SIZE",
"CONVERSION_UPLOAD_ENABLED",
"DOCUMENT_IMAGE_MAX_SIZE",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
Expand Down
1 change: 1 addition & 0 deletions src/backend/core/tests/test_api_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def test_api_config(is_authenticated):
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
"CONVERSION_FILE_MAX_SIZE": 20971520,
"CONVERSION_UPLOAD_ENABLED": False,
"DOCUMENT_IMAGE_MAX_SIZE": 10485760,
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
Expand Down
108 changes: 108 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-editor-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Page, expect, test } from '@playwright/test';

import { createDoc, overrideConfig } from './utils-common';
import { getEditor } from './utils-editor';

const dropFileInEditor = async (page: Page) => {
const dataTransfer = await page.evaluateHandle(() => {
const dt = new DataTransfer();
// 1MB + 1 byte, exceeds the 1MB limit set in overrideConfig.
const file = new File([new Uint8Array(1048577)], 'video.mp4', {
type: 'video/mp4',
});
dt.items.add(file);
return dt;
});

await page
.getByLabel('Document editor')
.dispatchEvent('drop', { dataTransfer });
};

const pasteFileInEditor = async (page: Page) => {
await page.getByLabel('Document editor').focus();

await page.getByLabel('Document editor').evaluate((el) => {
const dt = new DataTransfer();
const file = new File([new Uint8Array(1048577)], 'video.mp4', {
type: 'video/mp4',
});
dt.items.add(file);

const event = new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
el.dispatchEvent(event);
});
};

test.describe('Doc Editor - File Upload', () => {
test('dropping a file that is too large shows an error and does not leave a loading block', async ({
page,
browserName,
}) => {
await overrideConfig(page, { DOCUMENT_IMAGE_MAX_SIZE: 1048576 }); // Override the size limit to 1MB for the test.
await page.goto('/');
await createDoc(page, 'doc-upload-too-large', browserName, 1);
await getEditor({ page });

await dropFileInEditor(page);

await expect(
page.getByText('File size exceeds the maximum allowed size of 1MB.'),
).toBeVisible();

await expect(page.getByText('Loading...')).toBeHidden();
});

test('dismissing the error and dropping the same file again shows the error again', async ({
page,
browserName,
}) => {
await overrideConfig(page, { DOCUMENT_IMAGE_MAX_SIZE: 1048576 }); // Override the size limit to 1MB for the test.
await page.goto('/');
await createDoc(page, 'doc-upload-error-retry', browserName, 1);
await getEditor({ page });

await dropFileInEditor(page);
await expect(
page.getByText('File size exceeds the maximum allowed size of 1MB.'),
).toBeVisible();

// Dismiss the error
await page.locator('.--docs--text-errors').getByRole('button').click();
await expect(
page.getByText('File size exceeds the maximum allowed size of 1MB.'),
).toBeHidden();

// Drop the same file again
await dropFileInEditor(page);
await expect(
page.getByText('File size exceeds the maximum allowed size of 1MB.'),
).toBeVisible();
});

test('pasting a file that is too large shows an error and does not leave a loading block', async ({
page,
browserName,
}) => {
test.skip(
browserName === 'firefox',
'Firefox does not expose clipboardData.items on synthetic ClipboardEvents, making this untestable via dispatchEvent.',
);
await overrideConfig(page, { DOCUMENT_IMAGE_MAX_SIZE: 1048576 });
await page.goto('/');
await createDoc(page, 'doc-upload-paste-too-large', browserName, 1);
await getEditor({ page });

await pasteFileInEditor(page);

await expect(
page.getByText('File size exceeds the maximum allowed size of 1MB.'),
).toBeVisible();

await expect(page.getByText('Loading...')).toBeHidden();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const CONFIG = {
CONVERSION_UPLOAD_ENABLED: true,
CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'],
CONVERSION_FILE_MAX_SIZE: 20971520,
DOCUMENT_IMAGE_MAX_SIZE: 10485760,
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_JS_URL: null,
Expand Down
14 changes: 14 additions & 0 deletions src/frontend/apps/impress/src/api/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ describe('utils', () => {
expect(result.cause).toBeUndefined();
expect(result.data).toBeUndefined();
});

it('returns undefined causes when response body is not valid JSON (e.g. 413 from Nginx)', async () => {
const mockResponse = {
status: 413,
json: () =>
Promise.reject(new SyntaxError('Unexpected token < in JSON')),
} as unknown as Response;

const result = await errorCauses(mockResponse);

expect(result.status).toBe(413);
expect(result.cause).toBeUndefined();
expect(result.data).toBeUndefined();
});
});

describe('getCSRFToken', () => {
Expand Down
10 changes: 6 additions & 4 deletions src/frontend/apps/impress/src/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
* - `data`: The optional data passed in
*/
export const errorCauses = async (response: Response, data?: unknown) => {
const errorsBody = (await response.json()) as Record<
string,
string | string[]
> | null;
let errorsBody: Record<string, string | string[]> | null = null;
try {
errorsBody = await response.json();
} catch {
// response body is not JSON (e.g. HTML error page from Nginx)
}

const causes = errorsBody
? Object.entries(errorsBody)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface ConfigResponse {
CONVERSION_FILE_EXTENSIONS_ALLOWED: string[];
CONVERSION_FILE_MAX_SIZE: number;
CONVERSION_UPLOAD_ENABLED?: boolean;
DOCUMENT_IMAGE_MAX_SIZE?: number;
ENVIRONMENT: string;
FRONTEND_CSS_URL?: string;
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
? DEFAULT_LOCALE
: i18n.resolvedLanguage;

const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const { uploadFile, checkFileSize, errorAttachment, sizeErrorKey } =
useUploadFile(doc.id);
const conf = useConfig().data;
const { isFeatureFlagActivated } = useAnalytics();
const aiBlockNoteAllowed = !!(
Expand Down Expand Up @@ -206,6 +207,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}),
},
pasteHandler: ({ event, defaultPasteHandler }) => {
const files = Array.from(event.clipboardData?.files ?? []);
try {
files.forEach(checkFileSize);
} catch {
return;
}

// Get clipboard data
const blocknoteData = event.clipboardData?.getData('blocknote/html');

Expand Down Expand Up @@ -263,6 +271,41 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {

useUploadStatus(editor);

useEffect(() => {
const container = refEditorContainer.current;
if (!container) return;

const handleDrop = (event: DragEvent) => {
const files = Array.from(event.dataTransfer?.files ?? []);
try {
files.forEach(checkFileSize);
} catch {
event.stopPropagation();
event.preventDefault();
}
};

const handlePaste = (event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
try {
files.forEach(checkFileSize);
} catch {
event.stopPropagation();
event.preventDefault();
}
};

container.addEventListener('drop', handleDrop, true);
container.addEventListener('paste', handlePaste, true);
return () => {
container.removeEventListener('drop', handleDrop, true);
container.removeEventListener('paste', handlePaste, true);
};
}, [checkFileSize]);
Comment on lines +274 to +307

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems unnecessary to listen these events to display the error message about the upload size.
In my understanding it is to not see the "loading" part, but we should act when we get the error, not before, so either by updating or removing the block, but only when the error occurs.


Comment thread
coderabbitai[bot] marked this conversation as resolved.
useEffect(() => {
setEditor(editor);

Expand All @@ -279,7 +322,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
currentUserAvatarUrl={currentUserAvatarUrl}
/>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<Box
key={sizeErrorKey}
$margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}
>
<TextErrors
causes={errorAttachment.cause}
canClose
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { APIError } from '@/api';
import { AppWrapper } from '@/tests/utils';

import { useUploadFile } from '../useUploadFile';

vi.mock('@/core', () => ({
useConfig: () => ({
data: { DOCUMENT_IMAGE_MAX_SIZE: 1 * 1024 * 1024 }, // 1MB limit for test
}),
}));

describe('useUploadFile', () => {
// Fake file with a size slightly under the limit
const smallFile = new File(
[new ArrayBuffer(1 * 1024 * 1024 - 1)],
'big.png',
{
type: 'image/png',
},
);
// Fake file with a size slightly over the limit
const bigFile = new File([new ArrayBuffer(1 * 1024 * 1024 + 1)], 'big.png', {
type: 'image/png',
});

beforeEach(() => {
fetchMock.restore();
});

it("proceeds to upload when file doesn't exceed the size limit", async () => {
fetchMock.post(
'http://test.jest/api/v1.0/documents/doc-id/attachment-upload/',
{ body: { file: '/media/test.jpg' } },
);
const { result } = renderHook(() => useUploadFile('doc-id'), {
wrapper: AppWrapper,
});

await result.current.uploadFile(smallFile);
expect(fetchMock.calls()).toHaveLength(1);
});

it('throws an APIError before uploading when file exceeds the size limit', async () => {
const { result } = renderHook(() => useUploadFile('doc-id'), {
wrapper: AppWrapper,
});

await expect(result.current.uploadFile(bigFile)).rejects.toThrow(APIError);
expect(fetchMock.calls()).toHaveLength(0);
});

it('sets errorAttachment with a user-friendly message when file exceeds the size limit', async () => {
const { result } = renderHook(() => useUploadFile('doc-id'), {
wrapper: AppWrapper,
});

await result.current.uploadFile(bigFile).catch(() => {});

await waitFor(() => {
expect(result.current.isErrorAttachment).toBe(true);
});

expect(result.current.errorAttachment?.cause).toEqual([
'File size exceeds the maximum allowed size of 1MB.',
]);
});

it('exposes checkFileSize that does not throw for files within the limit', () => {
const { result } = renderHook(() => useUploadFile('doc-id'), {
wrapper: AppWrapper,
});

expect(() => result.current.checkFileSize(smallFile)).not.toThrow();
});

it('exposes checkFileSize that throws and sets errorAttachment for files over the limit', async () => {
const { result } = renderHook(() => useUploadFile('doc-id'), {
wrapper: AppWrapper,
});

expect(() => result.current.checkFileSize(bigFile)).toThrow(APIError);

await waitFor(() => {
expect(result.current.isErrorAttachment).toBe(true);
});

expect(result.current.errorAttachment?.cause).toEqual([
'File size exceeds the maximum allowed size of 1MB.',
]);
});
});
Loading