✨(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:
@@ -15,6 +15,7 @@ and this project adheres to
|
||||
- ✨(frontend) Can print a doc #1832
|
||||
- ✨(backend) manage reconciliation requests for user accounts #1878
|
||||
- 👷(CI) add GHCR workflow for forked repo testing #1851
|
||||
- ✨(frontend) Move doc modal #1886
|
||||
- ⚡️(backend) remove content from Document serializer when asked #1910
|
||||
- ✨(backend) allow the duplication of subpages #1893
|
||||
- ✨(backend) Onboarding docs for new users #1891
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
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';
|
||||
|
||||
test.describe('Doc grid dnd', () => {
|
||||
test('it creates a doc', async ({ page, browserName }) => {
|
||||
test.describe('Doc grid move', () => {
|
||||
test('it checks drag and drop functionality', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
const header = page.locator('header').first();
|
||||
await createDoc(page, 'Draggable doc', browserName, 1);
|
||||
@@ -29,7 +38,7 @@ test.describe('Doc grid dnd', () => {
|
||||
await expect(draggableElement).toBeVisible();
|
||||
await expect(dropZone).toBeVisible();
|
||||
|
||||
// Obtenir les positions des éléments
|
||||
// Get the position of the elements
|
||||
const draggableBoundingBox = await draggableElement.boundingBox();
|
||||
const dropZoneBoundingBox = await dropZone.boundingBox();
|
||||
|
||||
@@ -46,7 +55,7 @@ test.describe('Doc grid dnd', () => {
|
||||
);
|
||||
await page.mouse.down();
|
||||
|
||||
// Déplacer vers la zone cible
|
||||
// Move to the target zone
|
||||
await page.mouse.move(
|
||||
dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2,
|
||||
dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2,
|
||||
@@ -161,6 +170,55 @@ test.describe('Doc grid dnd', () => {
|
||||
|
||||
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', () => {
|
||||
@@ -15,6 +15,7 @@ export type QuickSearchAction = {
|
||||
|
||||
export type QuickSearchData<T> = {
|
||||
groupName: string;
|
||||
groupKey?: string;
|
||||
elements: T[];
|
||||
emptyString?: string;
|
||||
startActions?: QuickSearchAction[];
|
||||
@@ -30,13 +31,13 @@ export type QuickSearchProps = {
|
||||
loading?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
groupKey?: string;
|
||||
};
|
||||
|
||||
export const QuickSearch = ({
|
||||
onFilter,
|
||||
inputContent,
|
||||
inputValue,
|
||||
loading,
|
||||
showInput = true,
|
||||
label,
|
||||
placeholder,
|
||||
@@ -72,10 +73,10 @@ export const QuickSearch = ({
|
||||
tabIndex={-1}
|
||||
value={selectedValue}
|
||||
onValueChange={handleValueChange}
|
||||
disablePointerSelection
|
||||
>
|
||||
{showInput && (
|
||||
<QuickSearchInput
|
||||
loading={loading}
|
||||
withSeparator={hasChildrens(children)}
|
||||
inputValue={inputValue}
|
||||
onFilter={onFilter}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const QuickSearchGroup = <T,>({
|
||||
{group.startActions?.map((action, index) => {
|
||||
return (
|
||||
<QuickSearchItem
|
||||
key={`${group.groupName}-action-${index}`}
|
||||
key={`${group.groupKey ?? group.groupName}-start-actions-${index}`}
|
||||
onSelect={action.onSelect}
|
||||
>
|
||||
{action.content}
|
||||
@@ -38,8 +38,8 @@ export const QuickSearchGroup = <T,>({
|
||||
{group.elements.map((groupElement, index) => {
|
||||
return (
|
||||
<QuickSearchItem
|
||||
id={`${group.groupName}-element-${index}`}
|
||||
key={`${group.groupName}-element-${index}`}
|
||||
id={`${group.groupKey ?? group.groupName}-element-${index}`}
|
||||
key={`${group.groupKey ?? group.groupName}-element-${index}`}
|
||||
onSelect={() => {
|
||||
onSelect?.(groupElement);
|
||||
}}
|
||||
@@ -51,7 +51,7 @@ export const QuickSearchGroup = <T,>({
|
||||
{group.endActions?.map((action, index) => {
|
||||
return (
|
||||
<QuickSearchItem
|
||||
key={`${group.groupName}-action-${index}`}
|
||||
key={`${group.groupKey ?? group.groupName}-end-actions-${index}`}
|
||||
onSelect={action.onSelect}
|
||||
>
|
||||
{action.content}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Loader } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode } from 'react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { HorizontalSeparator } from '@/components';
|
||||
@@ -9,19 +8,16 @@ import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Box } from '../Box';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
type Props = {
|
||||
loading?: boolean;
|
||||
type QuickSearchInputProps = {
|
||||
inputValue?: string;
|
||||
onFilter?: (str: string) => void;
|
||||
placeholder?: string;
|
||||
children?: ReactNode;
|
||||
withSeparator?: boolean;
|
||||
listId?: string;
|
||||
onUserInteract?: () => void;
|
||||
isExpanded?: boolean;
|
||||
};
|
||||
export const QuickSearchInput = ({
|
||||
loading,
|
||||
inputValue,
|
||||
onFilter,
|
||||
placeholder,
|
||||
@@ -30,7 +26,7 @@ export const QuickSearchInput = ({
|
||||
listId,
|
||||
onUserInteract,
|
||||
isExpanded,
|
||||
}: Props) => {
|
||||
}: PropsWithChildren<QuickSearchInputProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
@@ -52,14 +48,7 @@ export const QuickSearchInput = ({
|
||||
$gap={spacingsTokens['2xs']}
|
||||
$padding={{ horizontal: 'base', vertical: 'sm' }}
|
||||
>
|
||||
{!loading && (
|
||||
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
|
||||
)}
|
||||
{loading && (
|
||||
<div>
|
||||
<Loader size="small" />
|
||||
</div>
|
||||
)}
|
||||
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
|
||||
<Command.Input
|
||||
autoFocus={true}
|
||||
aria-label={t('Quick search input')}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const QuickSearchStyle = createGlobalStyle`
|
||||
padding: var(--c--globals--spacings--xs);
|
||||
background: white;
|
||||
outline: none;
|
||||
color: var(--c--globals--colors--gray-1000);
|
||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
border-radius: var(--c--globals--spacings--0);
|
||||
|
||||
&::placeholder {
|
||||
|
||||
@@ -181,6 +181,7 @@ export const SearchPage = ({
|
||||
setSearch(value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
|
||||
@@ -12,15 +12,19 @@ import { DocSearchItem } from './DocSearchItem';
|
||||
type DocSearchContentProps = {
|
||||
search: string;
|
||||
filters: DocSearchFiltersValues;
|
||||
filterResults?: (doc: Doc) => boolean;
|
||||
onSelect: (doc: Doc) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
renderSearchElement?: (doc: Doc) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const DocSearchContent = ({
|
||||
search,
|
||||
filters,
|
||||
filterResults,
|
||||
onSelect,
|
||||
onLoadingChange,
|
||||
renderSearchElement,
|
||||
}: DocSearchContentProps) => {
|
||||
const {
|
||||
data,
|
||||
@@ -38,10 +42,15 @@ export const DocSearchContent = ({
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
|
||||
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 {
|
||||
groupName: docs.length > 0 ? t('Select a document') : '',
|
||||
groupKey: 'docs',
|
||||
elements: search ? docs : [],
|
||||
emptyString: t('No document found'),
|
||||
endActions: hasNextPage
|
||||
@@ -52,7 +61,7 @@ export const DocSearchContent = ({
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}, [search, data?.pages, fetchNextPage, hasNextPage]);
|
||||
}, [search, data?.pages, fetchNextPage, hasNextPage, filterResults]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(loading);
|
||||
@@ -62,7 +71,9 @@ export const DocSearchContent = ({
|
||||
<QuickSearchGroup
|
||||
onSelect={onSelect}
|
||||
group={docsData}
|
||||
renderElement={(doc) => <DocSearchItem doc={doc} />}
|
||||
renderElement={
|
||||
renderSearchElement ?? ((doc) => <DocSearchItem doc={doc} />)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './DocSearchContent';
|
||||
export * from './DocSearchModal';
|
||||
export * from './DocSearchFilters';
|
||||
export * from './DocSearchSubPageContent';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
useCreateFavoriteDoc,
|
||||
useDeleteFavoriteDoc,
|
||||
useDuplicateDoc,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { DocShareModal } from '@/docs/doc-share';
|
||||
|
||||
import { DocMoveModal } from './DocMoveModal';
|
||||
|
||||
interface DocsGridActionsProps {
|
||||
doc: Doc;
|
||||
}
|
||||
@@ -23,6 +26,8 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
|
||||
|
||||
const deleteModal = useModal();
|
||||
const shareModal = useModal();
|
||||
const importModal = useModal();
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
const { mutate: duplicateDoc } = useDuplicateDoc();
|
||||
|
||||
@@ -56,6 +61,15 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
|
||||
|
||||
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'),
|
||||
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}}', {
|
||||
title: documentTitle,
|
||||
});
|
||||
@@ -116,6 +130,13 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
|
||||
{shareModal.isOpen && (
|
||||
<DocShareModal doc={doc} onClose={shareModal.close} />
|
||||
)}
|
||||
{importModal.isOpen && (
|
||||
<DocMoveModal
|
||||
doc={doc}
|
||||
onClose={importModal.close}
|
||||
isOpen={importModal.isOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { css } from 'styled-components';
|
||||
import { Box, Icon, StyledLink, Text } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
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 { useResponsiveStore } from '@/stores';
|
||||
|
||||
@@ -26,14 +26,12 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const target = searchParams.get('target');
|
||||
const isInTrashbin = target === 'trashbin';
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { flexLeft, flexRight } = useResponsiveDocGrid();
|
||||
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) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -62,7 +60,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
|
||||
`}
|
||||
className="--docs--doc-grid-item"
|
||||
aria-label={t('Open document: {{title}}', {
|
||||
title: doc.title || t('Untitled document'),
|
||||
title: doc.title || untitledDocument,
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
@@ -82,72 +80,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
|
||||
href={`/docs/${doc.id}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<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
|
||||
}
|
||||
>
|
||||
{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>
|
||||
<DocsGridItemTitle doc={doc} withTooltip={!dragMode} />
|
||||
</StyledLink>
|
||||
</Box>
|
||||
|
||||
@@ -159,11 +92,13 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
|
||||
$gap="32px"
|
||||
role="gridcell"
|
||||
>
|
||||
<DocsGridItemDate
|
||||
doc={doc}
|
||||
isDesktop={isDesktop}
|
||||
isInTrashbin={isInTrashbin}
|
||||
/>
|
||||
<StyledLink href={`/docs/${doc.id}`} tabIndex={-1}>
|
||||
<DocsGridItemDate
|
||||
doc={doc}
|
||||
isDesktop={isDesktop}
|
||||
isInTrashbin={isInTrashbin}
|
||||
/>
|
||||
</StyledLink>
|
||||
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens.lg}>
|
||||
{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 = ({
|
||||
doc,
|
||||
isDesktop,
|
||||
@@ -210,15 +225,8 @@ export const DocsGridItemDate = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledLink href={`/docs/${doc.id}`} tabIndex={-1}>
|
||||
<Text
|
||||
$size="xs"
|
||||
$layer="background"
|
||||
$theme="neutral"
|
||||
$variation="primary"
|
||||
>
|
||||
{dateToDisplay}
|
||||
</Text>
|
||||
</StyledLink>
|
||||
<Text $size="xs" $layer="background" $theme="neutral" $variation="primary">
|
||||
{dateToDisplay}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-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';
|
||||
|
||||
@@ -18,6 +23,7 @@ export const DocsGridTrashbinActions = ({
|
||||
doc,
|
||||
}: DocsGridTrashbinActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { untitledDocument } = useTrans();
|
||||
const { toast } = useToastProvider();
|
||||
const { mutate: restoreDoc, error } = useRestoreDoc({
|
||||
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}}', {
|
||||
title: documentTitle,
|
||||
});
|
||||
|
||||
@@ -18,7 +18,9 @@ describe('DocsGridItemDate', () => {
|
||||
it('should not render date when not on desktop', () => {
|
||||
render(
|
||||
<DocsGridItemDate
|
||||
doc={{} as Doc}
|
||||
doc={
|
||||
{ updated_at: DateTime.now().minus({ minutes: 1 }).toISO() } as Doc
|
||||
}
|
||||
isDesktop={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 },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link')).toBeInTheDocument();
|
||||
expect(screen.getByText(rendered)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -87,7 +88,6 @@ describe('DocsGridItemDate', () => {
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link')).toBeInTheDocument();
|
||||
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
|
||||
|
||||
await i18next.changeLanguage('en');
|
||||
@@ -134,7 +134,6 @@ describe('DocsGridItemDate', () => {
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link')).toBeInTheDocument();
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText(rendered)).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user