🚚(frontend) move doc toolbox to Floating Bar

We are modifying the doc header, we are moving elements
to a floating bar. In this case, we are moving the
doc toolbox to the floating bar.
We adapted the toolbox with the updated actions.
We update the doc header to use all empty space.
We moved the emoji part from the toolbox directly
to the header.
This commit is contained in:
Anthony LC
2026-06-01 10:22:29 +02:00
parent 882c2ac979
commit ce6dc77222
17 changed files with 352 additions and 397 deletions
+1
View File
@@ -18,6 +18,7 @@ and this project adheres to
- ♻️(backend) allow global search in sub documents
- ✨(backend) add a breadcrumb in the search response
- ♻️(frontend) move doc action buttons to fix toolbar #2360
### Fixed
@@ -7,6 +7,7 @@ import { PDFParse } from 'pdf-parse';
import {
TestLanguage,
clickInEditorMenu,
createDoc,
verifyDocName,
waitForLanguageSwitch,
@@ -24,11 +25,7 @@ test.describe('Doc Export', () => {
browserName,
}) => {
await createDoc(page, 'doc-editor', browserName, 1);
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await clickInEditorMenu(page, 'Download');
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
@@ -54,11 +51,7 @@ test.describe('Doc Export', () => {
test('it exports the doc to docx', async ({ page, browserName }) => {
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await clickInEditorMenu(page, 'Download');
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Docx' }).click();
@@ -84,11 +77,7 @@ test.describe('Doc Export', () => {
test('it exports the doc to odt', async ({ page, browserName }) => {
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await clickInEditorMenu(page, 'Download');
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
@@ -139,11 +128,7 @@ test.describe('Doc Export', () => {
// Give some time for the image to be fully processed
await page.waitForTimeout(1000);
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await clickInEditorMenu(page, 'Download');
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'HTML' }).click();
@@ -231,11 +216,7 @@ test.describe('Doc Export', () => {
.fill('https://docs.numerique.gouv.fr/assets/logo-gouv.png');
await page.getByText('Embed image').click();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await clickInEditorMenu(page, 'Download');
await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -322,11 +303,7 @@ test.describe('Doc Export', () => {
await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await clickInEditorMenu(page, 'Download');
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Print' }).click();
@@ -375,11 +352,7 @@ test.describe('Doc Export', () => {
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await clickInEditorMenu(page, 'Download');
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import {
clickInEditorMenu,
clickInEditorShareButton,
createDoc,
getGridRow,
@@ -83,15 +84,14 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'close' }).first().click();
await expect(card.getByText('Public document')).toBeVisible();
await expect(card.getByText('Public ·')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Open the document options' }),
page.getByRole('menuitem', { name: 'Download' }),
).toBeVisible();
});
@@ -186,17 +186,15 @@ test.describe('Doc Header', () => {
await createDoc(page, 'doc-update-emoji', browserName, 1);
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
const addEmoji = page.getByRole('button', { name: 'Add icon' });
const removeEmoji = page.getByRole('button', {
name: 'Remove icon',
});
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await expect(removeEmojiMenuItem).toBeHidden();
await expect(addEmoji).toBeHidden();
await expect(removeEmoji).toBeHidden();
await page.keyboard.press('Escape');
const { name: docChild } = await createRootSubPage(
@@ -211,9 +209,8 @@ test.describe('Doc Header', () => {
await expect(emojiPicker).toBeHidden();
// Add emoji
await optionMenu.click();
await expect(removeEmojiMenuItem).toBeHidden();
await addEmojiMenuItem.click();
await expect(removeEmoji).toBeHidden();
await addEmoji.click();
// The 1 April the emoji is a fish
await expect(emojiPicker).toHaveText(/📄|🐟/);
@@ -235,17 +232,15 @@ test.describe('Doc Header', () => {
await expect(row.getByText('😀')).toBeVisible();
// Remove emoji
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await removeEmojiMenuItem.click();
await expect(addEmoji).toBeHidden();
await removeEmoji.click();
await expect(emojiPicker).toBeHidden();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await clickInEditorMenu(page, 'Delete');
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -301,15 +296,12 @@ test.describe('Doc Header', () => {
page.getByRole('textbox', { name: 'Document title' }),
).toContainText('Mocked document');
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
page.getByRole('menuitem', { name: 'Download' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeHidden();
// Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } });
@@ -381,14 +373,12 @@ test.describe('Doc Header', () => {
page.getByRole('textbox', { name: 'Document title' }),
).toContainText('Mocked document');
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
page.getByRole('menuitem', { name: 'Download' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeHidden();
// Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } });
@@ -453,14 +443,12 @@ test.describe('Doc Header', () => {
page.getByRole('heading', { name: 'Mocked document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
page.getByRole('menuitem', { name: 'Download' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeHidden();
// Click somewhere else to close the options
await page.locator('body').click({ position: { x: 0, y: 0 } });
@@ -707,13 +695,18 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(page.getByRole('button', { 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 }) => {
@@ -116,7 +116,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
).toBeHidden();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -93,7 +93,7 @@
* Tooltip
*/
.c__tooltip {
padding: var(--c--globals--font--sizes--sm) var(--c--globals--spacings--xxs);
padding: var(--c--globals--font--sizes--t) var(--c--globals--spacings--xxs);
}
.c__tooltip .react-aria-OverlayArrow svg {
@@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
const mockUpdateDocEmoji = vi.fn();
vi.mock('@/docs/doc-management', async () => {
const actual = await vi.importActual('@/docs/doc-management');
return {
...actual,
useDocTitleUpdate: () => ({ updateDocEmoji: mockUpdateDocEmoji }),
};
});
import { DocHeader } from '../components/DocHeader';
const doc = {
id: 'doc-1',
title: 'My document',
is_favorite: false,
nb_accesses_direct: 1,
abilities: {
versions_list: true,
destroy: true,
partial_update: true,
duplicate: true,
accesses_view: true,
},
} as any;
describe('DocHeader - Add emoji (April Fools easter egg)', () => {
beforeEach(() => {
vi.useFakeTimers();
mockUpdateDocEmoji.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
[
{ emoji: '🐟', date: '2026-04-01' },
{ emoji: '📄', date: '2026-03-30' },
{ emoji: '📄', date: '2026-04-02' },
].forEach(({ emoji, date }) => {
test(`uses ${emoji} emoji on ${date}`, () => {
vi.setSystemTime(new Date(date));
render(<DocHeader doc={doc} />, { wrapper: AppWrapper });
fireEvent.click(screen.getByRole('button', { name: 'Add icon' }));
expect(mockUpdateDocEmoji).toHaveBeenCalledWith(
'doc-1',
'My document',
emoji,
);
});
});
});
@@ -1,103 +0,0 @@
import { render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { DropdownMenuOption } from '@/components';
import { AppWrapper } from '@/tests/utils';
const mockUpdateDocEmoji = vi.fn();
let capturedOptions: DropdownMenuOption[] = [];
vi.mock('@/components', async () => {
const actual = await vi.importActual<any>('@/components');
return {
...actual,
DropdownMenu: ({ options }: { options: DropdownMenuOption[] }) => {
capturedOptions = options;
return null;
},
};
});
vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock('@/docs/doc-management', async () => {
const actual = await vi.importActual<any>('@/docs/doc-management');
return {
...actual,
useDocTitleUpdate: () => ({ updateDocEmoji: mockUpdateDocEmoji }),
useDocUtils: () => ({ isChild: true, isTopRoot: false }),
useCopyDocLink: () => vi.fn(),
useCreateFavoriteDoc: () => ({ mutate: vi.fn() }),
useDeleteFavoriteDoc: () => ({ mutate: vi.fn() }),
useDuplicateDoc: () => ({ mutate: vi.fn() }),
};
});
vi.mock('@/stores', () => ({
useFocusStore: (selector?: (state: any) => any) => {
const state = { addLastFocus: vi.fn(), restoreFocus: vi.fn() };
return selector ? selector(state) : state;
},
useResponsiveStore: () => ({
isSmallMobile: false,
isMobile: false,
isDesktop: true,
}),
}));
vi.mock('../hooks/useCopyCurrentEditorToClipboard', () => ({
useCopyCurrentEditorToClipboard: () => vi.fn(),
}));
import { DocToolBox } from '../components/DocToolBox';
const doc = {
id: 'doc-1',
title: 'My document',
is_favorite: false,
nb_accesses_direct: 1,
abilities: {
versions_list: true,
destroy: true,
partial_update: true,
duplicate: true,
accesses_view: true,
},
} as any;
describe('DocToolBox - Add emoji (April Fools easter egg)', () => {
beforeEach(() => {
vi.useFakeTimers();
mockUpdateDocEmoji.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
[
{ emoji: '🐟', date: '2026-04-01' },
{ emoji: '📄', date: '2026-03-30' },
{ emoji: '📄', date: '2026-04-02' },
].forEach(({ emoji, date }) => {
test(`uses ${emoji} emoji on ${date}`, () => {
vi.setSystemTime(new Date(date));
render(<DocToolBox doc={doc} />, { wrapper: AppWrapper });
const addEmojiOption = capturedOptions.find(
(o) => o.label === 'Add emoji',
);
void addEmojiOption?.callback?.();
expect(mockUpdateDocEmoji).toHaveBeenCalledWith(
'doc-1',
'My document',
emoji,
);
});
});
});
@@ -9,9 +9,33 @@ vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
useRouter: () => ({
push: vi.fn(),
pathname: '/docs/doc-1',
}),
}));
vi.mock('@gouvfr-lasuite/ui-kit', async () => {
const actual = await vi.importActual('@gouvfr-lasuite/ui-kit');
return {
...actual,
DropdownMenu: ({ options, children }: any) => (
<>
{children}
<ul>
{options
.filter((o: any) => !o.isHidden)
.map((o: any) => (
<li key={o.label}>{o.label}</li>
))}
</ul>
</>
),
};
});
vi.mock('../hooks/useCopyCurrentEditorToClipboard', () => ({
useCopyCurrentEditorToClipboard: () => vi.fn(),
}));
const doc = {
nb_accesses: 1,
abilities: {
@@ -39,9 +63,7 @@ describe('DocToolBox - Licence', () => {
wrapper: AppWrapper,
});
expect(
await screen.findByLabelText('Export the document'),
).toBeInTheDocument();
expect(await screen.findByText('Download')).toBeInTheDocument();
}, 15000);
test('The export button is not rendered when MIT version is activated', async () => {
@@ -53,12 +75,6 @@ describe('DocToolBox - Licence', () => {
wrapper: AppWrapper,
});
expect(
screen.getByLabelText('Open the document options'),
).toBeInTheDocument();
expect(
screen.queryByLabelText('Export the document'),
).not.toBeInTheDocument();
expect(screen.queryByText('Download')).not.toBeInTheDocument();
}, 15000);
});
@@ -1,62 +1,90 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import RemoveEmojiSVG from '@/assets/icons/ui-kit/face-remove.svg';
import AddEmojiSVG from '@/assets/icons/ui-kit/face.svg';
import { Box, HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import {
Doc,
getEmojiAndTitle,
useDocTitleUpdate,
useDocUtils,
useIsCollaborativeEditable,
} from '@/docs/doc-management';
import { AlertNetwork } from './AlertNetwork';
import { AlertRestore } from './AlertRestore';
import { DocHeaderInfo } from './DocHeaderInfo';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
interface DocHeaderProps {
doc: Doc;
}
export const DocHeader = ({ doc }: DocHeaderProps) => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { isEditable } = useIsCollaborativeEditable(doc);
const isDeletedDoc = !!doc.deleted_at;
// Emoji Management
const { emoji } = getEmojiAndTitle(doc.title ?? '');
const { updateDocEmoji } = useDocTitleUpdate();
const { isTopRoot } = useDocUtils(doc);
const displayEmojiButton = doc.abilities.partial_update && !isTopRoot;
return (
<>
<Box
$width="100%"
$padding={{ top: isDesktop ? '0' : 'md' }}
$gap={spacingsTokens['base']}
aria-label={t('It is the card information about the document.')}
className="--docs--doc-header"
$minHeight="125px"
>
{isDeletedDoc && <AlertRestore doc={doc} />}
{!isEditable && <AlertNetwork />}
<Box
$direction="row"
$align="center"
$width="100%"
$padding={{ bottom: 'xs' }}
$gap="base"
$padding={{
bottom: isDeletedDoc || !isEditable ? 'base' : undefined,
}}
>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
<Box $gap={spacingsTokens['3xs']} $overflow="auto">
<DocTitle doc={doc} />
<Box $direction="row">
<DocHeaderInfo doc={doc} />
</Box>
</Box>
{!isDeletedDoc && <DocToolBox doc={doc} />}
</Box>
{isDeletedDoc && <AlertRestore doc={doc} />}
{!isEditable && <AlertNetwork />}
</Box>
<HorizontalSeparator $margin="none" />
<Box $gap="sm">
<Box $minHeight="32px">
{displayEmojiButton && (
<Button
size="small"
onClick={() => {
const today = new Date();
const isAprilFools =
today.getMonth() === 3 && today.getDate() === 1;
emoji
? updateDocEmoji(doc.id, doc.title ?? '', '')
: updateDocEmoji(
doc.id,
doc.title ?? '',
isAprilFools ? '🐟' : '📄',
);
}}
aria-label={emoji ? t('Remove icon') : t('Add icon')}
color="neutral"
variant="tertiary"
icon={
emoji ? (
<RemoveEmojiSVG width={24} height={24} aria-hidden="true" />
) : (
<AddEmojiSVG width={24} height={24} aria-hidden="true" />
)
}
style={{ width: 'fit-content' }}
>
{emoji ? t('Remove icon') : t('Add icon')}
</Button>
)}
</Box>
<DocTitle doc={doc} />
<DocHeaderInfo doc={doc} />
</Box>
<HorizontalSeparator $margin={{ top: '24px' }} />
</Box>
</>
);
@@ -2,7 +2,7 @@ import { t } from 'i18next';
import PublicSVG from '@/assets/icons/ui-kit/public.svg';
import ProtedtedSVG from '@/assets/icons/ui-kit/vpn_lock.svg';
import { Text } from '@/components';
import { Box, Text } from '@/components';
import { useConfig } from '@/core';
import {
Doc,
@@ -40,7 +40,7 @@ export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
}
return (
<>
<Box $direction="row">
<Text
$variation="tertiary"
$size="s"
@@ -55,7 +55,7 @@ export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
<Text $variation="tertiary" $size="s">
{dateToDisplay}
</Text>
</>
</Box>
);
};
@@ -42,11 +42,7 @@ export const DocTitleText = () => {
return (
<Box className={CLASS_DOC_TITLE} $direction="row" $align="center">
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
>
<Text as="h2" $margin="none" $size={isMobile ? 'h4' : 'h2'}>
{currentDoc?.title || untitledDocument}
</Text>
</Box>
@@ -110,7 +106,7 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
};
const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isLargeScreen } = useResponsiveStore();
const { isSmallMobile } = useResponsiveStore();
const { t } = useTranslation();
const { isTopRoot } = useDocUtils(doc);
const { untitledDocument } = useTrans();
@@ -200,7 +196,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
$minHeight="40px"
>
{!isTopRoot && <DocTitleEmojiPicker doc={doc} />}
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
<Box
as="span"
@@ -217,7 +212,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}
onPasteCapture={handlePaste}
onDropCapture={handleDrop}
$padding={{ right: 'big' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
@@ -227,11 +221,12 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
pointer-events: none;
font-style: italic;
}
font-size: ${isLargeScreen
? 'var(--c--globals--font--sizes--h2)'
: 'var(--c--globals--font--sizes--sm)'};
font-size: ${isSmallMobile
? 'var(--c--globals--font--sizes--h4)'
: 'var(--c--globals--font--sizes--h2)'};
font-weight: 700;
outline: none;
width: 100%;
`}
>
{titleDisplay}
@@ -1,42 +1,36 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import {
DropdownMenu,
DropdownMenuOption,
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import AddLinkSVG from '@/assets/icons/ui-kit/add_link.svg';
import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg';
import DeleteSVG from '@/assets/icons/ui-kit/delete.svg';
import DownloadSVG from '@/assets/icons/ui-kit/download.svg';
import RemoveEmojiSVG from '@/assets/icons/ui-kit/face-remove.svg';
import AddEmojiSVG from '@/assets/icons/ui-kit/face.svg';
import SharedSVG from '@/assets/icons/ui-kit/group.svg';
import HistorySVG from '@/assets/icons/ui-kit/history.svg';
import KeepSVG from '@/assets/icons/ui-kit/keep.svg';
import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg';
import MarkdownCopySVG from '@/assets/icons/ui-kit/markdown_copy.svg';
import {
Box,
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
KEY_LIST_FAVORITE_DOC,
getEmojiAndTitle,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
useDocTitleUpdate,
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import { useFocusStore, useResponsiveStore } from '@/stores';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -57,6 +51,14 @@ const ModalSelectVersion = dynamic(
{ ssr: false },
);
const DocShareModal = dynamic(
() =>
import('@/docs/doc-share/components/DocShareModal').then((mod) => ({
default: mod.DocShareModal,
})),
{ ssr: false },
);
const ModalExport =
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false'
? dynamic(
@@ -76,16 +78,18 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const treeContext = useTreeContext<Doc>();
const router = useRouter();
const { isChild, isTopRoot } = useDocUtils(doc);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { isTopRoot } = useDocUtils(doc);
const { authenticated } = useAuth();
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
const [openDropdown, setOpenDropdown] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
const shareModal = useModal();
const selectHistoryModal = useModal();
const { addLastFocus, restoreFocus } = useFocusStore();
const { isSmallMobile, isMobile } = useResponsiveStore();
const { restoreFocus } = useFocusStore();
const { isMobile } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: (data) => {
@@ -99,19 +103,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
});
// Emoji Management
const { emoji } = getEmojiAndTitle(doc.title ?? '');
const { updateDocEmoji } = useDocTitleUpdate();
const options: DropdownMenuOption[] = [
{
label: t('Export'),
icon: <DownloadSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsModalExportOpen(true);
},
show: !!ModalExport && isSmallMobile,
},
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: doc.is_favorite ? (
@@ -126,55 +118,53 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
makeFavoriteDoc.mutate({ id: doc.id });
}
},
isHidden: !doc.abilities.favorite,
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
{
label: t('Version history'),
icon: <HistorySVG width={24} height={24} aria-hidden="true" />,
disabled: !doc.abilities.versions_list,
callback: () => {
selectHistoryModal.open();
},
show: !isMobile,
showSeparator: isTopRoot ? true : false,
},
{
label: t('Remove emoji'),
icon: <RemoveEmojiSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '');
},
showSeparator: true,
show: !!emoji && doc.abilities.partial_update && !isTopRoot,
},
{
label: t('Add emoji'),
icon: <AddEmojiSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
const today = new Date();
const isAprilFools = today.getMonth() === 3 && today.getDate() === 1;
updateDocEmoji(doc.id, doc.title ?? '', isAprilFools ? '🐟' : '📄');
},
showSeparator: true,
show: !emoji && doc.abilities.partial_update && !isTopRoot,
},
{
label: t('Copy link'),
icon: <AddLinkSVG width={24} height={24} aria-hidden="true" />,
callback: copyDocLink,
},
{
label: t('Share'),
icon: <SharedSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
shareModal.open();
},
isHidden: !isTopRoot || !authenticated,
},
{
label: t('Download'),
icon: <DownloadSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsModalExportOpen(true);
},
isHidden: !ModalExport,
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: <MarkdownCopySVG width={24} height={24} aria-hidden="true" />,
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
showSeparator: isMobile || !doc.abilities.versions_list,
},
{
label: t('Version history'),
icon: <HistorySVG width={24} height={24} aria-hidden="true" />,
isDisabled: !doc.abilities.versions_list,
callback: () => {
selectHistoryModal.open();
},
isHidden: isMobile || !doc.abilities.versions_list,
showSeparator: true,
},
{
label: t('Duplicate'),
icon: <ContentCopySVG width={24} height={24} aria-hidden="true" />,
disabled: !doc.abilities.duplicate,
isDisabled: !doc.abilities.duplicate,
callback: () => {
duplicateDoc({
docId: doc.id,
@@ -182,65 +172,40 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
canSave: doc.abilities.partial_update,
});
},
isHidden: !doc.abilities.duplicate,
showSeparator: true,
},
{
label: isChild ? t('Delete sub-document') : t('Delete document'),
label: t('Delete'),
icon: <DeleteSVG width={24} height={24} aria-hidden="true" />,
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
isHidden: !doc.abilities.destroy,
},
];
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
return (
<Box
$margin={{ left: 'auto' }}
$direction="row"
$align="center"
$gap="0.5rem 1.5rem"
$wrap={isSmallMobile ? 'wrap' : 'nowrap'}
className="--docs--doc-toolbox"
>
<Box
$direction="row"
$align="center"
$margin={{ left: 'auto' }}
$gap={spacingsTokens['2xs']}
<>
<DropdownMenu
options={options}
isOpen={openDropdown}
shouldCloseOnInteractOutside={() => true}
onOpenChange={setOpenDropdown}
>
{!isSmallMobile && ModalExport && (
<Button
data-testid="doc-open-modal-download-button"
variant="tertiary"
icon={
<Icon iconName="download" $color="inherit" aria-hidden={true} />
}
onClick={(e) => {
addLastFocus(e.currentTarget);
setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
aria-label={t('Export the document')}
/>
)}
<DropdownMenu
options={options}
label={t('Open the document options')}
buttonCss={css`
padding: ${spacingsTokens['xs']};
${isSmallMobile
? css`
border: 1px solid ${colorsTokens['gray-300']};
`
: ''}
`}
>
<IconOptions aria-hidden="true" isHorizontal $color="inherit" />
</DropdownMenu>
</Box>
<Button
aria-label={t('Open the document options')}
size="small"
icon={<MoreSVG width={24} height={24} aria-hidden="true" />}
color="neutral"
variant="tertiary"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setOpenDropdown((o) => !o);
}}
/>
</DropdownMenu>
{isModalExportOpen && ModalExport && (
<ModalExport
@@ -285,6 +250,16 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
doc={doc}
/>
)}
</Box>
{shareModal.isOpen && (
<DocShareModal
onClose={() => {
shareModal.close();
restoreFocus();
}}
doc={doc}
isRootDoc={treeContext?.root?.id === doc.id}
/>
)}
</>
);
};
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { css } from 'styled-components';
import { Box } from '@/components';
import { Box, Card } from '@/components';
import { useCunninghamTheme } from '@/cunningham/useCunninghamTheme';
import { useDocStore } from '@/docs/doc-management/stores/useDocStore';
import { DocShareButton } from '@/features/docs/doc-share/components/DocShareButton';
@@ -9,6 +9,8 @@ import { LeftPanelCollapseButton } from '@/features/left-panel/components/LeftPa
import { RightPanelCollapseButton } from '@/features/right-panel/components/RightPanelCollapseButton';
import { useResponsiveStore } from '@/stores';
import { DocToolBox } from './DocToolBox';
/**
* Sticky bar trick (desktop):
* - MainContent has padding `base`; we extend the bar width and apply
@@ -23,7 +25,6 @@ export const FloatingBar = () => {
const isDeletedDoc = !!currentDoc?.deleted_at;
const FLOATING_STYLES = useMemo(() => {
const base = spacingsTokens['base'];
const sm = spacingsTokens['sm'];
return css`
position: sticky;
@@ -33,7 +34,6 @@ export const FloatingBar = () => {
width: 100%;
min-height: 64px;
padding: ${sm};
margin-top: calc(-${base});
z-index: 21; // Under editor select box but above other elements (e.g., doc title, suggestion menu)
align-items: flex-start;
isolation: isolate;
@@ -76,7 +76,20 @@ export const FloatingBar = () => {
{isLargeScreen ? <LeftPanelCollapseButton /> : <Box />}
<Box $direction="row" $align="center" $gap="2xs">
{!isDeletedDoc && currentDoc && <DocShareButton doc={currentDoc} />}
<RightPanelCollapseButton />
<Card
className="--docs--right-panel-collapse-button"
$direction="row"
$css={css`
padding: var(--c--globals--spacings--xxxs);
align-items: center;
gap: var(--c--globals--spacings--xxxs);
border-radius: var(--c--globals--spacings--xs);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
`}
>
<RightPanelCollapseButton />
{!isDeletedDoc && currentDoc && <DocToolBox doc={currentDoc} />}
</Card>
</Box>
</Box>
);
@@ -1,7 +1,5 @@
import { useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Card } from '@/components';
import { CommentSideBarButton } from '@/features/docs/doc-editor/components/comments/CommentSideBar';
import { useEditorStore } from '@/features/docs/doc-editor/stores/useEditorStore';
import { useHeadingStore } from '@/features/docs/doc-editor/stores/useHeadingStore';
@@ -30,19 +28,9 @@ export const RightPanelCollapseButton = () => {
}
return (
<Card
className="--docs--right-panel-collapse-button"
$direction="row"
$css={css`
padding: var(--c--globals--spacings--xxxs);
align-items: center;
gap: var(--c--globals--spacings--xxxs);
border-radius: var(--c--globals--spacings--xs);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
`}
>
<>
{hasHeadings && <TableContentSideBarButton />}
{hasThreads && <CommentSideBarButton />}
</Card>
</>
);
};
@@ -1,10 +1,9 @@
import { css, keyframes } from 'styled-components';
import { Box, BoxType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
import { SkeletonCircle, SkeletonLine } from './SkeletionUI';
import { SkeletonLine } from './SkeletionUI';
export const DocEditorSkeleton = () => {
return (
@@ -16,6 +15,7 @@ export const DocEditorSkeleton = () => {
$height="100%"
className="--docs--doc-editor-skeleton"
>
<SkeletonEditorFloatingBar />
<SkeletonEditorHeader />
<SkeletonEditorCore />
</Box>
@@ -23,58 +23,63 @@ export const DocEditorSkeleton = () => {
);
};
const SkeletonEditorFloatingBar = () => {
return (
<Box
className="--docs--doc-editor-floating-bar-skeleton"
$width="100%"
$padding={{ top: 'sm', horizontal: 'sm' }}
$direction="row"
$justify="space-between"
>
<SkeletonLine $width="42px" $height="42px" />
<Box $direction="row" $gap="2xs" $align="center">
<SkeletonLine $width="75px" $height="42px" />
<SkeletonLine $width="42px" $height="42px" />
</Box>
</Box>
);
};
const SkeletonEditorHeader = () => {
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
return (
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
$padding={{ horizontal: isDesktop ? '54px' : 'base', top: '55px' }}
className="--docs--doc-editor-header-skeleton"
$width="100%"
$gap="base"
>
<Box
$direction="row"
$align="center"
$width="100%"
$padding={{ top: isDesktop ? '65px' : 'md' }}
$gap={spacingsTokens['base']}
$padding={{ bottom: 'xs' }}
>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$width="100%"
$padding={{ bottom: 'xs' }}
$maxWidth="100%"
>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
{/* Title and metadata skeleton */}
<Box $gap="0.25rem" $css="flex:1;">
{/* Title - "Untitled Document" style */}
<SkeletonLine $width="35%" $height="40px" />
{/* Title and metadata skeleton */}
<Box $gap="base" $css="flex:1;">
{/* Title - "Untitled Document" style */}
<SkeletonLine $width="255px" $height="40px" />
{/* Metadata (role and last update) */}
<Box $direction="row" $gap="0.5rem" $align="center">
<SkeletonLine $maxWidth="260px" $height="12px" />
</Box>
</Box>
{/* Toolbox skeleton (buttons) */}
<Box $direction="row" $gap={spacingsTokens['t']} $align="center">
{/* Download icon */}
<SkeletonCircle $width="40px" $height="40px" />
{/* Menu icon */}
<SkeletonCircle $width="40px" $height="40px" />
{/* Metadata (role and last update) */}
<Box $direction="row" $gap="0.5rem" $align="center">
<SkeletonLine $maxWidth="210px" $height="12px" />
</Box>
</Box>
</Box>
{/* Separator */}
<SkeletonLine $height="1px" />
</Box>
{/* Separator */}
<SkeletonLine $height="1px" />
</Box>
);
};
@@ -0,0 +1,2 @@
const SvgMock = () => null;
export default SvgMock;
@@ -1,4 +1,6 @@
/// <reference types="vitest" />
import path from 'path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
@@ -16,6 +18,12 @@ export default defineConfig({
'process.env.NODE_ENV': 'test',
},
resolve: {
alias: [
{
find: /^.*\.svg$/,
replacement: path.resolve(__dirname, 'src/tests/__mocks__/svgMock.tsx'),
},
],
tsconfigPaths: true,
},
});