mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-13 21:27:53 +02:00
♻️(frontend) unify toolbox menu
Depend the part of the app, we had different implementations of the toolbox menu. This commit unifies the implementation and uses the same component for both the docs grid, the doc tree and the doc header. It will make it easier to maintain and add new features to the toolbox menu.
This commit is contained in:
@@ -19,6 +19,7 @@ and this project adheres to
|
||||
- ♿️(frontend) announce search loading state for screen readers #2526
|
||||
- ♻️(frontend) change favorite to star #2539
|
||||
- 🚚(frontend) add doc move to doc options #2555
|
||||
- ♻️(frontend) unified menu #2620
|
||||
- ♿(frontend) hide decorative emojis in document titles from SR #2527
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -273,18 +273,9 @@ if (process.env.IS_INSTANCE !== 'true') {
|
||||
title: '',
|
||||
});
|
||||
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-editor-ai',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await createDoc(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');
|
||||
const editor = await writeInEditor({ page, text: 'Hello World' });
|
||||
await editor.getByText('Hello').selectText();
|
||||
|
||||
if (!ai_transform && !ai_translate) {
|
||||
|
||||
@@ -269,7 +269,9 @@ test.describe('Doc Export', () => {
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Ouvrir les options du document' })
|
||||
.getByRole('button', {
|
||||
name: `Ouvrir les options du document: ${randomDocFrench}`,
|
||||
})
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Télécharger' }).click();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
clickInDocOptionMenu,
|
||||
createDoc,
|
||||
getGridRow,
|
||||
getOtherBrowserName,
|
||||
@@ -205,9 +206,7 @@ test.describe('Doc grid move', () => {
|
||||
await expect(docsGrid.getByText(titleDoc2)).toBeVisible();
|
||||
|
||||
const row = await getGridRow(page, titleDoc1);
|
||||
await row.getByRole('button', { name: /Open the menu of actions/ }).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
|
||||
await clickInDocOptionMenu(page, row, 'Move into a doc');
|
||||
|
||||
await expect(
|
||||
page
|
||||
@@ -295,9 +294,7 @@ test.describe('Doc grid move', () => {
|
||||
await expect(docsGrid.getByText(titleDoc2)).toBeVisible();
|
||||
|
||||
const row = await getGridRow(page, titleDoc1);
|
||||
await row.getByRole('button', { name: /Open the menu of actions/ }).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
|
||||
await clickInDocOptionMenu(page, row, 'Move into a doc');
|
||||
|
||||
await expect(
|
||||
page
|
||||
@@ -366,9 +363,7 @@ test.describe('Doc grid move', () => {
|
||||
|
||||
// The first user should now be able to move the doc
|
||||
await page.reload();
|
||||
await row.getByRole('button', { name: /Open the menu of actions/ }).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
|
||||
await clickInDocOptionMenu(page, row, 'Move into a doc');
|
||||
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
clickInDocOptionMenu,
|
||||
clickInEditorShareButton,
|
||||
createDoc,
|
||||
getGridRow,
|
||||
@@ -103,9 +104,7 @@ test.describe('Document grid item options', () => {
|
||||
|
||||
await expect(page.getByText(docTitle)).toBeVisible();
|
||||
const row = await getGridRow(page, docTitle);
|
||||
await row.getByRole('button', { name: /Open the menu of actions/ }).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||
await clickInDocOptionMenu(page, row, 'Share');
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog').getByText('Share the document'),
|
||||
@@ -121,8 +120,7 @@ test.describe('Document grid item options', () => {
|
||||
const row = await getGridRow(page, docTitle);
|
||||
|
||||
// Star
|
||||
await row.getByRole('button', { name: /Open the menu of actions/ }).click();
|
||||
await page.getByRole('menuitem', { name: 'Star' }).click();
|
||||
await clickInDocOptionMenu(page, row, 'Star');
|
||||
|
||||
// Check is starred
|
||||
await expect(row.getByText(/This document is starred/)).toBeVisible();
|
||||
@@ -133,8 +131,7 @@ test.describe('Document grid item options', () => {
|
||||
await expect(page.getByText(docTitle2)).toBeHidden();
|
||||
|
||||
// Unstar
|
||||
await row.getByRole('button', { name: /Open the menu of actions/ }).click();
|
||||
await page.getByText('Unstar').click();
|
||||
await clickInDocOptionMenu(page, row, 'Unstar');
|
||||
await expect(row).toBeHidden();
|
||||
|
||||
// Check is unstarred
|
||||
@@ -153,8 +150,7 @@ test.describe('Document grid item options', () => {
|
||||
await expect(page.getByText(docTitle)).toBeVisible();
|
||||
const row = await getGridRow(page, docTitle);
|
||||
|
||||
await row.getByRole('button', { name: /Open the menu of actions/ }).click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await clickInDocOptionMenu(page, row, 'Delete');
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
@@ -186,9 +182,7 @@ test.describe('Document grid item options', () => {
|
||||
).toBeVisible();
|
||||
|
||||
const row = await getGridRow(page, docTitle);
|
||||
await row.getByRole('button', { name: /Open the menu of actions/ }).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Leave' }).click();
|
||||
await clickInDocOptionMenu(page, row, 'Leave');
|
||||
|
||||
const modal = page.getByRole('dialog', {
|
||||
name: 'Confirmation to leave the document',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
clickInDocOptionMenu,
|
||||
clickInEditorMenu,
|
||||
clickInEditorShareButton,
|
||||
createDoc,
|
||||
getGridRow,
|
||||
goToGridDoc,
|
||||
@@ -527,30 +527,23 @@ test.describe('Doc Header', () => {
|
||||
await createDoc(page, `Star doc`, browserName);
|
||||
|
||||
// Star
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the document options' })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Star' }).click();
|
||||
await clickInEditorMenu(page, 'Star');
|
||||
await expect(page.getByText('This document is starred')).toBeVisible();
|
||||
|
||||
// UnStar
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the document options' })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Unstar' }).click();
|
||||
await clickInEditorMenu(page, 'Unstar');
|
||||
await expect(page.getByText('This document is starred')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it duplicates a document', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, `Duplicate doc`, browserName);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello Duplicated World');
|
||||
await writeInEditor({
|
||||
page,
|
||||
text: 'Hello Duplicated World',
|
||||
});
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await clickInEditorMenu(page, 'Duplicate');
|
||||
await expect(
|
||||
page.getByText('Document duplicated successfully!'),
|
||||
).toBeVisible();
|
||||
@@ -564,10 +557,9 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await expect(row.getByText(duplicateTitle)).toBeVisible();
|
||||
|
||||
await row.getByRole('button', { name: /Open the menu of actions/ }).click();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await clickInDocOptionMenu(page, row, 'Duplicate');
|
||||
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
|
||||
await page.getByText(duplicateDuplicateTitle).click();
|
||||
await verifyDocName(page, duplicateDuplicateTitle);
|
||||
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -586,6 +578,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
const duplicateTitle = 'Copy of ' + childTitle;
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
const currentUrl = page.url();
|
||||
|
||||
const child = docTree
|
||||
.getByRole('treeitem')
|
||||
@@ -593,12 +586,9 @@ test.describe('Doc Header', () => {
|
||||
.filter({
|
||||
hasText: childTitle,
|
||||
});
|
||||
|
||||
await child.hover();
|
||||
await child.getByRole('button', { name: /More options/ }).click();
|
||||
|
||||
const currentUrl = page.url();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await clickInDocOptionMenu(page, child, 'Duplicate');
|
||||
|
||||
await expect(page).not.toHaveURL(new RegExp(currentUrl));
|
||||
|
||||
@@ -609,73 +599,3 @@ test.describe('Doc Header', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Documents Header mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('it checks the copy link button is displayed', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false,
|
||||
link_configuration: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
accesses_manage: false,
|
||||
accesses_view: false,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Copy link' }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const shareModal = page.getByRole('dialog', {
|
||||
name: 'Share the document',
|
||||
});
|
||||
await expect(
|
||||
shareModal.getByRole('button', { name: 'Copy link' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks the close button on Share modal', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: true, // Means owner
|
||||
link_configuration: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await clickInEditorShareButton(page);
|
||||
|
||||
const shareModal = page.getByRole('dialog', {
|
||||
name: 'Share the document',
|
||||
});
|
||||
await expect(shareModal).toBeVisible();
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Share the document' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,6 +188,8 @@ test.describe('Document list members', () => {
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await newUserRoles.click();
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner' }),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
clickInDocOptionMenu,
|
||||
clickInEditorMenu,
|
||||
clickInGridMenu,
|
||||
createDoc,
|
||||
getGridRow,
|
||||
verifyDocName,
|
||||
@@ -34,18 +34,18 @@ test.describe('Doc Trashbin', () => {
|
||||
|
||||
// Delete the first document - Is not displayed
|
||||
const row1 = await getGridRow(page, title1);
|
||||
await clickInGridMenu(page, row1, 'Delete');
|
||||
await clickInDocOptionMenu(page, row1, 'Delete');
|
||||
await page.getByRole('button', { name: 'Delete document' }).click();
|
||||
await expect(row1.getByText(title1)).toBeHidden();
|
||||
|
||||
// Star the second document - Is displayed in the starred list
|
||||
const row2 = await getGridRow(page, title2);
|
||||
await clickInGridMenu(page, row2, 'Star');
|
||||
await clickInDocOptionMenu(page, row2, 'Star');
|
||||
await page.getByRole('link', { name: 'Starred', exact: true }).click();
|
||||
await expect(row2.getByText(title2)).toBeVisible();
|
||||
|
||||
// Delete the second document - It is not displayed in the starred list anymore
|
||||
await clickInGridMenu(page, row2, 'Delete');
|
||||
await clickInDocOptionMenu(page, row2, 'Delete');
|
||||
await page.getByRole('button', { name: 'Delete document' }).click();
|
||||
await expect(row2.getByText(title2)).toBeHidden();
|
||||
|
||||
@@ -86,7 +86,7 @@ test.describe('Doc Trashbin', () => {
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
await clickInGridMenu(page, row2, 'Restore');
|
||||
await clickInDocOptionMenu(page, row2, 'Restore');
|
||||
|
||||
await expect(row2.getByText(title2)).toBeHidden();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
clickInDocOptionMenu,
|
||||
createDoc,
|
||||
getOtherBrowserName,
|
||||
updateDocTitle,
|
||||
@@ -247,9 +248,7 @@ test.describe('Doc Tree', () => {
|
||||
hasText: docChild,
|
||||
});
|
||||
await child.hover();
|
||||
const menu = child.getByRole('button', { name: /More options/ });
|
||||
await menu.click();
|
||||
await page.getByText('Move to my docs').click();
|
||||
await clickInDocOptionMenu(page, child, 'Move to my docs');
|
||||
|
||||
await verifyDocName(page, docParent);
|
||||
|
||||
@@ -258,24 +257,17 @@ test.describe('Doc Tree', () => {
|
||||
});
|
||||
|
||||
test('Only owner can detaches a document', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-tree-detach', browserName, 1);
|
||||
const [docParent] = await createDoc(
|
||||
page,
|
||||
'doc-tree-detach',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const otherBrowserName = getOtherBrowserName(browserName);
|
||||
await addNewMember(page, 0, 'Owner', otherBrowserName);
|
||||
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
const currentEmail =
|
||||
process.env[`SIGN_IN_USERNAME_${browserName.toUpperCase()}`] || '';
|
||||
const currentUser = list.getByTestId(
|
||||
`doc-share-member-row-${currentEmail}`,
|
||||
);
|
||||
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
|
||||
await list.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Ok' }).click();
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
@@ -284,12 +276,6 @@ test.describe('Doc Tree', () => {
|
||||
'doc-tree-detach-child',
|
||||
);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByLabel('It is the card information about the document.')
|
||||
.getByText('Administrator ·'),
|
||||
).toBeVisible();
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||
await docTree.click();
|
||||
@@ -299,13 +285,48 @@ test.describe('Doc Tree', () => {
|
||||
.filter({
|
||||
hasText: docChild,
|
||||
});
|
||||
|
||||
await child.hover();
|
||||
const menu = child.getByRole('button', { name: /More options/ });
|
||||
const menu = child.getByRole('button', {
|
||||
name: /Open the document options/,
|
||||
});
|
||||
await menu.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Move to my docs' }),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await docTree.getByText(docParent).click();
|
||||
await verifyDocName(page, docParent);
|
||||
|
||||
// Change the role current user to "Administrator" to test that only the owner can detach a document
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
const currentEmail =
|
||||
process.env[`SIGN_IN_USERNAME_${browserName.toUpperCase()}`] || '';
|
||||
const currentUser = list.getByTestId(
|
||||
`doc-share-member-row-${currentEmail}`,
|
||||
);
|
||||
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
|
||||
await list.click();
|
||||
await page.getByRole('button', { name: 'Ok' }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByLabel('It is the card information about the document.')
|
||||
.getByText('Administrator ·'),
|
||||
).toBeVisible();
|
||||
|
||||
await child.hover();
|
||||
await menu.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Move to my docs' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('keyboard navigation with Enter key opens documents', async ({
|
||||
@@ -371,12 +392,12 @@ test.describe('Doc Tree', () => {
|
||||
await rootItem.focus();
|
||||
await expect(rootItem).toBeFocused();
|
||||
|
||||
// Press F2 → focus should move to the root actions \"More options\" button
|
||||
// Press F2 → focus should move to the root actions \"Open the document options\" button
|
||||
await page.keyboard.press('F2');
|
||||
|
||||
const rootActions = rootItem.locator('.doc-tree-root-item-actions');
|
||||
const rootMoreOptionsButton = rootActions.getByRole('button', {
|
||||
name: /more options/i,
|
||||
name: /Open the document options/i,
|
||||
});
|
||||
|
||||
await expect(rootMoreOptionsButton).toBeFocused();
|
||||
@@ -386,13 +407,7 @@ test.describe('Doc Tree', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [docParent] = await createDoc(
|
||||
page,
|
||||
'doc-tree-shift-tab',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, docParent);
|
||||
await createDoc(page, 'doc-tree-shift-tab', browserName, 1);
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
@@ -400,6 +415,7 @@ test.describe('Doc Tree', () => {
|
||||
'doc-tree-shift-tab-child',
|
||||
);
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
const selectedSubDoc = await getTreeRow(page, docChild);
|
||||
await expect(selectedSubDoc).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
@@ -407,7 +423,7 @@ test.describe('Doc Tree', () => {
|
||||
await expect(selectedSubDoc).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByLabel('User menu')).toBeFocused();
|
||||
await expect(page.getByLabel('Open user menu')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByLabel('Open help menu')).toBeFocused();
|
||||
@@ -424,7 +440,7 @@ test.describe('Doc Tree', () => {
|
||||
await expect(page.getByLabel('User menu')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(selectedSubDoc).toBeFocused();
|
||||
await expect(docTree.getByLabel('Root document').first()).toBeFocused();
|
||||
});
|
||||
|
||||
test('it updates the child icon from the tree', async ({
|
||||
@@ -441,19 +457,6 @@ test.describe('Doc Tree', () => {
|
||||
|
||||
const row = await getTreeRow(page, docChild);
|
||||
|
||||
// Check Remove emoji is not present initially
|
||||
await row.hover();
|
||||
const menu = row.getByRole('button', { name: /More options/ });
|
||||
await menu.click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Remove emoji' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Close the menu
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Update the emoji from the tree
|
||||
await row.locator('.--docs--doc-icon').click();
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
@@ -465,14 +468,6 @@ test.describe('Doc Tree', () => {
|
||||
.locator('.--docs--doc-title')
|
||||
.getByRole('button');
|
||||
await expect(titleEmojiPicker).toHaveText('😀');
|
||||
|
||||
// Now remove the emoji using the new action
|
||||
await row.hover();
|
||||
await menu.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
|
||||
|
||||
await expect(row.getByText('😀')).toBeHidden();
|
||||
await expect(titleEmojiPicker).toBeHidden();
|
||||
});
|
||||
|
||||
test('A child inherit from the parent', async ({ page, browserName }) => {
|
||||
|
||||
@@ -2,12 +2,7 @@ import path from 'path';
|
||||
|
||||
import { Locator, Page, expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
saveContent,
|
||||
} from './utils-common';
|
||||
import { createDoc, mockedDocument, saveContent } from './utils-common';
|
||||
import {
|
||||
openSuggestionMenu,
|
||||
tryFocusEditorContent,
|
||||
@@ -15,7 +10,7 @@ import {
|
||||
} from './utils-editor';
|
||||
|
||||
const openPresenter = async (page: Page) => {
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByLabel('Open the document options').first().click();
|
||||
await page.getByRole('menuitem', { name: 'Present' }).click();
|
||||
|
||||
const overlay = page.getByRole('dialog', { name: 'Presenter mode' });
|
||||
@@ -513,9 +508,9 @@ test.describe('Presenter Mode mobile', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByLabel('Open the document options').first().click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Present' })).toBeHidden();
|
||||
});
|
||||
|
||||
|
||||
@@ -446,20 +446,20 @@ export const clickInEditorShareButton = async (page: Page) => {
|
||||
};
|
||||
|
||||
export const clickInEditorMenu = async (page: Page, textButton: string) => {
|
||||
await page
|
||||
.getByTestId('floating-bar')
|
||||
.getByRole('button', { name: 'Open the document options' })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: textButton }).click();
|
||||
await clickInDocOptionMenu(
|
||||
page,
|
||||
page.getByTestId('floating-bar'),
|
||||
textButton,
|
||||
);
|
||||
};
|
||||
|
||||
export const clickInGridMenu = async (
|
||||
export const clickInDocOptionMenu = async (
|
||||
page: Page,
|
||||
row: Locator,
|
||||
selector: Locator,
|
||||
textButton: string,
|
||||
) => {
|
||||
await row
|
||||
.getByRole('button', { name: /Open the menu of actions for the document/ })
|
||||
await selector
|
||||
.getByRole('button', { name: /Open the document options/ })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: textButton }).click();
|
||||
};
|
||||
|
||||
+3
-1
@@ -24,7 +24,9 @@ export const DocFloatingBar = () => {
|
||||
{!isDeletedDoc && currentDoc && <DocShareButton doc={currentDoc} />}
|
||||
<CardFloatingBar>
|
||||
<RightPanelCollapseButton />
|
||||
{!isDeletedDoc && currentDoc && <DocToolBox doc={currentDoc} />}
|
||||
{!isDeletedDoc && currentDoc && (
|
||||
<DocToolBox doc={currentDoc} isCurrentDoc={true} />
|
||||
)}
|
||||
</CardFloatingBar>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
+5
-1
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { syncDocInTree, useTreeContextOrNull } from '@/docs/doc-tree/utils';
|
||||
|
||||
import { Doc } from '../types';
|
||||
|
||||
@@ -32,16 +33,19 @@ export function useCreateFavoriteDoc({
|
||||
}: CreateFavoriteDocProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const treeContext = useTreeContextOrNull();
|
||||
|
||||
return useMutation<void, APIError, CreateFavoriteDocParams>({
|
||||
mutationFn: createFavoriteDoc,
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, { id }) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
syncDocInTree(treeContext, id, { is_favorite: true });
|
||||
|
||||
const message = t('Document starred successfully!');
|
||||
announce(message, 'polite');
|
||||
|
||||
|
||||
+5
-1
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { syncDocInTree, useTreeContextOrNull } from '@/docs/doc-tree/utils';
|
||||
|
||||
import { Doc } from '../types';
|
||||
|
||||
@@ -32,16 +33,19 @@ export function useDeleteFavoriteDoc({
|
||||
}: DeleteFavoriteDocProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const treeContext = useTreeContextOrNull();
|
||||
|
||||
return useMutation<void, APIError, DeleteFavoriteDocParams>({
|
||||
mutationFn: deleteFavoriteDoc,
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, { id }) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
syncDocInTree(treeContext, id, { is_favorite: false });
|
||||
|
||||
const message = t('Document unstarred successfully!');
|
||||
announce(message, 'polite');
|
||||
|
||||
|
||||
+62
-30
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonProps,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
useTreeContext,
|
||||
} from '@gouvfr-lasuite/ui-components';
|
||||
import { Present } from '@gouvfr-lasuite/ui-components/icons';
|
||||
import { announce } from '@react-aria/live-announcer';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Text } from '@/components/Text';
|
||||
@@ -17,15 +17,16 @@ import { getWordCount } from '@/docs/doc-editor/utils';
|
||||
import { printDocumentWithStyles } from '@/docs/doc-export/utils_print';
|
||||
import { usePresenterStore } from '@/docs/doc-presenter/stores';
|
||||
import { useDetachDoc } from '@/docs/doc-tree/api/useDetach';
|
||||
import { useTreeContextOrNull } from '@/docs/doc-tree/utils';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import ContentCopyIcon from '@/icons/copy.svg';
|
||||
import DocMoveInIcon from '@/icons/doc-move-in.svg';
|
||||
import DocMoveOutIcon from '@/icons/doc-move-out.svg';
|
||||
import DownloadSVG from '@/icons/download.svg';
|
||||
import HistorySVG from '@/icons/history.svg';
|
||||
import LeaveSVG from '@/icons/leave.svg';
|
||||
import DownloadIcon from '@/icons/download.svg';
|
||||
import HistoryIcon from '@/icons/history.svg';
|
||||
import LeaveIcon from '@/icons/leave.svg';
|
||||
import LinkIcon from '@/icons/link.svg';
|
||||
import MoreSVG from '@/icons/more_horiz.svg';
|
||||
import MoreIcon from '@/icons/more_horiz.svg';
|
||||
import PrintIcon from '@/icons/print.svg';
|
||||
import SharedIcon from '@/icons/shared.svg';
|
||||
import StarSlashIcon from '@/icons/star-slash.svg';
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
useDeleteFavoriteDoc,
|
||||
useDuplicateDoc,
|
||||
} from '../api';
|
||||
import { useCopyDocLink } from '../hooks';
|
||||
import { useCopyDocLink, useTrans } from '../hooks';
|
||||
import { Doc, Role } from '../types';
|
||||
|
||||
const DocMoveModal = dynamic(
|
||||
@@ -96,13 +97,24 @@ const ModalExport = dynamic(
|
||||
|
||||
interface DocToolBoxProps {
|
||||
doc: Doc;
|
||||
isCurrentDoc: boolean;
|
||||
buttonProps?: ButtonProps;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
optionsDefault?: DropdownMenuItem[];
|
||||
}
|
||||
|
||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const DocToolBoxComponent = ({
|
||||
buttonProps,
|
||||
doc,
|
||||
isCurrentDoc,
|
||||
onOpenChange,
|
||||
optionsDefault,
|
||||
}: DocToolBoxProps) => {
|
||||
const { t } = useTranslation();
|
||||
const treeContext = useTreeContext<Doc | null>();
|
||||
const { untitledDocument } = useTrans();
|
||||
const treeContext = useTreeContextOrNull<Doc | null>();
|
||||
const router = useRouter();
|
||||
const isTopParent = doc.id === treeContext?.root?.id; // it can be a child but not for the current user
|
||||
const isTopParent = !treeContext || doc.id === treeContext?.root?.id; // it can be a child but not for the current user
|
||||
const { authenticated } = useAuth();
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
@@ -111,17 +123,18 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const [isModalHistoryOpen, setIsModalHistoryOpen] = useState(false);
|
||||
const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
|
||||
const [isModalMoveOpen, setIsModalMoveOpen] = useState(false);
|
||||
const { onClick: onButtonClick, ...buttonPropsLeft } = buttonProps || {};
|
||||
|
||||
const { editor } = useEditorStore();
|
||||
const editor = useEditorStore((state) => state.editor);
|
||||
const wordCountLabel = useMemo(() => {
|
||||
if (openDropdown) {
|
||||
if (openDropdown && isCurrentDoc && editor) {
|
||||
return t('Word count: {{count}} words', {
|
||||
count: getWordCount(editor),
|
||||
description:
|
||||
'In the document options menu, showing the number of words in the document.',
|
||||
});
|
||||
}
|
||||
}, [editor, openDropdown, t]);
|
||||
}, [editor, isCurrentDoc, openDropdown, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wordCountLabel) {
|
||||
@@ -131,8 +144,9 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
|
||||
const { mutate: detachDoc } = useDetachDoc();
|
||||
|
||||
const { restoreFocus, addLastFocus } = useFocusStore();
|
||||
const { isMobile } = useResponsiveStore();
|
||||
const restoreFocus = useFocusStore((state) => state.restoreFocus);
|
||||
const addLastFocus = useFocusStore((state) => state.addLastFocus);
|
||||
const isMobile = useResponsiveStore((state) => state.isMobile);
|
||||
const copyDocLink = useCopyDocLink(doc.id);
|
||||
|
||||
const openPresenter = usePresenterStore((state) => state.open);
|
||||
@@ -155,7 +169,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
description: 'Dropdown menu item to copy the document link',
|
||||
}),
|
||||
icon: <LinkIcon width={18} height={18} aria-hidden="true" />,
|
||||
callback: copyDocLink,
|
||||
callback: () => {
|
||||
copyDocLink();
|
||||
restoreFocus();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Share', {
|
||||
@@ -166,8 +183,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
setIsModalShareOpen(true);
|
||||
},
|
||||
isHidden: !authenticated,
|
||||
showSeparator: isCurrentDoc,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('Present', {
|
||||
description:
|
||||
@@ -177,17 +194,18 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
callback: () => {
|
||||
openPresenter(0);
|
||||
},
|
||||
isHidden: Boolean(doc.deleted_at) || isMobile,
|
||||
isHidden: Boolean(doc.deleted_at) || isMobile || !isCurrentDoc,
|
||||
testId: `docs-actions-present-${doc.id}`,
|
||||
},
|
||||
{
|
||||
label: t('Download', {
|
||||
description: 'Dropdown menu item to download the document',
|
||||
}),
|
||||
icon: <DownloadSVG width={18} height={18} aria-hidden="true" />,
|
||||
icon: <DownloadIcon width={18} height={18} aria-hidden="true" />,
|
||||
callback: () => {
|
||||
setIsModalExportOpen(true);
|
||||
},
|
||||
isHidden: !isCurrentDoc,
|
||||
},
|
||||
{
|
||||
label: t('Print', {
|
||||
@@ -197,6 +215,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
callback: () => {
|
||||
printDocumentWithStyles();
|
||||
},
|
||||
isHidden: !isCurrentDoc,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
@@ -212,6 +231,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
} else {
|
||||
makeFavoriteDoc.mutate({ id: doc.id });
|
||||
}
|
||||
|
||||
restoreFocus();
|
||||
},
|
||||
isHidden: !doc.abilities.favorite,
|
||||
testId: `docs-actions-${doc.is_favorite ? 'unstar' : 'star'}-${doc.id}`,
|
||||
@@ -270,19 +291,19 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
label: t('History', {
|
||||
description: 'Dropdown menu item to view the document history',
|
||||
}),
|
||||
icon: <HistorySVG width={18} height={18} aria-hidden="true" />,
|
||||
icon: <HistoryIcon width={18} height={18} aria-hidden="true" />,
|
||||
isDisabled: !doc.abilities.versions_list,
|
||||
callback: () => {
|
||||
setIsModalHistoryOpen(true);
|
||||
},
|
||||
isHidden: isMobile || !doc.abilities.versions_list,
|
||||
isHidden: isMobile || !doc.abilities.versions_list || !isCurrentDoc,
|
||||
showSeparator: true,
|
||||
},
|
||||
{
|
||||
label: t('Leave', {
|
||||
description: 'Dropdown menu item to leave the document',
|
||||
}),
|
||||
icon: <LeaveSVG width={18} height={18} aria-hidden="true" />,
|
||||
icon: <LeaveIcon width={18} height={18} aria-hidden="true" />,
|
||||
callback: () => {
|
||||
setIsModalLeaveOpen(true);
|
||||
},
|
||||
@@ -302,35 +323,44 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
setIsModalRemoveOpen(true);
|
||||
},
|
||||
isHidden: !doc.abilities.destroy,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
showSeparator: isCurrentDoc,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
options={optionsDefault ?? options}
|
||||
isOpen={openDropdown}
|
||||
shouldCloseOnInteractOutside={() => true}
|
||||
onOpenChange={setOpenDropdown}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpenDropdown(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
}}
|
||||
bottomMessage={
|
||||
wordCountLabel && <Text $variation="tertiary">{wordCountLabel}</Text>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
aria-label={t('Open the document options')}
|
||||
aria-label={t('Open the document options: {{title}}', {
|
||||
title: doc.title || untitledDocument,
|
||||
})}
|
||||
size="small"
|
||||
icon={<MoreSVG width={24} height={24} aria-hidden="true" />}
|
||||
icon={<MoreIcon width={24} height={24} aria-hidden="true" />}
|
||||
color="neutral"
|
||||
variant="tertiary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setOpenDropdown((o) => !o);
|
||||
const isOpen = !openDropdown;
|
||||
setOpenDropdown(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
addLastFocus(e.currentTarget);
|
||||
onButtonClick?.(
|
||||
e as React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
);
|
||||
}}
|
||||
{...buttonPropsLeft}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -409,3 +439,5 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocToolBox = memo(DocToolBoxComponent);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { KEY_LIST_DOC } from '@/docs/doc-management/api';
|
||||
import { KEY_LIST_DOC, KEY_LIST_FAVORITE_DOC } from '@/docs/doc-management/api';
|
||||
|
||||
interface LeaveDocProps {
|
||||
docId: string;
|
||||
@@ -36,6 +36,9 @@ export const useLeaveDoc = (options?: UseLeaveDocOptions) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_FAVORITE_DOC],
|
||||
});
|
||||
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, onMutateResult, context);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Doc, LinkReach, LinkRole } from '@/docs/doc-management';
|
||||
import { syncDocInTree, useTreeContextOrNull } from '@/docs/doc-tree/utils';
|
||||
|
||||
export type UpdateDocLinkParams = Pick<Doc, 'id' | 'link_reach'> &
|
||||
Partial<Pick<Doc, 'link_role'>>;
|
||||
@@ -43,6 +44,7 @@ type UseUpdateDocLinkOptions = UseMutationOptions<
|
||||
|
||||
export function useUpdateDocLink(options?: UseUpdateDocLinkOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const treeContext = useTreeContextOrNull();
|
||||
|
||||
return useMutation<UpdateDocLinkResponse, APIError, UpdateDocLinkParams>({
|
||||
mutationFn: updateDocLink,
|
||||
@@ -54,6 +56,17 @@ export function useUpdateDocLink(options?: UseUpdateDocLinkOptions) {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Can be loaded from the doc tree, we need to resync the doc tree
|
||||
* in order to reflect the changes in the tree view.
|
||||
*/
|
||||
syncDocInTree(treeContext, variables.id, {
|
||||
link_reach: data.link_reach,
|
||||
link_role: data.link_role,
|
||||
computed_link_reach: data.link_reach,
|
||||
computed_link_role: data.link_role,
|
||||
});
|
||||
|
||||
options?.onSuccess?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
});
|
||||
|
||||
+62
-221
@@ -1,157 +1,39 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonElement,
|
||||
DropdownMenu,
|
||||
DropdownMenuOption,
|
||||
useArrowRoving,
|
||||
useModal,
|
||||
useTreeContext,
|
||||
} from '@gouvfr-lasuite/ui-components';
|
||||
import { Button, ButtonProps } from '@gouvfr-lasuite/ui-components';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import AddSVG from '@/assets/icons/ui-kit/add.svg';
|
||||
import MoreHorizSVG from '@/assets/icons/ui-kit/more_horiz.svg';
|
||||
import { Box, Icon } from '@/components';
|
||||
import {
|
||||
Doc,
|
||||
ModalRemoveDoc,
|
||||
Role,
|
||||
getEmojiAndTitle,
|
||||
useCopyDocLink,
|
||||
useCreateChildDoc,
|
||||
useDocTitleUpdate,
|
||||
} from '@/docs/doc-management';
|
||||
import { useDuplicateDoc } from '@/docs/doc-management/api';
|
||||
import DocMoveOutIcon from '@/icons/doc-move-out.svg';
|
||||
import { Doc, useCreateChildDoc, useTrans } from '@/docs/doc-management';
|
||||
import { DocToolBox } from '@/docs/doc-management/components/DocToolBox';
|
||||
import MoreIcon from '@/icons/more_horiz.svg';
|
||||
|
||||
import { useDetachDoc } from '../api/useDetach';
|
||||
import { CLASS_TREE_ITEM_ACTIONS } from '../utils';
|
||||
|
||||
// Module-level so the reference stays stable and the memoized `DocToolBox`
|
||||
// isn't forced to re-render on every parent render.
|
||||
const TOOLBOX_BUTTON_PROPS: ButtonProps = {
|
||||
color: 'brand',
|
||||
icon: <MoreIcon width={16} height={16} aria-hidden="true" />,
|
||||
size: 'nano',
|
||||
tabIndex: -1,
|
||||
};
|
||||
|
||||
type DocTreeItemActionsProps = {
|
||||
doc: Doc;
|
||||
isOpen?: boolean;
|
||||
isRoot?: boolean;
|
||||
onCreateSuccess?: (newDoc: Doc) => void;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
parentId?: string | null;
|
||||
actionsRef?: React.RefObject<HTMLDivElement | null>;
|
||||
buttonOptionRef?: React.RefObject<ButtonElement | null>;
|
||||
};
|
||||
|
||||
export const DocTreeItemActions = ({
|
||||
doc,
|
||||
isOpen,
|
||||
isRoot = false,
|
||||
onCreateSuccess,
|
||||
onOpenChange,
|
||||
parentId,
|
||||
actionsRef,
|
||||
buttonOptionRef,
|
||||
}: DocTreeItemActionsProps) => {
|
||||
const internalActionsRef = useRef<HTMLDivElement | null>(null);
|
||||
const targetActionsRef = actionsRef ?? internalActionsRef;
|
||||
const internalButtonRef = useRef<ButtonElement | null>(null);
|
||||
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const deleteModal = useModal();
|
||||
const copyLink = useCopyDocLink(doc.id);
|
||||
const { mutate: detachDoc } = useDetachDoc();
|
||||
const treeContext = useTreeContext<Doc | null>();
|
||||
|
||||
// Keyboard navigation inside the actions toolbar (ArrowLeft / ArrowRight).
|
||||
useArrowRoving(targetActionsRef.current);
|
||||
|
||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||
onSuccess: (duplicatedDoc) => {
|
||||
// Reset the tree context root will reset the full tree view.
|
||||
treeContext?.setRoot(null);
|
||||
void router.push(`/docs/${duplicatedDoc.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Emoji Management
|
||||
const { emoji } = getEmojiAndTitle(doc.title ?? '');
|
||||
const { updateDocEmoji } = useDocTitleUpdate();
|
||||
const removeEmoji = () => {
|
||||
updateDocEmoji(doc.id, doc.title ?? '', '');
|
||||
};
|
||||
|
||||
const handleDetachDoc = () => {
|
||||
if (!treeContext?.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
detachDoc(
|
||||
{ documentId: doc.id, rootId: treeContext.root.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (treeContext.root) {
|
||||
treeContext.treeData.setSelectedNode(treeContext.root);
|
||||
void router.push(`/docs/${treeContext.root.id}`).then(() => {
|
||||
setTimeout(() => {
|
||||
treeContext?.treeData.deleteNode(doc.id);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
{
|
||||
label: t('Copy link'),
|
||||
icon: <Icon iconName="link" $size="24px" />,
|
||||
callback: copyLink,
|
||||
},
|
||||
...(!isRoot
|
||||
? [
|
||||
...(emoji && doc.abilities.partial_update
|
||||
? [
|
||||
{
|
||||
label: t('Remove emoji'),
|
||||
icon: <Icon iconName="emoji_emotions" $size="24px" />,
|
||||
callback: removeEmoji,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t('Move to my docs'),
|
||||
isDisabled: doc.user_role !== Role.OWNER,
|
||||
icon: (
|
||||
<DocMoveOutIcon
|
||||
width={24}
|
||||
height={24}
|
||||
aria-hidden="true"
|
||||
color="var(--c--contextuals--content--semantic--neutral--primary)"
|
||||
/>
|
||||
),
|
||||
callback: handleDetachDoc,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t('Duplicate'),
|
||||
icon: <Icon iconName="content_copy" />,
|
||||
isDisabled: !doc.abilities.duplicate,
|
||||
callback: () => {
|
||||
duplicateDoc({
|
||||
docId: doc.id,
|
||||
with_accesses: false,
|
||||
canSave: doc.abilities.partial_update,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Delete'),
|
||||
isDisabled: !doc.abilities.destroy,
|
||||
icon: <Icon iconName="delete" $size="24px" />,
|
||||
callback: deleteModal.open,
|
||||
},
|
||||
];
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
const { mutate: createChildDoc } = useCreateChildDoc({
|
||||
onSuccess: (newDoc) => {
|
||||
@@ -160,97 +42,56 @@ export const DocTreeItemActions = ({
|
||||
},
|
||||
});
|
||||
|
||||
const onSuccessDelete = () => {
|
||||
const isTopParent = doc.id === treeContext?.root?.id && !parentId;
|
||||
const parentIdComputed = parentId || treeContext?.root?.id;
|
||||
|
||||
if (isTopParent) {
|
||||
void router.push(`/`);
|
||||
} else if (parentIdComputed) {
|
||||
void router.push(`/docs/${parentIdComputed}`).then(() => {
|
||||
setTimeout(() => {
|
||||
treeContext?.treeData.deleteNode(doc.id);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="doc-tree-root-item-actions actions">
|
||||
<Box
|
||||
ref={targetActionsRef}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="--docs--doc-tree-item-actions"
|
||||
$gap="4xs"
|
||||
tabIndex={-1}
|
||||
$css={css`
|
||||
& button {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px
|
||||
var(--c--contextuals--border--semantic--brand--primary);
|
||||
}
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="4xs"
|
||||
className={`${CLASS_TREE_ITEM_ACTIONS} --docs--doc-tree-item-actions actions`}
|
||||
role="toolbar"
|
||||
aria-label={t('Actions for {{title}}', {
|
||||
title: doc.title || untitledDocument,
|
||||
})}
|
||||
tabIndex={-1}
|
||||
$css={css`
|
||||
& button {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px
|
||||
var(--c--contextuals--border--semantic--brand--primary);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Button
|
||||
ref={targetButtonRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onOpenChange?.(!isOpen);
|
||||
}}
|
||||
aria-label={t('More options')}
|
||||
tabIndex={-1}
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
>
|
||||
<Icon
|
||||
$color="inherit"
|
||||
icon={<MoreHorizSVG width={16} height={16} aria-hidden="true" />}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
{doc.abilities.children_create && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
`}
|
||||
>
|
||||
<DocToolBox
|
||||
doc={doc}
|
||||
isCurrentDoc={doc.id === router.query.id}
|
||||
onOpenChange={onOpenChange}
|
||||
buttonProps={TOOLBOX_BUTTON_PROPS}
|
||||
/>
|
||||
{doc.abilities.children_create && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
createChildDoc({
|
||||
parentId: doc.id,
|
||||
});
|
||||
}}
|
||||
aria-label={t('Add a sub page')}
|
||||
data-testid="doc-tree-item-actions-add-child"
|
||||
tabIndex={-1}
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
>
|
||||
<Icon
|
||||
$color="inherit"
|
||||
icon={<AddSVG width={16} height={16} aria-hidden="true" />}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{deleteModal.isOpen && (
|
||||
<ModalRemoveDoc
|
||||
onClose={deleteModal.onClose}
|
||||
doc={doc}
|
||||
onSuccess={onSuccessDelete}
|
||||
/>
|
||||
createChildDoc({ parentId: doc.id });
|
||||
}}
|
||||
aria-label={t('Add a sub page')}
|
||||
data-testid="doc-tree-item-actions-add-child"
|
||||
tabIndex={-1}
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
>
|
||||
<Icon
|
||||
$color="inherit"
|
||||
icon={<AddSVG width={16} height={16} aria-hidden="true" />}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
TreeContext,
|
||||
TreeContextType,
|
||||
TreeDataItem,
|
||||
TreeViewDataType,
|
||||
TreeViewNodeTypeEnum,
|
||||
} from '@gouvfr-lasuite/ui-components';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { Doc } from '../doc-management';
|
||||
|
||||
@@ -24,6 +27,35 @@ export const subPageToTree = (children: Doc[]): TreeViewDataType<Doc>[] => {
|
||||
return children;
|
||||
};
|
||||
|
||||
export const useTreeContextOrNull = <T = Doc>(): TreeContextType<T> | null =>
|
||||
useContext(TreeContext);
|
||||
|
||||
/**
|
||||
* The doc tree keeps its own copy of each doc (`treeContext.treeData` /
|
||||
* `treeContext.root`), which react-query cache invalidations don't reach. Code
|
||||
* mutating a doc from a tree item must sync the changed fields into the tree by
|
||||
* hand, otherwise the tree UI stays stale until it is reloaded.
|
||||
*
|
||||
* No-op when there is no tree context (e.g. the doc grid or the doc header),
|
||||
* where the react-query cache is the single source of truth.
|
||||
*/
|
||||
export const syncDocInTree = (
|
||||
treeContext: TreeContextType<Doc> | null,
|
||||
docId: string,
|
||||
data: Partial<Doc>,
|
||||
) => {
|
||||
if (!treeContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { root } = treeContext;
|
||||
if (root && root.id === docId) {
|
||||
treeContext.setRoot({ ...root, ...data });
|
||||
} else if (treeContext.treeData.getNode(docId)) {
|
||||
treeContext.treeData.updateNode(docId, data);
|
||||
}
|
||||
};
|
||||
|
||||
export const findIndexInTree = (
|
||||
nodes: TreeDataItem<TreeViewDataType<Doc>>[],
|
||||
key: string,
|
||||
|
||||
+18
-223
@@ -1,12 +1,9 @@
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
ButtonProps,
|
||||
DropdownMenuItem,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/ui-components';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
@@ -14,64 +11,23 @@ import {
|
||||
type Doc,
|
||||
KEY_LIST_DOC,
|
||||
KEY_LIST_FAVORITE_DOC,
|
||||
useCreateFavoriteDoc,
|
||||
useDeleteFavoriteDoc,
|
||||
useDuplicateDoc,
|
||||
useRestoreDoc,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import ContentCopyIcon from '@/icons/content_copy.svg';
|
||||
import DeleteIcon from '@/icons/delete.svg';
|
||||
import DocMoveInIcon from '@/icons/doc-move-in.svg';
|
||||
import GroupIcon from '@/icons/group.svg';
|
||||
import LeaveIcon from '@/icons/leave.svg';
|
||||
import { DocToolBox } from '@/docs/doc-management/components/DocToolBox';
|
||||
import MoreIcon from '@/icons/more_horiz.svg';
|
||||
import StarSlashIcon from '@/icons/star-slash.svg';
|
||||
import StarIcon from '@/icons/star.svg';
|
||||
import { focusMainContentStart } from '@/layouts/utils';
|
||||
import { useFocusStore } from '@/stores';
|
||||
|
||||
import { KEY_LIST_DOC_TRASHBIN } from '../api';
|
||||
|
||||
const DocMoveModal = dynamic(
|
||||
() =>
|
||||
import('@/docs/doc-management/components/DocMoveModal').then((mod) => ({
|
||||
default: mod.DocMoveModal,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const DocShareModal = dynamic(
|
||||
() =>
|
||||
import('@/docs/doc-share/components/DocShareModal').then((mod) => ({
|
||||
default: mod.DocShareModal,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const ModalRemoveDoc = dynamic(
|
||||
() =>
|
||||
import('@/docs/doc-management/components/ModalRemoveDoc').then((mod) => ({
|
||||
default: mod.ModalRemoveDoc,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const ConfirmationLeaveModal = dynamic(
|
||||
() =>
|
||||
import('@/docs/doc-share/components/ConfirmationLeaveModal').then(
|
||||
(mod) => ({
|
||||
default: mod.ConfirmationLeaveModal,
|
||||
}),
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface DocsGridActionsProps {
|
||||
doc: Doc;
|
||||
isInTrashbin?: boolean;
|
||||
}
|
||||
|
||||
const TOOLBOX_BUTTON_PROPS: ButtonProps = {
|
||||
icon: <MoreIcon width={16} height={16} aria-hidden="true" />,
|
||||
size: 'nano',
|
||||
};
|
||||
|
||||
export const DocsGridActions = ({
|
||||
doc,
|
||||
isInTrashbin,
|
||||
@@ -79,141 +35,11 @@ export const DocsGridActions = ({
|
||||
return isInTrashbin ? (
|
||||
<DocsGridTrashbinActions doc={doc} />
|
||||
) : (
|
||||
<DocsGridActionsGlobal doc={doc} />
|
||||
);
|
||||
};
|
||||
|
||||
const DocsGridActionsGlobal = ({ doc }: { doc: Doc }) => {
|
||||
const { t } = useTranslation();
|
||||
const { restoreFocus } = useFocusStore();
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
|
||||
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
|
||||
const [isModalMoveOpen, setIsModalMoveOpen] = useState(false);
|
||||
|
||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||
onSuccess: () => {
|
||||
requestAnimationFrame(() => {
|
||||
focusMainContentStart({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_FAVORITE_DOC],
|
||||
});
|
||||
const makeFavoriteDoc = useCreateFavoriteDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_FAVORITE_DOC],
|
||||
});
|
||||
|
||||
const options: DropdownMenuItem[] = [
|
||||
{
|
||||
label: doc.is_favorite ? t('Unstar') : t('Star'),
|
||||
icon: doc.is_favorite ? (
|
||||
<StarSlashIcon width={18} height={18} aria-hidden="true" />
|
||||
) : (
|
||||
<StarIcon width={18} height={18} aria-hidden="true" />
|
||||
),
|
||||
callback: () => {
|
||||
if (doc.is_favorite) {
|
||||
removeFavoriteDoc.mutate({ id: doc.id });
|
||||
} else {
|
||||
makeFavoriteDoc.mutate({ id: doc.id });
|
||||
}
|
||||
},
|
||||
testId: `docs-grid-actions-${doc.is_favorite ? 'unstar' : 'star'}-${doc.id}`,
|
||||
showSeparator: true,
|
||||
},
|
||||
{
|
||||
label: t('Share'),
|
||||
icon: <GroupIcon width={18} height={18} aria-hidden="true" />,
|
||||
callback: () => {
|
||||
setIsModalShareOpen(true);
|
||||
},
|
||||
|
||||
testId: `docs-grid-actions-share-${doc.id}`,
|
||||
},
|
||||
{
|
||||
label: t('Move into a doc'),
|
||||
icon: <DocMoveInIcon width={18} height={18} aria-hidden="true" />,
|
||||
callback: () => {
|
||||
setIsModalMoveOpen(true);
|
||||
},
|
||||
testId: `docs-grid-actions-move-${doc.id}`,
|
||||
isHidden: !doc.abilities.move,
|
||||
},
|
||||
{
|
||||
label: t('Duplicate'),
|
||||
icon: <ContentCopyIcon width={18} height={18} aria-hidden="true" />,
|
||||
isDisabled: !doc.abilities.duplicate,
|
||||
callback: () => {
|
||||
duplicateDoc({
|
||||
docId: doc.id,
|
||||
with_accesses: false,
|
||||
canSave: false,
|
||||
});
|
||||
},
|
||||
showSeparator: true,
|
||||
},
|
||||
{
|
||||
label: t('Leave'),
|
||||
icon: <LeaveIcon width={18} height={18} aria-hidden="true" />,
|
||||
callback: () => {
|
||||
setIsModalLeaveOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Delete'),
|
||||
icon: <DeleteIcon width={18} height={18} aria-hidden="true" />,
|
||||
callback: () => {
|
||||
setIsModalRemoveOpen(true);
|
||||
},
|
||||
isHidden: !doc.abilities.destroy,
|
||||
testId: `docs-grid-actions-remove-${doc.id}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocsGridDropdown doc={doc} options={options} />
|
||||
{isModalRemoveOpen && (
|
||||
<ModalRemoveDoc
|
||||
onClose={() => {
|
||||
setIsModalRemoveOpen(false);
|
||||
restoreFocus();
|
||||
}}
|
||||
doc={doc}
|
||||
/>
|
||||
)}
|
||||
{isModalShareOpen && (
|
||||
<DocShareModal
|
||||
doc={doc}
|
||||
onClose={() => {
|
||||
setIsModalShareOpen(false);
|
||||
restoreFocus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isModalLeaveOpen && (
|
||||
<ConfirmationLeaveModal
|
||||
onClose={() => {
|
||||
setIsModalLeaveOpen(false);
|
||||
restoreFocus();
|
||||
}}
|
||||
doc={doc}
|
||||
/>
|
||||
)}
|
||||
{isModalMoveOpen && (
|
||||
<DocMoveModal
|
||||
doc={doc}
|
||||
onClose={() => {
|
||||
setIsModalMoveOpen(false);
|
||||
restoreFocus();
|
||||
}}
|
||||
isOpen={isModalMoveOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<DocToolBox
|
||||
doc={doc}
|
||||
isCurrentDoc={false}
|
||||
buttonProps={TOOLBOX_BUTTON_PROPS}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -276,43 +102,12 @@ export const DocsGridTrashbinActions = ({
|
||||
},
|
||||
];
|
||||
|
||||
return <DocsGridDropdown doc={doc} options={options} />;
|
||||
};
|
||||
|
||||
interface DocsGridDropdownProps {
|
||||
doc: Doc;
|
||||
options: DropdownMenuItem[];
|
||||
}
|
||||
|
||||
const DocsGridDropdown = ({ doc, options }: DocsGridDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { addLastFocus } = useFocusStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
isOpen={openDropdown}
|
||||
shouldCloseOnInteractOutside={() => true}
|
||||
onOpenChange={setOpenDropdown}
|
||||
>
|
||||
<Button
|
||||
data-testid={`docs-grid-actions-button-${doc.id}`}
|
||||
aria-label={t('Open the menu of actions for the document: {{title}}', {
|
||||
title: doc.title || untitledDocument,
|
||||
})}
|
||||
size="nano"
|
||||
icon={<MoreIcon width={16} height={16} aria-hidden="true" />}
|
||||
color="neutral"
|
||||
variant="tertiary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setOpenDropdown((o) => !o);
|
||||
addLastFocus(e.currentTarget);
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<DocToolBox
|
||||
doc={doc}
|
||||
isCurrentDoc={false}
|
||||
buttonProps={TOOLBOX_BUTTON_PROPS}
|
||||
optionsDefault={options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1523,6 +1523,7 @@
|
||||
"Open new document options": "Ouvrir les options de nouveau document",
|
||||
"Open root document": "Ouvrir le document racine",
|
||||
"Open the document options": "Ouvrir les options du document",
|
||||
"Open the document options: {{title}}": "Ouvrir les options du document: {{title}}",
|
||||
"Open the menu of actions for the document: {{title}}": "Ouvrir le menu des actions du document : {{title}}",
|
||||
"Open the sharing settings for the document": "Ouvrir les paramètres de partage pour le document",
|
||||
"Organize": "Organiser",
|
||||
|
||||
Reference in New Issue
Block a user