From 6523165ea0b8a8920cdc7dd91fdba0854752dfba Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 8 Oct 2025 17:14:35 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20doc=20page=20when=20delet?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whe the doc is deleted, the doc page is a bit different, we have to adapt the doc header to add some information and actions that are relevant for a deleted doc. --- .../__tests__/app-impress/doc-header.spec.ts | 2 +- .../app-impress/doc-trashbin.spec.ts | 73 ++++++++++++++ .../e2e/__tests__/app-impress/utils-common.ts | 5 + .../__tests__/app-impress/utils-sub-pages.ts | 44 ++++++++- .../apps/impress/src/components/Card.tsx | 3 +- .../apps/impress/src/components/Overlayer.tsx | 36 +++++++ .../apps/impress/src/components/index.ts | 1 + .../doc-editor/components/BlockNoteEditor.tsx | 5 +- .../src/features/docs/doc-editor/styles.tsx | 9 +- .../doc-header/components/AlertRestore.tsx | 98 +++++++++++++++++++ .../doc-header/components/BoutonShare.tsx | 87 ++++++++++++++++ .../docs/doc-header/components/DocHeader.tsx | 38 +++++-- .../docs/doc-header/components/DocToolBox.tsx | 88 ++++++----------- .../components/ModalRemoveDoc.tsx | 6 +- .../docs/doc-tree/assets/sub-page-logo.svg | 13 ++- .../doc-tree/components/DocSubPageItem.tsx | 10 +- .../docs/doc-tree/components/DocTree.tsx | 48 ++++----- .../components/DocTreeItemActions.tsx | 15 ++- .../impress/src/pages/docs/[id]/index.tsx | 6 +- 19 files changed, 477 insertions(+), 110 deletions(-) create mode 100644 src/frontend/apps/impress/src/components/Overlayer.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-header/components/AlertRestore.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-header/components/BoutonShare.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index 8e05c5e6..ff275443 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -96,7 +96,7 @@ test.describe('Doc Header', () => { page.getByRole('heading', { name: 'Delete a doc' }), ).toBeVisible(); - await expect(page.getByText(`This document and any sub-`)).toBeVisible(); + await expect(page.getByText(`This document will be`)).toBeVisible(); await page .getByRole('button', { 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 index 3b723404..ff506587 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts @@ -1,12 +1,18 @@ import { expect, test } from '@playwright/test'; import { + clickInEditorMenu, clickInGridMenu, createDoc, getGridRow, verifyDocName, } from './utils-common'; import { addNewMember } from './utils-share'; +import { + addChild, + createRootSubPage, + navigateToPageFromTree, +} from './utils-sub-pages'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -74,4 +80,71 @@ test.describe('Doc Trashbin', () => { await page.getByRole('link', { name: 'Trashbin' }).click(); await expect(row2.getByText(title2)).toBeHidden(); }); + + test('it controls UI and interaction from the doc page', async ({ + page, + browserName, + }) => { + const [topParent] = await createDoc( + page, + 'my-trash-editor-doc', + browserName, + 1, + ); + await verifyDocName(page, topParent); + const { name: subDocName } = await createRootSubPage( + page, + browserName, + 'my-trash-editor-subdoc', + ); + + const subsubDocName = await addChild({ + page, + browserName, + docParent: subDocName, + }); + await verifyDocName(page, subsubDocName); + + await navigateToPageFromTree({ page, title: subDocName }); + await verifyDocName(page, subDocName); + + await clickInEditorMenu(page, 'Delete sub-document'); + await page.getByRole('button', { name: 'Delete document' }).click(); + await verifyDocName(page, topParent); + + await page.getByRole('button', { name: 'Back to homepage' }).click(); + await page.getByRole('link', { name: 'Trashbin' }).click(); + const row = await getGridRow(page, subDocName); + await row.getByText(subDocName).click(); + await verifyDocName(page, subDocName); + + await expect(page.getByLabel('Alert deleted document')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Share' })).toBeDisabled(); + await expect(page.locator('.bn-editor')).toHaveAttribute( + 'contenteditable', + 'false', + ); + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(topParent)).toBeHidden(); + await expect( + docTree.getByText(subDocName, { + exact: true, + }), + ).toBeVisible(); + await expect(docTree.getByText(subsubDocName)).toBeVisible(); + await expect( + docTree + .locator(".--docs-sub-page-item[aria-disabled='true']") + .getByText(subsubDocName), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Restore' }).click(); + await expect(page.getByLabel('Alert deleted document')).toBeHidden(); + await expect(page.locator('.bn-editor')).toHaveAttribute( + 'contenteditable', + 'true', + ); + await expect(page.getByRole('button', { name: 'Share' })).toBeEnabled(); + await expect(docTree.getByText(topParent)).toBeVisible(); + }); }); 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 cba59792..70e75d1c 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -327,6 +327,11 @@ export async function waitForLanguageSwitch( await page.getByRole('menuitem', { name: lang.label }).click(); } +export const clickInEditorMenu = async (page: Page, textButton: string) => { + await page.getByRole('button', { name: 'Open the document options' }).click(); + await page.getByRole('menuitem', { name: textButton }).click(); +}; + export const clickInGridMenu = async ( page: Page, row: Locator, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts index 16bf849d..5a0229ff 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts @@ -1,6 +1,7 @@ import { Page, expect } from '@playwright/test'; import { + BrowserName, randomName, updateDocTitle, verifyDocName, @@ -9,7 +10,7 @@ import { export const createRootSubPage = async ( page: Page, - browserName: string, + browserName: BrowserName, docName: string, isMobile: boolean = false, ) => { @@ -67,6 +68,47 @@ export const clickOnAddRootSubPage = async (page: Page) => { await rootItem.getByTestId('doc-tree-item-actions-add-child').click(); }; +export const addChild = async ({ + page, + browserName, + docParent, +}: { + page: Page; + browserName: BrowserName; + docParent: string; +}) => { + let item = page.getByTestId('doc-tree-root-item'); + + const isParent = await item + .filter({ + hasText: docParent, + }) + .first() + .count(); + + if (!isParent) { + const items = page.getByRole('treeitem'); + + item = items + .filter({ + hasText: docParent, + }) + .first(); + } + + await item.hover(); + await item.getByTestId('doc-tree-item-actions-add-child').click(); + + const [name] = randomName(docParent, browserName, 1); + await updateDocTitle(page, name); + + return name; +}; + +export const navigateToTopParentFromTree = async ({ page }: { page: Page }) => { + await page.getByRole('link', { name: /Open root document/ }).click(); +}; + export const navigateToPageFromTree = async ({ page, title, diff --git a/src/frontend/apps/impress/src/components/Card.tsx b/src/frontend/apps/impress/src/components/Card.tsx index 9e884bf4..4bc35a7f 100644 --- a/src/frontend/apps/impress/src/components/Card.tsx +++ b/src/frontend/apps/impress/src/components/Card.tsx @@ -7,6 +7,7 @@ import { Box, BoxType } from '.'; export const Card = ({ children, + className, $css, ...props }: PropsWithChildren) => { @@ -14,7 +15,7 @@ export const Card = ({ return ( & + Partial; + +export const Overlayer = ({ + children, + className, + $css, + isOverlay, + ...props +}: OverlayerProps) => { + if (!isOverlay) { + return children; + } + + return ( + + {children} + + ); +}; diff --git a/src/frontend/apps/impress/src/components/index.ts b/src/frontend/apps/impress/src/components/index.ts index e79e0890..89916d0f 100644 --- a/src/frontend/apps/impress/src/components/index.ts +++ b/src/frontend/apps/impress/src/components/index.ts @@ -9,6 +9,7 @@ export * from './InfiniteScroll'; export * from './Link'; export * from './Loading'; export * from './modal'; +export * from './Overlayer'; export * from './separators'; export * from './Text'; export * from './TextErrors'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 68d7c269..3c507f8e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -83,6 +83,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { isEditable, isLoading } = useIsCollaborativeEditable(doc); const isConnectedToCollabServer = provider.isSynced; const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; + const isDeletedDoc = !!doc.deleted_at; useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer); const { i18n } = useTranslation(); @@ -180,7 +181,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { {errorAttachment && ( @@ -231,7 +232,7 @@ export const BlockNoteEditorVersion = ({ ); return ( - + ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx index 8343e64f..954628f6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx @@ -1,6 +1,6 @@ import { css } from 'styled-components'; -export const cssEditor = (readonly: boolean) => css` +export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css` &, & > .bn-container, & .ProseMirror { @@ -127,6 +127,13 @@ export const cssEditor = (readonly: boolean) => css` .bn-block-outer:not([data-prev-depth-changed]):before { border-left: none; } + + ${isDeletedDoc && + ` + .node-interlinkingLinkInline button { + pointer-events: none; + } + `} } & .bn-editor { diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertRestore.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertRestore.tsx new file mode 100644 index 00000000..33a2cfd6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertRestore.tsx @@ -0,0 +1,98 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, BoxButton, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { + Doc, + KEY_DOC, + KEY_LIST_DOC, + useRestoreDoc, +} from '@/docs/doc-management'; +import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid'; + +export const AlertRestore = ({ doc }: { doc: Doc }) => { + const { t } = useTranslation(); + const { toast } = useToastProvider(); + const treeContext = useTreeContext(); + const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + const { mutate: restoreDoc, error } = useRestoreDoc({ + listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN, KEY_DOC], + options: { + onSuccess: (_data) => { + // It will force the tree to be reloaded + treeContext?.setRoot(undefined as unknown as Doc); + + toast(t('The document has been restored.'), VariantType.SUCCESS, { + duration: 4000, + }); + }, + onError: () => { + toast( + t('An error occurred while restoring the document: {{error}}', { + error: error?.message, + }), + VariantType.ERROR, + { + duration: 4000, + }, + ); + }, + }, + }); + + return ( + + + + + {t('Document deleted')} + + + + restoreDoc({ + docId: doc.id, + }) + } + $direction="row" + $gap="0.2rem" + $align="center" + > + + + {t('Restore')} + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/BoutonShare.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/BoutonShare.tsx new file mode 100644 index 00000000..c0e31db5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/BoutonShare.tsx @@ -0,0 +1,87 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { Button } from '@openfun/cunningham-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, Icon } from '@/components'; +import { Doc } from '@/docs/doc-management'; + +interface BoutonShareProps { + displayNbAccess: boolean; + doc: Doc; + isDisabled?: boolean; + isHidden?: boolean; + open: () => void; +} + +export const BoutonShare = ({ + displayNbAccess, + doc, + isDisabled, + isHidden, + open, +}: BoutonShareProps) => { + const { t } = useTranslation(); + const treeContext = useTreeContext(); + + /** + * Following the change where there is no default owner when adding a sub-page, + * we need to handle both the case where the doc is the root and the case of sub-pages. + */ + const hasAccesses = useMemo(() => { + if (treeContext?.root?.id === doc.id) { + return doc.nb_accesses_direct > 1 && displayNbAccess; + } + + return doc.nb_accesses_direct >= 1 && displayNbAccess; + }, [doc.id, treeContext?.root, doc.nb_accesses_direct, displayNbAccess]); + + if (isHidden) { + return null; + } + + if (hasAccesses) { + return ( + + + + ); + } + + return ( + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index 7074ad67..90a2a803 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -1,7 +1,7 @@ -import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { Box, HorizontalSeparator, Text } from '@/components'; +import { useConfig } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; import { Doc, @@ -11,10 +11,13 @@ import { useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; +import { useDate } from '@/hook'; import { useResponsiveStore } from '@/stores'; import { AlertNetwork } from './AlertNetwork'; import { AlertPublic } from './AlertPublic'; +import { AlertRestore } from './AlertRestore'; +import { BoutonShare } from './BoutonShare'; import { DocTitle } from './DocTitle'; import { DocToolBox } from './DocToolBox'; @@ -30,6 +33,22 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { const { isEditable } = useIsCollaborativeEditable(doc); const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC; const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED; + const { relativeDate, calculateDaysLeft } = useDate(); + const { data: config } = useConfig(); + const isDeletedDoc = !!doc.deleted_at; + + let dateToDisplay = t('Last update: {{update}}', { + update: relativeDate(doc.updated_at), + }); + + if (config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) { + const daysLeft = calculateDaysLeft( + doc.deleted_at, + config.TRASHBIN_CUTOFF_DAYS, + ); + + dateToDisplay = `${t('Days remaining:')} ${daysLeft} ${t('days', { count: daysLeft })}`; + } return ( <> @@ -40,6 +59,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { aria-label={t('It is the card information about the document.')} className="--docs--doc-header" > + {isDeletedDoc && } {!isEditable && } {(docIsPublic || docIsAuth) && ( @@ -78,20 +98,26 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {  ยท  - {t('Last update: {{update}}', { - update: DateTime.fromISO(doc.updated_at).toRelative(), - })} + {dateToDisplay} )} {!isDesktop && ( - {DateTime.fromISO(doc.updated_at).toRelative()} + {dateToDisplay} )} - + {!isDeletedDoc && } + {isDeletedDoc && ( + {}} + displayNbAccess={true} + isDisabled + /> + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 7d5eacfe..95a42441 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -2,7 +2,7 @@ import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; import { Button, useModal } from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -23,6 +23,7 @@ import { useCopyDocLink, useCreateFavoriteDoc, useDeleteFavoriteDoc, + useDocUtils, useDuplicateDoc, } from '@/docs/doc-management'; import { DocShareModal } from '@/docs/doc-share'; @@ -35,6 +36,8 @@ import { useResponsiveStore } from '@/stores'; import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard'; +import { BoutonShare } from './BoutonShare'; + const ModalExport = Export?.ModalExport; interface DocToolBoxProps { @@ -44,21 +47,9 @@ interface DocToolBoxProps { export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); const treeContext = useTreeContext(); - - /** - * Following the change where there is no default owner when adding a sub-page, - * we need to handle both the case where the doc is the root and the case of sub-pages. - */ - const hasAccesses = useMemo(() => { - if (treeContext?.root?.id === doc.id) { - return doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; - } - - return doc.nb_accesses_direct >= 1 && doc.abilities.accesses_view; - }, [doc, treeContext?.root]); - const queryClient = useQueryClient(); const router = useRouter(); + const { isChild } = useDocUtils(doc); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); @@ -164,7 +155,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { }, }, { - label: t('Delete document'), + label: isChild ? t('Delete sub-document') : t('Delete document'), icon: 'delete', disabled: !doc.abilities.destroy, callback: () => { @@ -190,46 +181,12 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { $margin={{ left: 'auto' }} $gap={spacingsTokens['2xs']} > - {!isSmallMobile && ( - <> - {!hasAccesses && ( - - )} - {hasAccesses && ( - - - - )} - - )} + {!isSmallMobile && ModalExport && (