Compare commits

..

1 Commits

Author SHA1 Message Date
Anthony LC 2cbd43caae 🔧(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:14:27 +01:00
22 changed files with 481 additions and 673 deletions
-3
View File
@@ -15,13 +15,10 @@ and this project adheres to
- 💄(frontend) improve comments highlights #1961 - 💄(frontend) improve comments highlights #1961
- ♿️(frontend) improve BoxButton a11y and native button semantics #2103 - ♿️(frontend) improve BoxButton a11y and native button semantics #2103
- ♿️(frontend) improve language picker accessibility #2069 - ♿️(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
### Fixed ### Fixed
- 🐛(y-provider) destroy Y.Doc instances after each convert request #2129 - 🐛(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 ## [v4.8.3] - 2026-03-23
+13 -15
View File
@@ -674,8 +674,17 @@ class DocumentViewSet(
return drf.response.Response(serializer.data) return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer): def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object.""" """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 # Remove file from validated_data as it's not a model field
# Process it if present # Process it if present
uploaded_file = serializer.validated_data.pop("file", None) uploaded_file = serializer.validated_data.pop("file", None)
@@ -693,25 +702,15 @@ class DocumentViewSet(
) )
serializer.validated_data["content"] = converted_content serializer.validated_data["content"] = converted_content
serializer.validated_data["title"] = uploaded_file.name serializer.validated_data["title"] = uploaded_file.name
logger.info("conversion ended successfully")
except ConversionError as err: except ConversionError as err:
logger.error("could not convert file content with error: %s", err)
raise drf.exceptions.ValidationError( raise drf.exceptions.ValidationError(
{"file": ["Could not convert file content"]} {"file": ["Could not convert file content"]}
) from err ) from err
with transaction.atomic(): obj = models.Document.add_root(
# locks the table to ensure safe concurrent access creator=self.request.user,
with connection.cursor() as cursor: **serializer.validated_data,
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 serializer.instance = obj
models.DocumentAccess.objects.create( models.DocumentAccess.objects.create(
document=obj, document=obj,
@@ -828,7 +827,6 @@ class DocumentViewSet(
queryset = self.queryset.filter(path_list) queryset = self.queryset.filter(path_list)
queryset = queryset.filter(id__in=favorite_documents_ids) 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_user_roles(user)
queryset = queryset.annotate( queryset = queryset.annotate(
is_favorite=db.Value(True, output_field=db.BooleanField()) is_favorite=db.Value(True, output_field=db.BooleanField())
+11 -10
View File
@@ -267,16 +267,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
if settings.USER_ONBOARDING_SANDBOX_DOCUMENT: if settings.USER_ONBOARDING_SANDBOX_DOCUMENT:
# transaction.atomic is used in a context manager to avoid a transaction if # transaction.atomic is used in a context manager to avoid a transaction if
# the settings USER_ONBOARDING_SANDBOX_DOCUMENT is unused # 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(): with transaction.atomic():
# locks the table to ensure safe concurrent access # locks the table to ensure safe concurrent access
with connection.cursor() as cursor: with connection.cursor() as cursor:
@@ -284,6 +274,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
f'LOCK TABLE "{Document._meta.db_table}" ' # noqa: SLF001 f'LOCK TABLE "{Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;" "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( sandbox_document = Document.add_root(
title=template_document.title, title=template_document.title,
content=template_document.content, content=template_document.content,
@@ -45,8 +45,6 @@ class Converter:
def convert(self, data, content_type, accept): def convert(self, data, content_type, accept):
"""Convert input into other formats using external microservices.""" """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: if content_type == mime_types.DOCX and accept == mime_types.YJS:
blocknote_data = self.docspec.convert( blocknote_data = self.docspec.convert(
data, mime_types.DOCX, mime_types.BLOCKNOTE data, mime_types.DOCX, mime_types.BLOCKNOTE
@@ -114,29 +114,3 @@ def test_api_document_favorite_list_with_favorite_children():
assert content[0]["id"] == str(children[0].id) assert content[0]["id"] == str(children[0].id)
assert content[1]["id"] == str(children[1].id) assert content[1]["id"] == str(children[1].id)
assert content[2]["id"] == str(access.document.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)
-15
View File
@@ -1,15 +0,0 @@
PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
CUSTOM_SIGN_IN=false
TEST_INSTANCE_ONLY=false
SIGN_IN_EL_TRIGGER=Start Writing
FIRST_NAME=E2E
SIGN_IN_USERNAME_CHROMIUM=user.test@chromium.test
SIGN_IN_USERNAME_WEBKIT=user.test@webkit.com
SIGN_IN_USERNAME_FIREFOX=user.test@firefox.com
# To test server to server API calls
SERVER_TO_SERVER_API_TOKENS='server-api-token'
SUB_CHROMIUM=user.test@chromium.test
SUB_WEBKIT=user.test@webkit.com
SUB_FIREFOX=user.test@firefox.com
-22
View File
@@ -1,22 +0,0 @@
PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
TEST_INSTANCE_ONLY=false
CUSTOM_SIGN_IN=false
SIGN_IN_EL_TRIGGER=Start Writing
FIRST_NAME=E2E
SIGN_IN_USERNAME_CHROMIUM=user.test@chromium.test
SIGN_IN_USERNAME_WEBKIT=user.test@webkit.com
SIGN_IN_USERNAME_FIREFOX=user.test@firefox.com
# Used only on instance with custom sign in
SIGN_IN_EL_USERNAME_INPUT=
SIGN_IN_EL_USERNAME_VALIDATION=
SIGN_IN_EL_PASSWORD_INPUT=
SIGN_IN_PASSWORD_CHROMIUM=
SIGN_IN_PASSWORD_WEBKIT=
SIGN_IN_PASSWORD_FIREFOX=
# To test server to server API calls
SERVER_TO_SERVER_API_TOKENS='server-api-token'
SUB_CHROMIUM=user.test@chromium.test
SUB_WEBKIT=user.test@webkit.com
SUB_FIREFOX=user.test@firefox.com
-1
View File
@@ -5,4 +5,3 @@ blob-report/
playwright/.auth/ playwright/.auth/
playwright/.cache/ playwright/.cache/
screenshots/ screenshots/
.env.local
@@ -1,6 +1,6 @@
import { FullConfig, FullProject, chromium, expect } from '@playwright/test'; import { FullConfig, FullProject, chromium, expect } from '@playwright/test';
import { SignIn } from './utils-signin'; import { keyCloakSignIn } from './utils-common';
const saveStorageState = async ( const saveStorageState = async (
browserConfig: FullProject<unknown, unknown>, browserConfig: FullProject<unknown, unknown>,
@@ -22,7 +22,7 @@ const saveStorageState = async (
await page.content(); await page.content();
await expect(page.getByText('Docs').first()).toBeVisible(); await expect(page.getByText('Docs').first()).toBeVisible();
await SignIn(page, browserName); await keyCloakSignIn(page, browserName);
await expect( await expect(
page.locator('header').first().getByRole('button', { page.locator('header').first().getByRole('button', {
@@ -4,166 +4,160 @@ import { expect, test } from '@playwright/test';
import { CONFIG, createDoc, overrideConfig } from './utils-common'; import { CONFIG, createDoc, overrideConfig } from './utils-common';
if (process.env.TEST_INSTANCE_ONLY !== 'true') { test.describe('Config', () => {
test.describe('Config', () => { test('it checks that sentry is trying to init from config endpoint', async ({
test('it checks that sentry is trying to init from config endpoint', async ({ page,
page, }) => {
}) => { await overrideConfig(page, {
await overrideConfig(page, { SENTRY_DSN: 'https://sentry.io/123',
SENTRY_DSN: 'https://sentry.io/123',
});
const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
const consoleMessage = page.waitForEvent('console', {
timeout: 5000,
predicate: (msg) => msg.text().includes(invalidMsg),
});
await page.goto('/');
expect((await consoleMessage).text()).toContain(invalidMsg);
}); });
test('it checks that media server is configured from config endpoint', async ({ const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
page, const consoleMessage = page.waitForEvent('console', {
browserName, timeout: 5000,
}) => { predicate: (msg) => msg.text().includes(invalidMsg),
await page.goto('/');
await createDoc(page, 'doc-media', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('Anything');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
}); });
test('it checks that collaboration server is configured from config endpoint', async ({ await page.goto('/');
page,
}) => {
await page.goto('/');
void page expect((await consoleMessage).text()).toContain(invalidMsg);
.getByRole('button', {
name: 'New doc',
})
.click();
const webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:4444/collaboration/ws/');
});
expect(webSocket.url()).toContain(
'ws://localhost:4444/collaboration/ws/',
);
});
test('it checks that Crisp is trying to init from config endpoint', async ({
page,
}) => {
await overrideConfig(page, {
CRISP_WEBSITE_ID: '1234',
});
await page.goto('/');
await expect(
page.locator('#crisp-chatbox').getByText('Invalid website'),
).toBeVisible();
});
test('it checks FRONTEND_CSS_URL config', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_CSS_URL: 'http://localhost:123465/css/style.css',
});
await page.goto('/');
await expect(
page
.locator('head link[href="http://localhost:123465/css/style.css"]')
.first(),
).toBeAttached();
});
test('it checks FRONTEND_JS_URL config', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_JS_URL: 'http://localhost:123465/js/script.js',
});
await page.goto('/');
await expect(
page
.locator('script[src="http://localhost:123465/js/script.js"]')
.first(),
).toBeAttached();
});
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const json = (await response.json()) as typeof CONFIG;
expect(json).toStrictEqual(CONFIG);
});
}); });
test.describe('Config: Not logged', () => { test('it checks that media server is configured from config endpoint', async ({
test.use({ storageState: { cookies: [], origins: [] } }); page,
browserName,
}) => {
await page.goto('/');
test('it checks that theme is configured from config endpoint', async ({ await createDoc(page, 'doc-media', browserName, 1);
page,
}) => {
await page.goto('/');
await expect( const fileChooserPromise = page.waitForEvent('filechooser');
page.getByText('Collaborative writing, Simplified.'),
).toHaveCSS('font-family', /Roboto/i, {
timeout: 10000,
});
await overrideConfig(page, { await page.locator('.bn-block-outer').last().fill('Anything');
FRONTEND_THEME: 'dsfr', await page.locator('.bn-block-outer').last().fill('/');
}); await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
await page.goto('/'); const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
await expect( const image = page
page.getByText('Collaborative writing, Simplified.'), .locator('.--docs--editor-container img.bn-visual-media')
).toHaveCSS('font-family', /Marianne/i, { .first();
timeout: 10000,
}); await expect(image).toBeVisible();
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
test('it checks that collaboration server is configured from config endpoint', async ({
page,
}) => {
await page.goto('/');
void page
.getByRole('button', {
name: 'New doc',
})
.click();
const webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/collaboration/ws/');
});
expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({
page,
}) => {
await overrideConfig(page, {
CRISP_WEBSITE_ID: '1234',
});
await page.goto('/');
await expect(
page.locator('#crisp-chatbox').getByText('Invalid website'),
).toBeVisible();
});
test('it checks FRONTEND_CSS_URL config', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_CSS_URL: 'http://localhost:123465/css/style.css',
});
await page.goto('/');
await expect(
page
.locator('head link[href="http://localhost:123465/css/style.css"]')
.first(),
).toBeAttached();
});
test('it checks FRONTEND_JS_URL config', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_JS_URL: 'http://localhost:123465/js/script.js',
});
await page.goto('/');
await expect(
page
.locator('script[src="http://localhost:123465/js/script.js"]')
.first(),
).toBeAttached();
});
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const json = (await response.json()) as typeof CONFIG;
expect(json).toStrictEqual(CONFIG);
});
});
test.describe('Config: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
await page.goto('/');
await expect(
page.getByText('Collaborative writing, Simplified.'),
).toHaveCSS('font-family', /Roboto/i, {
timeout: 10000,
});
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
});
await page.goto('/');
await expect(
page.getByText('Collaborative writing, Simplified.'),
).toHaveCSS('font-family', /Marianne/i, {
timeout: 10000,
}); });
}); });
} });
@@ -13,295 +13,210 @@ import {
writeInEditor, writeInEditor,
} from './utils-editor'; } from './utils-editor';
if (process.env.TEST_INSTANCE_ONLY !== 'true') { test.beforeEach(async ({ page }) => {
test.describe('Doc AI feature', () => { await page.goto('/');
test.beforeEach(async ({ page }) => { });
await page.goto('/');
});
[ test.describe('Doc AI feature', () => {
{ [
AI_FEATURE_ENABLED: false, {
selector: 'Ask AI', AI_FEATURE_ENABLED: false,
}, selector: 'Ask AI',
{ },
AI_FEATURE_ENABLED: true, {
AI_FEATURE_BLOCKNOTE_ENABLED: false, AI_FEATURE_ENABLED: true,
selector: 'Ask AI', AI_FEATURE_BLOCKNOTE_ENABLED: false,
}, selector: 'Ask AI',
{ },
AI_FEATURE_ENABLED: true, {
AI_FEATURE_LEGACY_ENABLED: false, AI_FEATURE_ENABLED: true,
selector: 'AI', AI_FEATURE_LEGACY_ENABLED: false,
}, selector: 'AI',
].forEach((config) => { },
test(`it checks the AI feature flag from config endpoint: ${JSON.stringify(config)}`, async ({ ].forEach((config) => {
page, test(`it checks the AI feature flag from config endpoint: ${JSON.stringify(config)}`, async ({
browserName,
}) => {
await overrideConfig(page, config);
await page.goto('/');
await createDoc(page, 'doc-ai-feature', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText();
await expect(
page.locator('button[data-test="convertMarkdown"]'),
).toHaveCount(1);
await expect(
page.getByRole('button', { name: config.selector, exact: true }),
).toBeHidden();
});
});
test('it checks the AI feature and accepts changes', async ({
page, page,
browserName, browserName,
}) => { }) => {
await overrideConfig(page, { await overrideConfig(page, config);
AI_BOT: {
name: 'Albert AI',
color: '#8bc6ff',
},
});
await mockAIResponse(page);
await page.goto('/'); await page.goto('/');
await createDoc(page, 'doc-ai', browserName, 1); await createDoc(page, 'doc-ai-feature', browserName, 1);
await openSuggestionMenu({ page }); await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Ask AI').click(); await page.getByText('Anything').selectText();
await expect( await expect(
page.getByRole('option', { name: 'Continue Writing' }), page.locator('button[data-test="convertMarkdown"]'),
).toBeVisible(); ).toHaveCount(1);
await expect( await expect(
page.getByRole('option', { name: 'Summarize' }), page.getByRole('button', { name: config.selector, exact: true }),
).toBeVisible(); ).toBeHidden();
});
});
await page.keyboard.press('Escape'); test('it checks the AI feature and accepts changes', async ({
page,
const editor = await writeInEditor({ page, text: 'Hello World' }); browserName,
await editor.getByText('Hello World').selectText(); }) => {
await overrideConfig(page, {
// Check from toolbar AI_BOT: {
await page.getByRole('button', { name: 'Ask AI' }).click(); name: 'Albert AI',
color: '#8bc6ff',
await expect( },
page.getByRole('option', { name: 'Improve Writing' }),
).toBeVisible();
await expect(
page.getByRole('option', { name: 'Fix Spelling' }),
).toBeVisible();
await expect(
page.getByRole('option', { name: 'Translate' }),
).toBeVisible();
await page.getByRole('option', { name: 'Translate' }).click();
await page
.getByRole('textbox', { name: 'Ask anything...' })
.fill('Translate into french');
await page
.getByRole('textbox', { name: 'Ask anything...' })
.press('Enter');
await expect(editor.getByText('Albert AI')).toBeVisible();
await page
.locator('p.bn-mt-suggestion-menu-item-title')
.getByText('Accept')
.click();
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
// Check Suggestion menu
await page.locator('.bn-block-outer').last().fill('/');
await expect(page.getByText('Write with AI')).toBeVisible();
// Reload the page to check that the AI change is still there
await page.goto(page.url());
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
}); });
test('it reverts with the AI feature', async ({ page, browserName }) => { await mockAIResponse(page);
await overrideConfig(page, {
AI_BOT: {
name: 'Albert AI',
color: '#8bc6ff',
},
});
await mockAIResponse(page); await page.goto('/');
await page.goto('/'); await createDoc(page, 'doc-ai', browserName, 1);
await createDoc(page, 'doc-ai', browserName, 1); await openSuggestionMenu({ page });
await page.getByText('Ask AI').click();
await expect(
page.getByRole('option', { name: 'Continue Writing' }),
).toBeVisible();
await expect(page.getByRole('option', { name: 'Summarize' })).toBeVisible();
const editor = await writeInEditor({ page, text: 'Hello World' }); await page.keyboard.press('Escape');
await editor.getByText('Hello World').selectText();
// Check from toolbar const editor = await writeInEditor({ page, text: 'Hello World' });
await page.getByRole('button', { name: 'Ask AI' }).click(); await editor.getByText('Hello World').selectText();
await page.getByRole('option', { name: 'Translate' }).click(); // Check from toolbar
await page await page.getByRole('button', { name: 'Ask AI' }).click();
.getByRole('textbox', { name: 'Ask anything...' })
.fill('Translate into french');
await page
.getByRole('textbox', { name: 'Ask anything...' })
.press('Enter');
await expect(editor.getByText('Albert AI')).toBeVisible();
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
await page
.locator('p.bn-mt-suggestion-menu-item-title')
.getByText('Revert')
.click();
await expect(editor.getByText('Hello World')).toBeVisible(); await expect(
page.getByRole('option', { name: 'Improve Writing' }),
).toBeVisible();
await expect(
page.getByRole('option', { name: 'Fix Spelling' }),
).toBeVisible();
await expect(page.getByRole('option', { name: 'Translate' })).toBeVisible();
await page.getByRole('option', { name: 'Translate' }).click();
await page
.getByRole('textbox', { name: 'Ask anything...' })
.fill('Translate into french');
await page.getByRole('textbox', { name: 'Ask anything...' }).press('Enter');
await expect(editor.getByText('Albert AI')).toBeVisible();
await page
.locator('p.bn-mt-suggestion-menu-item-title')
.getByText('Accept')
.click();
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
// Check Suggestion menu
await page.locator('.bn-block-outer').last().fill('/');
await expect(page.getByText('Write with AI')).toBeVisible();
// Reload the page to check that the AI change is still there
await page.goto(page.url());
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
});
test('it reverts with the AI feature', async ({ page, browserName }) => {
await overrideConfig(page, {
AI_BOT: {
name: 'Albert AI',
color: '#8bc6ff',
},
}); });
test('it checks the AI buttons feature legacy', async ({ await mockAIResponse(page);
page,
browserName,
}) => {
await page.route(/.*\/ai-translate\//, async (route) => {
const request = route.request();
if (request.method().includes('POST')) {
await route.fulfill({
json: {
answer: 'Hallo Welt',
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-ai', browserName, 1); await page.goto('/');
await page.locator('.bn-block-outer').last().fill('Hello World'); await createDoc(page, 'doc-ai', browserName, 1);
const editor = page.locator('.ProseMirror'); const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello').selectText(); await editor.getByText('Hello World').selectText();
await page.getByRole('button', { name: 'AI', exact: true }).click(); // Check from toolbar
await page.getByRole('button', { name: 'Ask AI' }).click();
await expect( await page.getByRole('option', { name: 'Translate' }).click();
page.getByRole('menuitem', { name: 'Use as prompt' }), await page
).toBeVisible(); .getByRole('textbox', { name: 'Ask anything...' })
await expect( .fill('Translate into french');
page.getByRole('menuitem', { name: 'Rephrase' }), await page.getByRole('textbox', { name: 'Ask anything...' }).press('Enter');
).toBeVisible(); await expect(editor.getByText('Albert AI')).toBeVisible();
await expect( await expect(editor.getByText('Bonjour le monde')).toBeVisible();
page.getByRole('menuitem', { name: 'Summarize' }), await page
).toBeVisible(); .locator('p.bn-mt-suggestion-menu-item-title')
await expect( .getByText('Revert')
page.getByRole('menuitem', { name: 'Correct' }), .click();
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Language' }).hover(); await expect(editor.getByText('Hello World')).toBeVisible();
await expect( });
page.getByRole('menuitem', { name: 'English', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'German', exact: true }).click(); test('it checks the AI buttons feature legacy', async ({
page,
await expect(editor.getByText('Hallo Welt')).toBeVisible(); browserName,
}); }) => {
await page.route(/.*\/ai-translate\//, async (route) => {
[ const request = route.request();
{ ai_transform: false, ai_translate: false }, if (request.method().includes('POST')) {
{ ai_transform: true, ai_translate: false }, await route.fulfill({
{ ai_transform: false, ai_translate: true }, json: {
].forEach(({ ai_transform, ai_translate }) => { answer: 'Hallo Welt',
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
page,
browserName,
}) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
ai_transform,
ai_translate,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
}, },
link_reach: 'restricted',
link_role: 'editor',
created_at: '2021-09-01T09:00:00Z',
title: '',
}); });
} else {
const [randomDoc] = await createDoc( await route.continue();
page, }
'doc-editor-ai',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
if (!ai_transform && !ai_translate) {
await expect(
page.getByRole('button', { name: 'AI', exact: true }),
).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI', exact: true }).click();
if (ai_transform) {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
}
if (ai_translate) {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
});
}); });
test(`it checks ai_proxy ability`, async ({ page, browserName }) => { await createDoc(page, 'doc-ai', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Rephrase' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Summarize' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Language' }).hover();
await expect(
page.getByRole('menuitem', { name: 'English', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'German', exact: true }).click();
await expect(editor.getByText('Hallo Welt')).toBeVisible();
});
[
{ ai_transform: false, ai_translate: false },
{ ai_transform: true, ai_translate: false },
{ ai_transform: false, ai_translate: true },
].forEach(({ ai_transform, ai_translate }) => {
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
page,
browserName,
}) => {
await mockedDocument(page, { await mockedDocument(page, {
accesses: [ accesses: [
{ {
@@ -316,7 +231,8 @@ if (process.env.TEST_INSTANCE_ONLY !== 'true') {
abilities: { abilities: {
destroy: true, // Means owner destroy: true, // Means owner
link_configuration: true, link_configuration: true,
ai_proxy: false, ai_transform,
ai_translate,
accesses_manage: true, accesses_manage: true,
accesses_view: true, accesses_view: true,
update: true, update: true,
@@ -331,7 +247,7 @@ if (process.env.TEST_INSTANCE_ONLY !== 'true') {
const [randomDoc] = await createDoc( const [randomDoc] = await createDoc(
page, page,
'doc-editor-ai-proxy', 'doc-editor-ai',
browserName, browserName,
1, 1,
); );
@@ -343,9 +259,81 @@ if (process.env.TEST_INSTANCE_ONLY !== 'true') {
const editor = page.locator('.ProseMirror'); const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText(); await editor.getByText('Hello').selectText();
await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden(); if (!ai_transform && !ai_translate) {
await page.locator('.bn-block-outer').last().fill('/'); await expect(
await expect(page.getByText('Write with AI')).toBeHidden(); page.getByRole('button', { name: 'AI', exact: true }),
).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI', exact: true }).click();
if (ai_transform) {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
}
if (ai_translate) {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
}); });
}); });
}
test(`it checks ai_proxy ability`, async ({ page, browserName }) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
ai_proxy: false,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
},
link_reach: 'restricted',
link_role: 'editor',
created_at: '2021-09-01T09:00:00Z',
title: '',
});
const [randomDoc] = await createDoc(
page,
'doc-editor-ai-proxy',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden();
await page.locator('.bn-block-outer').last().fill('/');
await expect(page.getByText('Write with AI')).toBeHidden();
});
});
@@ -58,14 +58,10 @@ test.describe('Doc Comments', () => {
await page.getByRole('button', { name: '👍' }).click(); await page.getByRole('button', { name: '👍' }).click();
await expect( await expect(
thread thread.getByRole('img', { name: `E2E ${browserName}` }).first(),
.getByRole('img', { name: `${process.env.FIRST_NAME} ${browserName}` })
.first(),
).toBeVisible(); ).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible(); await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect( await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
thread.getByText(`${process.env.FIRST_NAME} ${browserName}`).first(),
).toBeVisible();
await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1'); await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1');
const urlCommentDoc = page.url(); const urlCommentDoc = page.url();
@@ -89,7 +85,7 @@ test.describe('Doc Comments', () => {
otherThread.getByText('This is a comment').first(), otherThread.getByText('This is a comment').first(),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
otherThread.getByText(`${process.env.FIRST_NAME} ${browserName}`).first(), otherThread.getByText(`E2E ${browserName}`).first(),
).toBeVisible(); ).toBeVisible();
await expect(otherThread.locator('.bn-comment-reaction')).toHaveText('👍2'); await expect(otherThread.locator('.bn-comment-reaction')).toHaveText('👍2');
@@ -102,19 +98,13 @@ test.describe('Doc Comments', () => {
// We check that the second user can see the comment he just made // We check that the second user can see the comment he just made
await expect( await expect(
otherThread otherThread.getByRole('img', { name: `E2E ${otherBrowserName}` }).first(),
.getByRole('img', {
name: `${process.env.FIRST_NAME} ${otherBrowserName}`,
})
.first(),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
otherThread.getByText('This is a comment from the other user').first(), otherThread.getByText('This is a comment from the other user').first(),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
otherThread otherThread.getByText(`E2E ${otherBrowserName}`).first(),
.getByText(`${process.env.FIRST_NAME} ${otherBrowserName}`)
.first(),
).toBeVisible(); ).toBeVisible();
// We check that the first user can see the comment made by the second user in real time // We check that the first user can see the comment made by the second user in real time
@@ -122,7 +112,7 @@ test.describe('Doc Comments', () => {
thread.getByText('This is a comment from the other user').first(), thread.getByText('This is a comment from the other user').first(),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
thread.getByText(`${process.env.FIRST_NAME} ${otherBrowserName}`).first(), thread.getByText(`E2E ${otherBrowserName}`).first(),
).toBeVisible(); ).toBeVisible();
await cleanup(); await cleanup();
@@ -144,7 +134,7 @@ test.describe('Doc Comments', () => {
await expect(editor.getByText('Hello')).toHaveCSS( await expect(editor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
/color\(srgb\s+[\d\s.]+\s+\/\s+0\.4\)/, 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
await editor.first().click(); await editor.first().click();
@@ -211,7 +201,7 @@ test.describe('Doc Comments', () => {
await expect(editor.getByText('Hello')).toHaveCSS( await expect(editor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
/color\(srgb\s+[\d\s.]+\s+\/\s+0\.4\)/, 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
await editor.first().click(); await editor.first().click();
@@ -277,15 +267,11 @@ test.describe('Doc Comments', () => {
await expect(otherEditor.getByText('Hello')).toHaveCSS( await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
/color\(srgb\s+[\d\s.]+\s+\/\s+0\.4\)/, 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
// We change the role of the second user to reader // We change the role of the second user to reader
await updateRoleUser( await updateRoleUser(page, 'Reader', `user.test@${otherBrowserName}.test`);
page,
'Reader',
process.env[`SIGN_IN_USERNAME_${otherBrowserName.toUpperCase()}`] || '',
);
// With the reader role, the second user cannot see comments // With the reader role, the second user cannot see comments
await otherPage.reload(); await otherPage.reload();
@@ -310,15 +296,13 @@ test.describe('Doc Comments', () => {
// Anonymous user can see and add comments // Anonymous user can see and add comments
await otherPage.getByRole('button', { name: 'Logout' }).click(); await otherPage.getByRole('button', { name: 'Logout' }).click();
await otherPage.waitForTimeout(1500);
await otherPage.goto(urlCommentDoc); await otherPage.goto(urlCommentDoc);
await verifyDocName(otherPage, docTitle); await verifyDocName(otherPage, docTitle);
await expect(otherEditor.getByText('Hello')).toHaveCSS( await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
/color\(srgb\s+[\d\s.]+\s+\/\s+0\.4\)/, 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
await otherEditor.getByText('Hello').click(); await otherEditor.getByText('Hello').click();
await expect( await expect(
@@ -364,7 +348,7 @@ test.describe('Doc Comments', () => {
await expect(editor1.getByText('Document One')).toHaveCSS( await expect(editor1.getByText('Document One')).toHaveCSS(
'background-color', 'background-color',
/color\(srgb\s+[\d\s.]+\s+\/\s+0\.4\)/, 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
await editor1.getByText('Document One').click(); await editor1.getByText('Document One').click();
@@ -3,11 +3,11 @@ import { expect, test } from '@playwright/test';
import { import {
createDoc, createDoc,
goToGridDoc, goToGridDoc,
keyCloakSignIn,
randomName, randomName,
verifyDocName, verifyDocName,
} from './utils-common'; } from './utils-common';
import { connectOtherUserToDoc } from './utils-share'; import { connectOtherUserToDoc } from './utils-share';
import { SignIn } from './utils-signin';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -22,7 +22,8 @@ test.describe('Doc Create', () => {
{ timeout: 5000 }, { timeout: 5000 },
); );
await page.getByRole('button', { name: 'Back to homepage' }).click(); const header = page.locator('header').first();
await header.locator('h1').getByText('Docs').click();
const docsGrid = page.getByTestId('docs-grid'); const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible(); await expect(docsGrid).toBeVisible();
@@ -133,7 +134,7 @@ test.describe('Doc Create', () => {
withoutSignIn: true, withoutSignIn: true,
}); });
await SignIn(otherPage, otherBrowserName, false); await keyCloakSignIn(otherPage, otherBrowserName, false);
await verifyDocName(otherPage, 'From unlogged doc from url'); await verifyDocName(otherPage, 'From unlogged doc from url');
@@ -159,21 +160,22 @@ test.describe('Doc Create: Not logged', () => {
browserName, browserName,
request, request,
}) => { }) => {
const SERVER_TO_SERVER_API_TOKENS = 'server-api-token';
const markdown = `This is a normal text\n\n# And this is a large heading`; const markdown = `This is a normal text\n\n# And this is a large heading`;
const [title] = randomName('My server way doc create', browserName, 1); const [title] = randomName('My server way doc create', browserName, 1);
const data = { const data = {
title, title,
content: markdown, content: markdown,
sub: process.env[`SUB_${browserName.toUpperCase()}`], sub: `user.test@${browserName}.test`,
email: process.env[`SIGN_IN_USERNAME_${browserName.toUpperCase()}`], email: `user.test@${browserName}.test`,
}; };
const newDoc = await request.post( const newDoc = await request.post(
`${process.env.BASE_API_URL}/documents/create-for-owner/`, `http://localhost:8071/api/v1.0/documents/create-for-owner/`,
{ {
data, data,
headers: { headers: {
Authorization: `Bearer ${process.env.SERVER_TO_SERVER_API_TOKENS}`, Authorization: `Bearer ${SERVER_TO_SERVER_API_TOKENS}`,
format: 'json', format: 'json',
}, },
}, },
@@ -181,7 +183,7 @@ test.describe('Doc Create: Not logged', () => {
expect(newDoc.ok()).toBeTruthy(); expect(newDoc.ok()).toBeTruthy();
await SignIn(page, browserName); await keyCloakSignIn(page, browserName);
await goToGridDoc(page, { title }); await goToGridDoc(page, { title });
@@ -208,11 +208,8 @@ export const goToGridDoc = async (
page: Page, page: Page,
{ nthRow = 1, title }: GoToGridDocOptions = {}, { nthRow = 1, title }: GoToGridDocOptions = {},
) => { ) => {
if ( const header = page.locator('header').first();
await page.getByRole('button', { name: 'Back to homepage' }).isVisible() await header.locator('h1').getByText('Docs').click();
) {
await page.getByRole('button', { name: 'Back to homepage' }).click();
}
const docsGrid = page.getByTestId('docs-grid'); const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible(); await expect(docsGrid).toBeVisible();
@@ -3,9 +3,9 @@ import { Page, chromium, expect } from '@playwright/test';
import { import {
BrowserName, BrowserName,
getOtherBrowserName, getOtherBrowserName,
keyCloakSignIn,
verifyDocName, verifyDocName,
} from './utils-common'; } from './utils-common';
import { SignIn } from './utils-signin';
export type Role = 'Administrator' | 'Owner' | 'Editor' | 'Reader'; export type Role = 'Administrator' | 'Owner' | 'Editor' | 'Reader';
export type LinkReach = 'Private' | 'Connected' | 'Public'; export type LinkReach = 'Private' | 'Connected' | 'Public';
@@ -131,14 +131,14 @@ export const connectOtherUserToDoc = async ({
.getByRole('main', { name: 'Main content' }) .getByRole('main', { name: 'Main content' })
.getByLabel('Login'); .getByLabel('Login');
const loginFromHome = otherPage.getByRole('button', { const loginFromHome = otherPage.getByRole('button', {
name: process.env.SIGN_IN_EL_TRIGGER, name: 'Start Writing',
}); });
await loginFromApp.or(loginFromHome).first().click({ await loginFromApp.or(loginFromHome).first().click({
timeout: 15000, timeout: 15000,
}); });
await SignIn(otherPage, otherBrowserName, false); await keyCloakSignIn(otherPage, otherBrowserName, false);
} }
if (docTitle) { if (docTitle) {
await verifyDocName(otherPage, docTitle); await verifyDocName(otherPage, docTitle);
@@ -1,87 +0,0 @@
import { Page, expect } from '@playwright/test';
export const SignIn = async (
page: Page,
browserName: string,
fromHome = true,
) => {
if (process.env.CUSTOM_SIGN_IN === 'true') {
await customSignIn(page, browserName, fromHome);
return;
}
await keyCloakSignIn(page, browserName, fromHome);
};
export const customSignIn = async (
page: Page,
browserName: string,
fromHome = true,
) => {
// Check if already signed in (Silent login or session still valid)
if (
await page
.locator('header')
.first()
.getByRole('button', {
name: 'Logout',
})
.isVisible()
) {
return;
}
if (fromHome) {
await page
.getByRole('button', { name: process.env.SIGN_IN_EL_TRIGGER })
.first()
.click();
}
await page
.getByRole('textbox', { name: process.env.SIGN_IN_EL_USERNAME_INPUT })
.fill(process.env[`SIGN_IN_USERNAME_${browserName.toUpperCase()}`] || '');
if (process.env.SIGN_IN_EL_USERNAME_VALIDATION) {
await page
.getByRole('button', { name: process.env.SIGN_IN_EL_USERNAME_VALIDATION })
.first()
.click();
}
await page
.locator(
`input[name="${process.env.SIGN_IN_EL_PASSWORD_INPUT || 'password'}"]`,
)
.fill(process.env[`SIGN_IN_PASSWORD_${browserName.toUpperCase()}`] || '');
await page.click('button[type="submit"]', { force: true });
};
export const keyCloakSignIn = async (
page: Page,
browserName: string,
fromHome = true,
) => {
if (fromHome) {
await page
.getByRole('button', { name: process.env.SIGN_IN_EL_TRIGGER })
.first()
.click();
}
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
await expect(
page.locator('.login-pf #kc-header-wrapper').getByText('impress'),
).toBeVisible();
if (await page.getByLabel('Restart login').isVisible()) {
await page.getByLabel('Restart login').click();
}
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('button[type="submit"]', { force: true });
};
-1
View File
@@ -24,7 +24,6 @@
"dependencies": { "dependencies": {
"@types/pngjs": "6.0.5", "@types/pngjs": "6.0.5",
"convert-stream": "1.0.2", "convert-stream": "1.0.2",
"dotenv": "17.3.1",
"pdf-parse": "2.4.5", "pdf-parse": "2.4.5",
"pixelmatch": "7.1.0", "pixelmatch": "7.1.0",
"pngjs": "7.0.0" "pngjs": "7.0.0"
+2 -4
View File
@@ -1,10 +1,8 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config({ path: ['./.env.local', './.env'], quiet: true, debug: true }); const PORT = process.env.PORT || 3000;
const PORT = process.env.PORT; const baseURL = `http://localhost:${PORT}`;
const baseURL = process.env.BASE_URL;
/** /**
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
@@ -275,6 +275,7 @@ export const DropdownMenu = ({
<Box <Box
$theme="neutral" $theme="neutral"
$variation={isDisabled ? 'tertiary' : 'primary'} $variation={isDisabled ? 'tertiary' : 'primary'}
aria-hidden="true"
> >
{option.icon} {option.icon}
</Box> </Box>
@@ -118,13 +118,13 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const options: DropdownMenuOption[] = [ const options: DropdownMenuOption[] = [
{ {
label: t('Share'), label: t('Share'),
icon: <GroupSVG width={24} height={24} aria-hidden="true" />, icon: <GroupSVG width={24} height={24} />,
callback: modalShare.open, callback: modalShare.open,
show: isSmallMobile, show: isSmallMobile,
}, },
{ {
label: t('Export'), label: t('Export'),
icon: <DownloadSVG width={24} height={24} aria-hidden="true" />, icon: <DownloadSVG width={24} height={24} />,
callback: () => { callback: () => {
setIsModalExportOpen(true); setIsModalExportOpen(true);
}, },
@@ -133,9 +133,9 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
{ {
label: doc.is_favorite ? t('Unpin') : t('Pin'), label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: doc.is_favorite ? ( icon: doc.is_favorite ? (
<KeepOffSVG width={24} height={24} aria-hidden="true" /> <KeepOffSVG width={24} height={24} />
) : ( ) : (
<KeepSVG width={24} height={24} aria-hidden="true" /> <KeepSVG width={24} height={24} />
), ),
callback: () => { callback: () => {
if (doc.is_favorite) { if (doc.is_favorite) {
@@ -148,7 +148,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}, },
{ {
label: t('Version history'), label: t('Version history'),
icon: <HistorySVG width={24} height={24} aria-hidden="true" />, icon: <HistorySVG width={24} height={24} />,
disabled: !doc.abilities.versions_list, disabled: !doc.abilities.versions_list,
callback: () => { callback: () => {
selectHistoryModal.open(); selectHistoryModal.open();
@@ -158,7 +158,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}, },
{ {
label: t('Remove emoji'), label: t('Remove emoji'),
icon: <RemoveEmojiSVG width={24} height={24} aria-hidden="true" />, icon: <RemoveEmojiSVG width={24} height={24} />,
callback: () => { callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', ''); updateDocEmoji(doc.id, doc.title ?? '', '');
}, },
@@ -167,7 +167,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}, },
{ {
label: t('Add emoji'), label: t('Add emoji'),
icon: <AddEmojiSVG width={24} height={24} aria-hidden="true" />, icon: <AddEmojiSVG width={24} height={24} />,
callback: () => { callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '📄'); updateDocEmoji(doc.id, doc.title ?? '', '📄');
}, },
@@ -176,12 +176,12 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}, },
{ {
label: t('Copy link'), label: t('Copy link'),
icon: <AddLinkSVG width={24} height={24} aria-hidden="true" />, icon: <AddLinkSVG width={24} height={24} />,
callback: copyDocLink, callback: copyDocLink,
}, },
{ {
label: t('Copy as {{format}}', { format: 'Markdown' }), label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: <MarkdownCopySVG width={24} height={24} aria-hidden="true" />, icon: <MarkdownCopySVG width={24} height={24} />,
callback: () => { callback: () => {
void copyCurrentEditorToClipboard('markdown'); void copyCurrentEditorToClipboard('markdown');
}, },
@@ -189,7 +189,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}, },
{ {
label: t('Duplicate'), label: t('Duplicate'),
icon: <ContentCopySVG width={24} height={24} aria-hidden="true" />, icon: <ContentCopySVG width={24} height={24} />,
disabled: !doc.abilities.duplicate, disabled: !doc.abilities.duplicate,
callback: () => { callback: () => {
duplicateDoc({ duplicateDoc({
@@ -202,7 +202,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}, },
{ {
label: isChild ? t('Delete sub-document') : t('Delete document'), label: isChild ? t('Delete sub-document') : t('Delete document'),
icon: <DeleteSVG width={24} height={24} aria-hidden="true" />, icon: <DeleteSVG width={24} height={24} />,
disabled: !doc.abilities.destroy, disabled: !doc.abilities.destroy,
callback: () => { callback: () => {
setIsModalRemoveOpen(true); setIsModalRemoveOpen(true);
@@ -72,9 +72,9 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
{ {
label: doc.is_favorite ? t('Unpin') : t('Pin'), label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: doc.is_favorite ? ( icon: doc.is_favorite ? (
<KeepOffSVG width={24} height={24} aria-hidden="true" /> <KeepOffSVG width={24} height={24} />
) : ( ) : (
<KeepSVG width={24} height={24} aria-hidden="true" /> <KeepSVG width={24} height={24} />
), ),
callback: () => { callback: () => {
if (doc.is_favorite) { if (doc.is_favorite) {
@@ -88,7 +88,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
}, },
{ {
label: t('Share'), label: t('Share'),
icon: <GroupSVG width={24} height={24} aria-hidden="true" />, icon: <GroupSVG width={24} height={24} />,
callback: () => { callback: () => {
shareModal.open(); shareModal.open();
}, },
@@ -97,7 +97,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
}, },
{ {
label: t('Move into a doc'), label: t('Move into a doc'),
icon: <DocMoveInSVG width={24} height={24} aria-hidden="true" />, icon: <DocMoveInSVG width={24} height={24} />,
callback: () => { callback: () => {
importModal.open(); importModal.open();
}, },
@@ -106,7 +106,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
}, },
{ {
label: t('Duplicate'), label: t('Duplicate'),
icon: <ContentCopySVG width={24} height={24} aria-hidden="true" />, icon: <ContentCopySVG width={24} height={24} />,
disabled: !doc.abilities.duplicate, disabled: !doc.abilities.duplicate,
callback: () => { callback: () => {
duplicateDoc({ duplicateDoc({
@@ -119,7 +119,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
}, },
{ {
label: t('Delete'), label: t('Delete'),
icon: <DeleteSVG width={24} height={24} aria-hidden="true" />, icon: <DeleteSVG width={24} height={24} />,
callback: () => deleteModal.open(), callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy, disabled: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`, testId: `docs-grid-actions-remove-${doc.id}`,
+15 -13
View File
@@ -1769,11 +1769,18 @@
minimatch "^10.2.4" minimatch "^10.2.4"
"@eslint/config-helpers@^0.5.2": "@eslint/config-helpers@^0.5.2":
version "0.5.3" version "0.5.2"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.3.tgz#721fe6bbb90d74b0c80d6ff2428e5bbcb002becb" resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.2.tgz#314c7b03d02a371ad8c0a7f6821d5a8a8437ba9d"
integrity sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw== integrity sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==
dependencies: dependencies:
"@eslint/core" "^1.1.1" "@eslint/core" "^1.1.0"
"@eslint/core@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.0.tgz#51f5cd970e216fbdae6721ac84491f57f965836d"
integrity sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/core@^1.1.1": "@eslint/core@^1.1.1":
version "1.1.1" version "1.1.1"
@@ -10344,9 +10351,9 @@ eslint@10.0.3:
optionator "^0.9.3" optionator "^0.9.3"
espree@^11.1.1: espree@^11.1.1:
version "11.2.0" version "11.1.1"
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" resolved "https://registry.yarnpkg.com/espree/-/espree-11.1.1.tgz#866f6bc9ccccd6f28876b7a6463abb281b9cb847"
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== integrity sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==
dependencies: dependencies:
acorn "^8.16.0" acorn "^8.16.0"
acorn-jsx "^5.3.2" acorn-jsx "^5.3.2"
@@ -10722,12 +10729,7 @@ flat-cache@^6.1.20:
flatted "^3.3.3" flatted "^3.3.3"
hookified "^1.15.0" hookified "^1.15.0"
flatted@^3.2.9: flatted@^3.2.9, flatted@^3.3.3:
version "3.4.2"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
flatted@^3.3.3:
version "3.3.3" version "3.3.3"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==