(frontend) Move doc modal

We can now move a doc to another doc from a search
modal. It will make it easier to move a doc
without having to scroll through the doc grid to
find the destination doc.
We kept most of the logic implemented in the
doc grid dnd.
This commit is contained in:
Anthony LC
2026-02-20 17:22:14 +01:00
parent 217af2e2a8
commit 2718321fbe
14 changed files with 480 additions and 124 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to
- ✨(frontend) Can print a doc #1832 - ✨(frontend) Can print a doc #1832
- ✨(backend) manage reconciliation requests for user accounts #1878 - ✨(backend) manage reconciliation requests for user accounts #1878
- 👷(CI) add GHCR workflow for forked repo testing #1851 - 👷(CI) add GHCR workflow for forked repo testing #1851
- ✨(frontend) Move doc modal #1886
- ⚡️(backend) remove content from Document serializer when asked #1910 - ⚡️(backend) remove content from Document serializer when asked #1910
- ✨(backend) allow the duplication of subpages #1893 - ✨(backend) allow the duplication of subpages #1893
- ✨(backend) Onboarding docs for new users #1891 - ✨(backend) Onboarding docs for new users #1891

View File

@@ -1,10 +1,19 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs, toggleHeaderMenu } from './utils-common'; import {
createDoc,
getGridRow,
mockedListDocs,
toggleHeaderMenu,
verifyDocName,
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages'; import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc grid dnd', () => { test.describe('Doc grid move', () => {
test('it creates a doc', async ({ page, browserName }) => { test('it checks drag and drop functionality', async ({
page,
browserName,
}) => {
await page.goto('/'); await page.goto('/');
const header = page.locator('header').first(); const header = page.locator('header').first();
await createDoc(page, 'Draggable doc', browserName, 1); await createDoc(page, 'Draggable doc', browserName, 1);
@@ -29,7 +38,7 @@ test.describe('Doc grid dnd', () => {
await expect(draggableElement).toBeVisible(); await expect(draggableElement).toBeVisible();
await expect(dropZone).toBeVisible(); await expect(dropZone).toBeVisible();
// Obtenir les positions des éléments // Get the position of the elements
const draggableBoundingBox = await draggableElement.boundingBox(); const draggableBoundingBox = await draggableElement.boundingBox();
const dropZoneBoundingBox = await dropZone.boundingBox(); const dropZoneBoundingBox = await dropZone.boundingBox();
@@ -46,7 +55,7 @@ test.describe('Doc grid dnd', () => {
); );
await page.mouse.down(); await page.mouse.down();
// Déplacer vers la zone cible // Move to the target zone
await page.mouse.move( await page.mouse.move(
dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2, dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2,
dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2, dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2,
@@ -161,6 +170,55 @@ test.describe('Doc grid dnd', () => {
await page.mouse.up(); await page.mouse.up();
}); });
test('it moves a doc from the doc search modal', async ({
page,
browserName,
}) => {
await page.goto('/');
const [titleDoc1] = await createDoc(page, 'Draggable doc', browserName, 1);
await page.getByRole('button', { name: 'Back to homepage' }).click();
const [titleDoc2] = await createDoc(page, 'Droppable doc', browserName, 1);
await page.getByRole('button', { name: 'Back to homepage' }).click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid.getByText(titleDoc1)).toBeVisible();
await expect(docsGrid.getByText(titleDoc2)).toBeVisible();
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
).toBeVisible();
const input = page.getByRole('combobox', { name: 'Quick search input' });
await input.click();
await input.fill(titleDoc2);
await expect(page.getByRole('option').getByText(titleDoc2)).toBeVisible();
// Select the first result
await page.keyboard.press('Enter');
// The CTA should get the focus
await page.keyboard.press('Tab');
// Validate the move action
await page.keyboard.press('Enter');
await expect(docsGrid.getByText(titleDoc1)).toBeHidden();
await docsGrid
.getByRole('link', { name: `Open document ${titleDoc2}` })
.click();
await verifyDocName(page, titleDoc2);
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(titleDoc1)).toBeVisible();
});
}); });
test.describe('Doc grid dnd mobile', () => { test.describe('Doc grid dnd mobile', () => {

View File

@@ -15,6 +15,7 @@ export type QuickSearchAction = {
export type QuickSearchData<T> = { export type QuickSearchData<T> = {
groupName: string; groupName: string;
groupKey?: string;
elements: T[]; elements: T[];
emptyString?: string; emptyString?: string;
startActions?: QuickSearchAction[]; startActions?: QuickSearchAction[];
@@ -30,13 +31,13 @@ export type QuickSearchProps = {
loading?: boolean; loading?: boolean;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
groupKey?: string;
}; };
export const QuickSearch = ({ export const QuickSearch = ({
onFilter, onFilter,
inputContent, inputContent,
inputValue, inputValue,
loading,
showInput = true, showInput = true,
label, label,
placeholder, placeholder,
@@ -72,10 +73,10 @@ export const QuickSearch = ({
tabIndex={-1} tabIndex={-1}
value={selectedValue} value={selectedValue}
onValueChange={handleValueChange} onValueChange={handleValueChange}
disablePointerSelection
> >
{showInput && ( {showInput && (
<QuickSearchInput <QuickSearchInput
loading={loading}
withSeparator={hasChildrens(children)} withSeparator={hasChildrens(children)}
inputValue={inputValue} inputValue={inputValue}
onFilter={onFilter} onFilter={onFilter}

View File

@@ -28,7 +28,7 @@ export const QuickSearchGroup = <T,>({
{group.startActions?.map((action, index) => { {group.startActions?.map((action, index) => {
return ( return (
<QuickSearchItem <QuickSearchItem
key={`${group.groupName}-action-${index}`} key={`${group.groupKey ?? group.groupName}-start-actions-${index}`}
onSelect={action.onSelect} onSelect={action.onSelect}
> >
{action.content} {action.content}
@@ -38,8 +38,8 @@ export const QuickSearchGroup = <T,>({
{group.elements.map((groupElement, index) => { {group.elements.map((groupElement, index) => {
return ( return (
<QuickSearchItem <QuickSearchItem
id={`${group.groupName}-element-${index}`} id={`${group.groupKey ?? group.groupName}-element-${index}`}
key={`${group.groupName}-element-${index}`} key={`${group.groupKey ?? group.groupName}-element-${index}`}
onSelect={() => { onSelect={() => {
onSelect?.(groupElement); onSelect?.(groupElement);
}} }}
@@ -51,7 +51,7 @@ export const QuickSearchGroup = <T,>({
{group.endActions?.map((action, index) => { {group.endActions?.map((action, index) => {
return ( return (
<QuickSearchItem <QuickSearchItem
key={`${group.groupName}-action-${index}`} key={`${group.groupKey ?? group.groupName}-end-actions-${index}`}
onSelect={action.onSelect} onSelect={action.onSelect}
> >
{action.content} {action.content}

View File

@@ -1,6 +1,5 @@
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import { Command } from 'cmdk'; import { Command } from 'cmdk';
import { ReactNode } from 'react'; import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { HorizontalSeparator } from '@/components'; import { HorizontalSeparator } from '@/components';
@@ -9,19 +8,16 @@ import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box'; import { Box } from '../Box';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
type Props = { type QuickSearchInputProps = {
loading?: boolean;
inputValue?: string; inputValue?: string;
onFilter?: (str: string) => void; onFilter?: (str: string) => void;
placeholder?: string; placeholder?: string;
children?: ReactNode;
withSeparator?: boolean; withSeparator?: boolean;
listId?: string; listId?: string;
onUserInteract?: () => void; onUserInteract?: () => void;
isExpanded?: boolean; isExpanded?: boolean;
}; };
export const QuickSearchInput = ({ export const QuickSearchInput = ({
loading,
inputValue, inputValue,
onFilter, onFilter,
placeholder, placeholder,
@@ -30,7 +26,7 @@ export const QuickSearchInput = ({
listId, listId,
onUserInteract, onUserInteract,
isExpanded, isExpanded,
}: Props) => { }: PropsWithChildren<QuickSearchInputProps>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
@@ -52,14 +48,7 @@ export const QuickSearchInput = ({
$gap={spacingsTokens['2xs']} $gap={spacingsTokens['2xs']}
$padding={{ horizontal: 'base', vertical: 'sm' }} $padding={{ horizontal: 'base', vertical: 'sm' }}
> >
{!loading && ( <Icon iconName="search" $variation="secondary" aria-hidden="true" />
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
)}
{loading && (
<div>
<Loader size="small" />
</div>
)}
<Command.Input <Command.Input
autoFocus={true} autoFocus={true}
aria-label={t('Quick search input')} aria-label={t('Quick search input')}

View File

@@ -22,7 +22,7 @@ export const QuickSearchStyle = createGlobalStyle`
padding: var(--c--globals--spacings--xs); padding: var(--c--globals--spacings--xs);
background: white; background: white;
outline: none; outline: none;
color: var(--c--globals--colors--gray-1000); color: var(--c--contextuals--content--semantic--neutral--primary);
border-radius: var(--c--globals--spacings--0); border-radius: var(--c--globals--spacings--0);
&::placeholder { &::placeholder {

View File

@@ -181,6 +181,7 @@ export const SearchPage = ({
setSearch(value); setSearch(value);
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoComplete="off"
/> />
</Box> </Box>
<Box <Box

View File

@@ -12,15 +12,19 @@ import { DocSearchItem } from './DocSearchItem';
type DocSearchContentProps = { type DocSearchContentProps = {
search: string; search: string;
filters: DocSearchFiltersValues; filters: DocSearchFiltersValues;
filterResults?: (doc: Doc) => boolean;
onSelect: (doc: Doc) => void; onSelect: (doc: Doc) => void;
onLoadingChange?: (loading: boolean) => void; onLoadingChange?: (loading: boolean) => void;
renderSearchElement?: (doc: Doc) => React.ReactNode;
}; };
export const DocSearchContent = ({ export const DocSearchContent = ({
search, search,
filters, filters,
filterResults,
onSelect, onSelect,
onLoadingChange, onLoadingChange,
renderSearchElement,
}: DocSearchContentProps) => { }: DocSearchContentProps) => {
const { const {
data, data,
@@ -38,10 +42,15 @@ export const DocSearchContent = ({
const loading = isFetching || isRefetching || isLoading; const loading = isFetching || isRefetching || isLoading;
const docsData: QuickSearchData<Doc> = useMemo(() => { const docsData: QuickSearchData<Doc> = useMemo(() => {
const docs = data?.pages.flatMap((page) => page.results) || []; let docs = data?.pages.flatMap((page) => page.results) || [];
if (filterResults) {
docs = docs.filter(filterResults);
}
return { return {
groupName: docs.length > 0 ? t('Select a document') : '', groupName: docs.length > 0 ? t('Select a document') : '',
groupKey: 'docs',
elements: search ? docs : [], elements: search ? docs : [],
emptyString: t('No document found'), emptyString: t('No document found'),
endActions: hasNextPage endActions: hasNextPage
@@ -52,7 +61,7 @@ export const DocSearchContent = ({
] ]
: [], : [],
}; };
}, [search, data?.pages, fetchNextPage, hasNextPage]); }, [search, data?.pages, fetchNextPage, hasNextPage, filterResults]);
useEffect(() => { useEffect(() => {
onLoadingChange?.(loading); onLoadingChange?.(loading);
@@ -62,7 +71,9 @@ export const DocSearchContent = ({
<QuickSearchGroup <QuickSearchGroup
onSelect={onSelect} onSelect={onSelect}
group={docsData} group={docsData}
renderElement={(doc) => <DocSearchItem doc={doc} />} renderElement={
renderSearchElement ?? ((doc) => <DocSearchItem doc={doc} />)
}
/> />
); );
}; };

View File

@@ -1,3 +1,4 @@
export * from './DocSearchContent';
export * from './DocSearchModal'; export * from './DocSearchModal';
export * from './DocSearchFilters'; export * from './DocSearchFilters';
export * from './DocSearchSubPageContent'; export * from './DocSearchSubPageContent';

View File

@@ -0,0 +1,260 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import Image from 'next/image';
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
import { Box, ButtonCloseModal, Text } from '@/components';
import { QuickSearch } from '@/components/quick-search';
import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management';
import { DocSearchContent, DocSearchTarget } from '@/docs/doc-search';
import EmptySearchIcon from '@/docs/doc-search/assets/illustration-docs-empty.png';
import { useResponsiveStore } from '@/stores';
import { DocsGridItemDate, DocsGridItemTitle } from './DocsGridItem';
export const DocMoveModalStyle = createGlobalStyle`
.c__modal--full .c__modal__scroller {
height: 100vh;
}
.c__modal__scroller:has(.quick-search-container){
display: flex;
flex-direction: column;
.quick-search-container [cmdk-list] {
overflow-y: auto;
}
.c__modal__title {
padding-inline: var(--c--globals--spacings--md);
padding-block: var(--c--globals--spacings--base);
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
}
.c__modal__footer {
margin-top: 0rem;
}
.quick-search-input{
padding-inline: var(--c--globals--spacings--md);
}
.c__modal__footer{
border-top: 1px solid var(--c--contextuals--border--surface--primary);
}
.quick-search-container [cmdk-item] {
border-radius: 4px;
}
.quick-search-container [cmdk-item][data-selected='true'] {
background: var(--c--contextuals--background--semantic--contextual--primary);
}
}
`;
type DocMoveModalGlobalProps = {
doc: Doc;
isOpen: boolean;
onClose: () => void;
};
export const DocMoveModal = ({
doc,
isOpen,
onClose,
}: DocMoveModalGlobalProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [docSelected, setDocSelected] = useState<Doc>();
const { untitledDocument } = useTrans();
const docTitle = doc.title || untitledDocument;
const { mutate: moveDoc } = useMoveDoc(true);
const [search, setSearch] = useState('');
const { isDesktop } = useResponsiveStore();
const handleInputSearch = useDebouncedCallback(setSearch, 700);
const handleSelect = (docSelected: Doc) => {
setDocSelected(docSelected);
};
const handleMoveDoc = () => {
if (!docSelected?.id) {
return;
}
moveDoc({
sourceDocumentId: doc.id,
targetDocumentId: docSelected.id,
position: TreeViewMoveModeEnum.FIRST_CHILD,
});
};
return (
<>
<DocMoveModalStyle />
<Modal
isOpen={isOpen}
onClose={onClose}
closeOnClickOutside
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
hideCloseButton
aria-label={t('Move Modal')}
rightActions={
<Box $direction="row-reverse" $padding="md" $gap="small">
<Button
aria-label={t('Move the document to the selected location')}
variant="primary"
fullWidth
onClick={() => {
handleMoveDoc();
}}
disabled={!docSelected}
>
{t('Move here')}
</Button>
<Button
aria-label={t('Cancel the move')}
variant="secondary"
fullWidth
onClick={onClose}
>
{t('Cancel')}
</Button>
</Box>
}
title={
<Box>
<Box
$direction="row"
$justify="space-between"
$align="center"
$width="100%"
>
<Text as="h2" $margin="0" $size="h6" $align="flex-start">
{t('Move')}
</Text>
<ButtonCloseModal
aria-label={t('Close the move modal')}
onClick={() => onClose()}
/>
</Box>
<Box $margin={{ top: 'sm' }}>
<Text
$size="sm"
$variation="secondary"
$display="inline"
$weight="normal"
$textAlign="left"
>
<Trans t={t}>
Choose the new location for <strong>{docTitle}</strong>.
</Trans>
</Text>
</Box>
</Box>
}
>
<Box
aria-label={t('Move modal')}
$direction="column"
$justify="space-between"
className="--docs--doc-move-modal"
onKeyDown={(e) => {
// Close modal on Escape
if (e.key === 'Escape') {
onClose();
return;
}
// Prevent keyboard events from bubbling to parent components (e.g., drag and drop)
e.stopPropagation();
}}
>
<QuickSearch
placeholder={t('Search for a doc')}
loading={loading}
onFilter={handleInputSearch}
>
<Box
$padding={{ horizontal: 'md' }}
$height={isDesktop ? 'min(60vh, 500px)' : 'calc(100vh - 260px)'}
>
{search.length === 0 && (
<Box
$direction="column"
$height="100%"
$align="center"
$justify="center"
>
<Image
width={320}
src={EmptySearchIcon}
alt={t('No active search')}
style={{ maxWidth: '100%', height: 'auto' }}
priority
/>
</Box>
)}
{search && (
<Box>
<DocSearchContent
search={search}
filters={{ target: DocSearchTarget.ALL }}
filterResults={(docResults) =>
docResults.id !== doc.id && docResults.abilities.move
}
onSelect={handleSelect}
onLoadingChange={setLoading}
renderSearchElement={(docSearch) => {
const isSelected = docSelected?.id === docSearch.id;
return (
<Box
className="--docs--doc-move-modal-search-item"
$direction="row"
$align="center"
$justify="space-between"
$width="100%"
$gap="sm"
$padding="3xs"
$css={css`
background-color: ${isSelected
? 'var(--c--contextuals--background--semantic--brand--tertiary)'
: 'transparent'};
border: 1px solid
${isSelected
? 'var(--c--contextuals--border--semantic--brand--tertiary)'
: 'transparent'};
border-radius: var(--c--globals--spacings--3xs);
/* Arrow key navigation highlight */
&[data-selected='true'] {
${!isSelected &&
`
background-color: var(--c--contextuals--background--semantic--contextual--primary);
border-color: transparent;
`}
}
`}
aria-selected={isSelected}
>
<DocsGridItemTitle
doc={docSearch}
withTooltip={false}
/>
<DocsGridItemDate
doc={docSearch}
isDesktop={isDesktop}
isInTrashbin={false}
/>
</Box>
);
}}
/>
</Box>
)}
</Box>
</QuickSearch>
</Box>
</Modal>
</>
);
};

View File

@@ -11,9 +11,12 @@ import {
useCreateFavoriteDoc, useCreateFavoriteDoc,
useDeleteFavoriteDoc, useDeleteFavoriteDoc,
useDuplicateDoc, useDuplicateDoc,
useTrans,
} from '@/docs/doc-management'; } from '@/docs/doc-management';
import { DocShareModal } from '@/docs/doc-share'; import { DocShareModal } from '@/docs/doc-share';
import { DocMoveModal } from './DocMoveModal';
interface DocsGridActionsProps { interface DocsGridActionsProps {
doc: Doc; doc: Doc;
} }
@@ -23,6 +26,8 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const deleteModal = useModal(); const deleteModal = useModal();
const shareModal = useModal(); const shareModal = useModal();
const importModal = useModal();
const { untitledDocument } = useTrans();
const { mutate: duplicateDoc } = useDuplicateDoc(); const { mutate: duplicateDoc } = useDuplicateDoc();
@@ -56,6 +61,15 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
testId: `docs-grid-actions-share-${doc.id}`, testId: `docs-grid-actions-share-${doc.id}`,
}, },
{
label: t('Move into a doc'),
icon: 'copy_all',
callback: () => {
importModal.open();
},
testId: `docs-grid-actions-import-${doc.id}`,
show: doc.abilities.move,
},
{ {
label: t('Duplicate'), label: t('Duplicate'),
icon: 'content_copy', icon: 'content_copy',
@@ -78,7 +92,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
}, },
]; ];
const documentTitle = doc.title || t('Untitled document'); const documentTitle = doc.title || untitledDocument;
const menuLabel = t('Open the menu of actions for the document: {{title}}', { const menuLabel = t('Open the menu of actions for the document: {{title}}', {
title: documentTitle, title: documentTitle,
}); });
@@ -116,6 +130,13 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
{shareModal.isOpen && ( {shareModal.isOpen && (
<DocShareModal doc={doc} onClose={shareModal.close} /> <DocShareModal doc={doc} onClose={shareModal.close} />
)} )}
{importModal.isOpen && (
<DocMoveModal
doc={doc}
onClose={importModal.close}
isOpen={importModal.isOpen}
/>
)}
</> </>
); );
}; };

View File

@@ -7,7 +7,7 @@ import { css } from 'styled-components';
import { Box, Icon, StyledLink, Text } from '@/components'; import { Box, Icon, StyledLink, Text } from '@/components';
import { useConfig } from '@/core'; import { useConfig } from '@/core';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { Doc, LinkReach, SimpleDocItem } from '@/docs/doc-management'; import { Doc, LinkReach, SimpleDocItem, useTrans } from '@/docs/doc-management';
import { useDate } from '@/hooks'; import { useDate } from '@/hooks';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
@@ -26,14 +26,12 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const target = searchParams.get('target'); const target = searchParams.get('target');
const isInTrashbin = target === 'trashbin'; const isInTrashbin = target === 'trashbin';
const { untitledDocument } = useTrans();
const { t } = useTranslation(); const { t } = useTranslation();
const { isDesktop } = useResponsiveStore(); const { isDesktop } = useResponsiveStore();
const { flexLeft, flexRight } = useResponsiveDocGrid(); const { flexLeft, flexRight } = useResponsiveDocGrid();
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isShared = isPublic || isAuthenticated;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -62,7 +60,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
`} `}
className="--docs--doc-grid-item" className="--docs--doc-grid-item"
aria-label={t('Open document: {{title}}', { aria-label={t('Open document: {{title}}', {
title: doc.title || t('Untitled document'), title: doc.title || untitledDocument,
})} })}
> >
<Box <Box
@@ -82,72 +80,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
href={`/docs/${doc.id}`} href={`/docs/${doc.id}`}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<Box <DocsGridItemTitle doc={doc} withTooltip={!dragMode} />
data-testid={`docs-grid-name-${doc.id}`}
$direction="row"
$align="center"
$gap={spacingsTokens.xs}
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{isShared && (
<Box
$padding={{ top: !isDesktop ? '4xs' : undefined }}
$css={
!isDesktop
? css`
align-self: flex-start;
`
: undefined
}
>
{dragMode && (
<>
<Icon
$layer="background"
$theme="neutral"
$variation="primary"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
<span className="sr-only">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</span>
</>
)}
{!dragMode && (
<Tooltip
content={
<Text $textAlign="center">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</Text>
}
placement="top"
>
<div>
<Icon
$layer="background"
$theme="neutral"
$variation="primary"
$size="sm"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
<span className="sr-only">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</span>
</div>
</Tooltip>
)}
</Box>
)}
</Box>
</StyledLink> </StyledLink>
</Box> </Box>
@@ -159,11 +92,13 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
$gap="32px" $gap="32px"
role="gridcell" role="gridcell"
> >
<DocsGridItemDate <StyledLink href={`/docs/${doc.id}`} tabIndex={-1}>
doc={doc} <DocsGridItemDate
isDesktop={isDesktop} doc={doc}
isInTrashbin={isInTrashbin} isDesktop={isDesktop}
/> isInTrashbin={isInTrashbin}
/>
</StyledLink>
<Box $direction="row" $align="center" $gap={spacingsTokens.lg}> <Box $direction="row" $align="center" $gap={spacingsTokens.lg}>
{isDesktop && ( {isDesktop && (
@@ -181,6 +116,86 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
); );
}; };
export const DocsGridItemTitle = ({
doc,
withTooltip,
}: {
doc: Doc;
withTooltip: boolean;
}) => {
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isShared = isPublic || isAuthenticated;
return (
<Box
data-testid={`docs-grid-name-${doc.id}`}
$direction="row"
$align="center"
$gap={spacingsTokens.xs}
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{isShared && (
<Box
$padding={{ top: !isDesktop ? '4xs' : undefined }}
$css={
!isDesktop
? css`
align-self: flex-start;
`
: undefined
}
>
{withTooltip ? (
<Tooltip
content={
<Text $textAlign="center">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</Text>
}
placement="top"
>
<Box>
<IconPublic isPublic={isPublic} />
</Box>
</Tooltip>
) : (
<IconPublic isPublic={isPublic} />
)}
</Box>
)}
</Box>
);
};
const IconPublic = ({ isPublic }: { isPublic: boolean }) => {
const { t } = useTranslation();
return (
<>
<Icon
$layer="background"
$theme="neutral"
$variation="primary"
$size="sm"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
<span className="sr-only">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</span>
</>
);
};
export const DocsGridItemDate = ({ export const DocsGridItemDate = ({
doc, doc,
isDesktop, isDesktop,
@@ -210,15 +225,8 @@ export const DocsGridItemDate = ({
} }
return ( return (
<StyledLink href={`/docs/${doc.id}`} tabIndex={-1}> <Text $size="xs" $layer="background" $theme="neutral" $variation="primary">
<Text {dateToDisplay}
$size="xs" </Text>
$layer="background"
$theme="neutral"
$variation="primary"
>
{dateToDisplay}
</Text>
</StyledLink>
); );
}; };

View File

@@ -6,7 +6,12 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components'; import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { Doc, KEY_LIST_DOC, useRestoreDoc } from '@/docs/doc-management'; import {
Doc,
KEY_LIST_DOC,
useRestoreDoc,
useTrans,
} from '@/docs/doc-management';
import { KEY_LIST_DOC_TRASHBIN } from '../api'; import { KEY_LIST_DOC_TRASHBIN } from '../api';
@@ -18,6 +23,7 @@ export const DocsGridTrashbinActions = ({
doc, doc,
}: DocsGridTrashbinActionsProps) => { }: DocsGridTrashbinActionsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { untitledDocument } = useTrans();
const { toast } = useToastProvider(); const { toast } = useToastProvider();
const { mutate: restoreDoc, error } = useRestoreDoc({ const { mutate: restoreDoc, error } = useRestoreDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN], listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN],
@@ -61,7 +67,7 @@ export const DocsGridTrashbinActions = ({
}, },
]; ];
const documentTitle = doc.title || t('Untitled document'); const documentTitle = doc.title || untitledDocument;
const menuLabel = t('Open the menu of actions for the document: {{title}}', { const menuLabel = t('Open the menu of actions for the document: {{title}}', {
title: documentTitle, title: documentTitle,
}); });

View File

@@ -18,7 +18,9 @@ describe('DocsGridItemDate', () => {
it('should not render date when not on desktop', () => { it('should not render date when not on desktop', () => {
render( render(
<DocsGridItemDate <DocsGridItemDate
doc={{} as Doc} doc={
{ updated_at: DateTime.now().minus({ minutes: 1 }).toISO() } as Doc
}
isDesktop={false} isDesktop={false}
isInTrashbin={false} isInTrashbin={false}
/>, />,
@@ -27,7 +29,7 @@ describe('DocsGridItemDate', () => {
}, },
); );
expect(screen.queryByRole('link')).not.toBeInTheDocument(); expect(screen.queryByText('1 minute ago')).not.toBeInTheDocument();
}); });
[ [
@@ -66,7 +68,6 @@ describe('DocsGridItemDate', () => {
{ wrapper: AppWrapper }, { wrapper: AppWrapper },
); );
expect(screen.getByRole('link')).toBeInTheDocument();
expect(screen.getByText(rendered)).toBeInTheDocument(); expect(screen.getByText(rendered)).toBeInTheDocument();
}); });
}); });
@@ -87,7 +88,6 @@ describe('DocsGridItemDate', () => {
{ wrapper: AppWrapper }, { wrapper: AppWrapper },
); );
expect(screen.getByRole('link')).toBeInTheDocument();
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument(); expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
await i18next.changeLanguage('en'); await i18next.changeLanguage('en');
@@ -134,7 +134,6 @@ describe('DocsGridItemDate', () => {
{ wrapper: AppWrapper }, { wrapper: AppWrapper },
); );
expect(screen.getByRole('link')).toBeInTheDocument();
await waitFor( await waitFor(
() => { () => {
expect(screen.getByText(rendered)).toBeInTheDocument(); expect(screen.getByText(rendered)).toBeInTheDocument();