diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f772649..7adc5639 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to
### Added
- ✨(frontend) add pdf block to the editor #1293
+- ✨List and restore deleted docs #1450
### Changed
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts
index 0347d804..1e12421f 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts
@@ -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' }),
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts
new file mode 100644
index 00000000..6f1a56da
--- /dev/null
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts
@@ -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();
+ });
+});
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts
index 77b25f7d..cba59792 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts
@@ -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();
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/assets/child-document.svg b/src/frontend/apps/impress/src/features/docs/doc-management/assets/child-document.svg
new file mode 100644
index 00000000..008a9add
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/assets/child-document.svg
@@ -0,0 +1,87 @@
+
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx
index 41a753a4..27b31cd1 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx
@@ -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 = ({
+ isChild ? (
+
+ ) : (
+
+ )
}
$size="25px"
/>
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx
index c6fd9f2c..d786366e 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx
@@ -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 =
diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx
index 26467fa3..3705e98c 100644
--- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx
+++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx
@@ -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 (
- {t('Updated at')}
+ {DocDefaultFilter.TRASHBIN === target
+ ? t('Days remaining')
+ : t('Updated at')}
)}
@@ -157,3 +162,29 @@ export const DocsGrid = ({
);
};
+
+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;
+};
diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx
index 0f396048..60521f09 100644
--- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx
+++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx
@@ -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,
diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
index 8829d82d..f6e2876f 100644
--- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
+++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
@@ -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 && (
-
-
- {DateTime.fromISO(doc.updated_at).toRelative()}
-
-
- )}
+
{isDesktop && (
)}
-
+ {isInTrashbin ? (
+
+ ) : (
+
+ )}
@@ -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 (
+
+
+ {dateToDisplay}
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx
index 7bf0b223..d8d255cf 100644
--- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx
+++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx
@@ -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"
>
diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx
new file mode 100644
index 00000000..7859f1b3
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx
@@ -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(
+ ,
+ {
+ 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(
+ ,
+ { 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(
+ ,
+ { 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(
+ ,
+ { wrapper: AppWrapper },
+ );
+
+ expect(screen.getByRole('link')).toBeInTheDocument();
+ await waitFor(
+ () => {
+ expect(screen.getByText(rendered)).toBeInTheDocument();
+ },
+ { timeout: 1000 },
+ );
+ });
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx
index 60377a38..66430865 100644
--- a/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx
+++ b/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx
@@ -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) => {
diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts
index f050e98e..e84415a7 100644
--- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts
+++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts
@@ -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,
diff --git a/src/frontend/apps/impress/src/hook/useDate.tsx b/src/frontend/apps/impress/src/hook/useDate.tsx
index 5b1b78be..14b09dbd 100644
--- a/src/frontend/apps/impress/src/hook/useDate.tsx
+++ b/src/frontend/apps/impress/src/hook/useDate.tsx
@@ -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 };
};
diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json
index 66269443..bcf04a5d 100644
--- a/src/frontend/apps/impress/src/i18n/translations.json
+++ b/src/frontend/apps/impress/src/i18n/translations.json
@@ -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": {