(frontend) add trashbin list

List the docs deleted in the trashbin list,
it is displayed in the docs grid.
This commit is contained in:
Anthony LC
2025-10-03 12:45:25 +02:00
parent 2c1a9ff74f
commit 37138c1a23
16 changed files with 460 additions and 39 deletions

View File

@@ -139,7 +139,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),

View File

@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import {
clickInGridMenu,
createDoc,
getGridRow,
verifyDocName,
} from './utils-common';
import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Trashbin', () => {
test('it controls UI and interaction from the grid page', async ({
page,
browserName,
}) => {
const [title1] = await createDoc(page, 'my-trash-doc-1', browserName, 1);
const [title2] = await createDoc(page, 'my-trash-doc-2', browserName, 1);
await verifyDocName(page, title2);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor');
await page.getByRole('button', { name: 'close' }).click();
await page.getByRole('button', { name: 'Back to homepage' }).click();
const row1 = await getGridRow(page, title1);
await clickInGridMenu(page, row1, 'Delete');
await page.getByRole('button', { name: 'Delete document' }).click();
const row2 = await getGridRow(page, title2);
await clickInGridMenu(page, row2, 'Delete');
await page.getByRole('button', { name: 'Delete document' }).click();
await page.getByRole('link', { name: 'Trashbin' }).click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid.getByText('Days remaining')).toBeVisible();
await expect(row1.getByText(title1)).toBeVisible();
await expect(row1.getByText('30 days')).toBeVisible();
await expect(row2.getByText(title2)).toBeVisible();
await expect(
row2.getByRole('button', {
name: 'Open the sharing settings for the document',
}),
).toBeVisible();
await expect(
row2.getByRole('button', {
name: 'Open the sharing settings for the document',
}),
).toBeDisabled();
});
});

View File

@@ -1,4 +1,4 @@
import { Page, expect } from '@playwright/test';
import { Locator, Page, expect } from '@playwright/test';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
@@ -326,3 +326,14 @@ export async function waitForLanguageSwitch(
await page.getByRole('menuitem', { name: lang.label }).click();
}
export const clickInGridMenu = async (
page: Page,
row: Locator,
textButton: string,
) => {
await row
.getByRole('button', { name: /Open the menu of actions for the document/ })
.click();
await page.getByRole('menuitem', { name: textButton }).click();
};

View File

@@ -0,0 +1,87 @@
<svg
width="32"
height="40"
viewBox="0 0 32 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_d_11326_8575)">
<rect
x="4.35"
y="2.35"
width="23.3"
height="31.3"
rx="3.20556"
fill="white"
/>
<rect
x="4.35"
y="2.35"
width="23.3"
height="31.3"
rx="3.20556"
stroke="currentColor"
stroke-width="0.7"
/>
<rect
x="4.35"
y="2.35"
width="23.3"
height="31.3"
rx="3.20556"
stroke="#F6F8F9"
stroke-opacity="0.8"
stroke-width="0.7"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.55664 7.7778C7.55664 7.28689 7.95461 6.88892 8.44553 6.88892H23.5002C23.9911 6.88892 24.3891 7.28689 24.3891 7.7778C24.3891 8.26872 23.9911 8.66669 23.5002 8.66669H8.44553C7.95461 8.66669 7.55664 8.26872 7.55664 7.7778ZM7.55664 10.4445C7.55664 9.95355 7.95461 9.55558 8.44553 9.55558H23.5002C23.9911 9.55558 24.3891 9.95355 24.3891 10.4445C24.3891 10.9354 23.9911 11.3334 23.5002 11.3334H8.44553C7.95461 11.3334 7.55664 10.9354 7.55664 10.4445ZM7.55664 13.1111C7.55664 12.6202 7.95461 12.2222 8.44553 12.2222H23.5002C23.9911 12.2222 24.3891 12.6202 24.3891 13.1111C24.3891 13.6021 23.9911 14 23.5002 14H8.44553C7.95461 14 7.55664 13.6021 7.55664 13.1111ZM7.55664 15.7778C7.55664 15.2869 7.95461 14.8889 8.44553 14.8889H15.5002C15.9911 14.8889 16.3891 15.2869 16.3891 15.7778C16.3891 16.2687 15.9911 16.6667 15.5002 16.6667H8.44553C7.95461 16.6667 7.55664 16.2687 7.55664 15.7778Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.55664 7.7778C7.55664 7.28689 7.95461 6.88892 8.44553 6.88892H23.5002C23.9911 6.88892 24.3891 7.28689 24.3891 7.7778C24.3891 8.26872 23.9911 8.66669 23.5002 8.66669H8.44553C7.95461 8.66669 7.55664 8.26872 7.55664 7.7778ZM7.55664 10.4445C7.55664 9.95355 7.95461 9.55558 8.44553 9.55558H23.5002C23.9911 9.55558 24.3891 9.95355 24.3891 10.4445C24.3891 10.9354 23.9911 11.3334 23.5002 11.3334H8.44553C7.95461 11.3334 7.55664 10.9354 7.55664 10.4445ZM7.55664 13.1111C7.55664 12.6202 7.95461 12.2222 8.44553 12.2222H23.5002C23.9911 12.2222 24.3891 12.6202 24.3891 13.1111C24.3891 13.6021 23.9911 14 23.5002 14H8.44553C7.95461 14 7.55664 13.6021 7.55664 13.1111ZM7.55664 15.7778C7.55664 15.2869 7.95461 14.8889 8.44553 14.8889H15.5002C15.9911 14.8889 16.3891 15.2869 16.3891 15.7778C16.3891 16.2687 15.9911 16.6667 15.5002 16.6667H8.44553C7.95461 16.6667 7.55664 16.2687 7.55664 15.7778Z"
fill="white"
fill-opacity="0.65"
/>
</g>
<defs>
<filter
id="filter0_d_11326_8575"
x="-4"
y="0"
width="40"
height="40"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="2" />
<feGaussianBlur stdDeviation="2" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_11326_8575"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_11326_8575"
result="shape"
/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -4,9 +4,15 @@ import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, getEmojiAndTitle, useTrans } from '@/docs/doc-management';
import {
Doc,
getEmojiAndTitle,
useDocUtils,
useTrans,
} from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import ChildDocument from '../assets/child-document.svg';
import PinnedDocumentIcon from '../assets/pinned-document.svg';
import SimpleFileIcon from '../assets/simple-document.svg';
@@ -37,6 +43,7 @@ export const SimpleDocItem = ({
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { untitledDocument } = useTrans();
const { isChild } = useDocUtils(doc);
const { emoji, titleWithoutEmoji: displayTitle } = getEmojiAndTitle(
doc.title || untitledDocument,
@@ -73,11 +80,19 @@ export const SimpleDocItem = ({
<DocIcon
emoji={emoji}
defaultIcon={
<SimpleFileIcon
aria-hidden="true"
data-testid="doc-simple-icon"
color={colorsTokens['primary-500']}
/>
isChild ? (
<ChildDocument
aria-hidden="true"
data-testid="doc-child-icon"
color={colorsTokens['primary-500']}
/>
) : (
<SimpleFileIcon
aria-hidden="true"
data-testid="doc-simple-icon"
color={colorsTokens['primary-500']}
/>
)
}
$size="25px"
/>

View File

@@ -56,6 +56,7 @@ export interface Doc {
content: Base64;
created_at: string;
creator: string;
deleted_at: string | null;
depth: number;
path: string;
is_favorite: boolean;
@@ -107,6 +108,7 @@ export enum DocDefaultFilter {
ALL_DOCS = 'all_docs',
MY_DOCS = 'my_docs',
SHARED_WITH_ME = 'shared_with_me',
TRASHBIN = 'trashbin',
}
export type DocsOrdering =

View File

@@ -7,6 +7,7 @@ import { Box, Card, Text } from '@/components';
import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useInfiniteDocsTrashbin } from '../api';
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
import {
@@ -33,13 +34,7 @@ export const DocsGrid = ({
isLoading,
fetchNextPage,
hasNextPage,
} = useInfiniteDocs({
page: 1,
...(target &&
target !== DocDefaultFilter.ALL_DOCS && {
is_creator_me: target === DocDefaultFilter.MY_DOCS,
}),
});
} = useDocsQuery(target);
const docs = data?.pages.flatMap((page) => page.results) ?? [];
@@ -52,12 +47,20 @@ export const DocsGrid = ({
void fetchNextPage();
};
const title =
target === DocDefaultFilter.MY_DOCS
? t('My docs')
: target === DocDefaultFilter.SHARED_WITH_ME
? t('Shared with me')
: t('All docs');
let title = t('All docs');
switch (target) {
case DocDefaultFilter.MY_DOCS:
title = t('My docs');
break;
case DocDefaultFilter.SHARED_WITH_ME:
title = t('Shared with me');
break;
case DocDefaultFilter.TRASHBIN:
title = t('Trashbin');
break;
default:
title = t('All docs');
}
return (
<Box
@@ -121,7 +124,9 @@ export const DocsGrid = ({
role="columnheader"
>
<Text $size="xs" $weight="500" $variation="600">
{t('Updated at')}
{DocDefaultFilter.TRASHBIN === target
? t('Days remaining')
: t('Updated at')}
</Text>
</Box>
)}
@@ -157,3 +162,29 @@ export const DocsGrid = ({
</Box>
);
};
const useDocsQuery = (target: DocDefaultFilter) => {
const trashbinQuery = useInfiniteDocsTrashbin(
{
page: 1,
},
{
enabled: target === DocDefaultFilter.TRASHBIN,
},
);
const docsQuery = useInfiniteDocs(
{
page: 1,
...(target &&
target !== DocDefaultFilter.ALL_DOCS && {
is_creator_me: target === DocDefaultFilter.MY_DOCS,
}),
},
{
enabled: target !== DocDefaultFilter.TRASHBIN,
},
);
return target === DocDefaultFilter.TRASHBIN ? trashbinQuery : docsQuery;
};

View File

@@ -45,6 +45,7 @@ export const DocsGridActions = ({
}
},
testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
showSeparator: true,
},
{
label: t('Share'),
@@ -66,9 +67,10 @@ export const DocsGridActions = ({
canSave: false,
});
},
showSeparator: true,
},
{
label: t('Remove'),
label: t('Delete'),
icon: 'delete',
callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy,

View File

@@ -1,19 +1,22 @@
import { Tooltip, useModal } from '@openfun/cunningham-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'next/navigation';
import { KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
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 { DocShareModal } from '@/docs/doc-share';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
import { DocsGridActions } from './DocsGridActions';
import { DocsGridItemSharedButton } from './DocsGridItemSharedButton';
import { DocsGridTrashbinActions } from './DocsGridTrashbinActions';
type DocsGridItemProps = {
doc: Doc;
@@ -21,6 +24,10 @@ type DocsGridItemProps = {
};
export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const searchParams = useSearchParams();
const target = searchParams.get('target');
const isInTrashbin = target === 'trashbin';
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { flexLeft, flexRight } = useResponsiveDocGrid();
@@ -156,22 +163,25 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
$gap="32px"
role="gridcell"
>
{isDesktop && (
<StyledLink href={`/docs/${doc.id}`}>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
</StyledLink>
)}
<DocsGridItemDate
doc={doc}
isDesktop={isDesktop}
isInTrashbin={isInTrashbin}
/>
<Box $direction="row" $align="center" $gap={spacingsTokens.lg}>
{isDesktop && (
<DocsGridItemSharedButton
doc={doc}
handleClick={handleShareClick}
disabled={isInTrashbin}
/>
)}
<DocsGridActions doc={doc} openShareModal={handleShareClick} />
{isInTrashbin ? (
<DocsGridTrashbinActions doc={doc} />
) : (
<DocsGridActions doc={doc} openShareModal={handleShareClick} />
)}
</Box>
</Box>
</Box>
@@ -181,3 +191,40 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
</>
);
};
export const DocsGridItemDate = ({
doc,
isDesktop,
isInTrashbin,
}: {
doc: Doc;
isDesktop: boolean;
isInTrashbin: boolean;
}) => {
const { data: config } = useConfig();
const { t } = useTranslation();
const { relativeDate, calculateDaysLeft } = useDate();
if (!isDesktop) {
return null;
}
let dateToDisplay = relativeDate(doc.updated_at);
if (isInTrashbin && config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) {
const daysLeft = calculateDaysLeft(
doc.deleted_at,
config.TRASHBIN_CUTOFF_DAYS,
);
dateToDisplay = `${daysLeft} ${t('days', { count: daysLeft })}`;
}
return (
<StyledLink href={`/docs/${doc.id}`}>
<Text $variation="600" $size="xs">
{dateToDisplay}
</Text>
</StyledLink>
);
};

View File

@@ -2,14 +2,18 @@ import { Button, Tooltip } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { Doc } from '../../doc-management';
import { Doc } from '@/docs/doc-management';
type Props = {
doc: Doc;
handleClick: () => void;
disabled: boolean;
};
export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
export const DocsGridItemSharedButton = ({
doc,
handleClick,
disabled,
}: Props) => {
const { t } = useTranslation();
const sharedCount = doc.nb_accesses_direct;
const isShared = sharedCount - 1 > 0;
@@ -29,7 +33,13 @@ export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
className="--docs--doc-tooltip-grid-item-shared-button"
>
<Button
style={{ minWidth: '50px', justifyContent: 'center' }}
className="--docs--doc-grid-item-shared-button"
aria-label={t('Open the sharing settings for the document')}
data-testid={`docs-grid-item-shared-button-${doc.id}`}
style={{
minWidth: '50px',
justifyContent: 'center',
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -37,7 +47,8 @@ export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
}}
color="tertiary"
size="nano"
icon={<Icon $variation="800" $theme="primary" iconName="group" />}
icon={<Icon $theme="primary" iconName="group" disabled={disabled} />}
disabled={disabled}
>
{sharedCount}
</Button>

View File

@@ -0,0 +1,139 @@
import { render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import i18next from 'i18next';
import { DateTime } from 'luxon';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Doc } from '@/docs/doc-management';
import { AppWrapper } from '@/tests/utils';
import { DocsGridItemDate } from '../DocsGridItem';
describe('DocsGridItemDate', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.restore();
});
it('should not render date when not on desktop', () => {
render(
<DocsGridItemDate
doc={{} as Doc}
isDesktop={false}
isInTrashbin={false}
/>,
{
wrapper: AppWrapper,
},
);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
[
{ updated_at: DateTime.now().toISO(), rendered: '0 seconds ago' },
{
updated_at: DateTime.now().minus({ days: 1 }).toISO(),
rendered: '1 day ago',
},
{
updated_at: DateTime.now().minus({ days: 5 }).toISO(),
rendered: '5 days ago',
},
{
updated_at: DateTime.now().minus({ days: 30 }).toISO(),
rendered: '1 month ago',
},
].forEach(({ updated_at, rendered }) => {
it(`should render "${rendered}" from the updated_at field`, () => {
render(
<DocsGridItemDate
doc={
{
updated_at,
} as Doc
}
isDesktop={true}
isInTrashbin={false}
/>,
{ wrapper: AppWrapper },
);
expect(screen.getByRole('link')).toBeInTheDocument();
expect(screen.getByText(rendered)).toBeInTheDocument();
});
});
it(`should render rendered the updated_at field in the correct language`, () => {
i18next.changeLanguage('fr');
render(
<DocsGridItemDate
doc={
{
updated_at: DateTime.now().minus({ days: 5 }).toISO(),
} as Doc
}
isDesktop={true}
isInTrashbin={false}
/>,
{ wrapper: AppWrapper },
);
expect(screen.getByRole('link')).toBeInTheDocument();
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
i18next.changeLanguage('en');
});
[
{
deleted_at: DateTime.now().toISO(),
rendered: '30 days',
trashbin_cutoff_days: 30,
updated_at: DateTime.now().toISO(),
},
{
deleted_at: DateTime.now().toISO(),
rendered: '1 day',
trashbin_cutoff_days: 1,
updated_at: DateTime.now().toISO(),
},
{
deleted_at: DateTime.now().toISO(),
rendered: '0 seconds ago',
trashbin_cutoff_days: 0,
updated_at: DateTime.now().toISO(),
},
].forEach(({ deleted_at, rendered, trashbin_cutoff_days, updated_at }) => {
it(`should render "${rendered}" when we are in the trashbin`, async () => {
fetchMock.get('http://test.jest/api/v1.0/config/', {
body: JSON.stringify({
TRASHBIN_CUTOFF_DAYS: trashbin_cutoff_days,
}),
});
render(
<DocsGridItemDate
doc={
{
deleted_at,
updated_at,
} as Doc
}
isDesktop={true}
isInTrashbin={true}
/>,
{ wrapper: AppWrapper },
);
expect(screen.getByRole('link')).toBeInTheDocument();
await waitFor(
() => {
expect(screen.getByText(rendered)).toBeInTheDocument();
},
{ timeout: 1000 },
);
});
});
});

View File

@@ -35,6 +35,11 @@ export const LeftPanelTargetFilters = () => {
label: t('Shared with me'),
targetQuery: DocDefaultFilter.SHARED_WITH_ME,
},
{
icon: 'delete',
label: t('Trashbin'),
targetQuery: DocDefaultFilter.TRASHBIN,
},
];
const buildHref = (query: DocDefaultFilter) => {

View File

@@ -172,6 +172,7 @@ export class ApiPlugin implements WorkboxPlugin {
content: '',
created_at: new Date().toISOString(),
creator: 'dummy-id',
deleted_at: null,
depth: 1,
is_favorite: false,
nb_accesses_direct: 1,

View File

@@ -21,5 +21,17 @@ export const useDate = () => {
.toLocaleString(format);
};
return { formatDate };
const relativeDate = (date: string): string => {
return DateTime.fromISO(date).setLocale(i18n.language).toRelative() || '';
};
const calculateDaysLeft = (date: string, daysLimit: number): number =>
Math.max(
0,
Math.ceil(
DateTime.fromISO(date).plus({ days: daysLimit }).diffNow('days').days,
),
);
return { formatDate, relativeDate, calculateDaysLeft };
};

View File

@@ -434,7 +434,9 @@
"Share with {{count}} users_one": "Share with {{count}} user",
"Shared with {{count}} users_many": "Shared with {{count}} users",
"Shared with {{count}} users_one": "Shared with {{count}} user",
"Shared with {{count}} users_other": "Shared with {{count}} users"
"Shared with {{count}} users_other": "Shared with {{count}} users",
"days_one": "day",
"days_other": "days"
}
},
"es": {