From 0a6502a77d39c588fbfb254406644487e52bbe81 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 17 Mar 2025 15:13:02 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20added=20subpage=20managem?= =?UTF-8?q?ent=20and=20document=20tree=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New components were created to manage subpages in the document tree, including the ability to add, reorder, and view subpages. Tests were added to verify the functionality of these features. Additionally, API changes were made to manage the creation and retrieval of document children. --- .../app-impress/doc-tree-content.spec.ts | 149 +++++++++++++++ .../impress/src/components/DropdownMenu.tsx | 9 +- .../apps/impress/src/components/Icon.tsx | 15 +- .../src/components/filter/FilterDropdown.tsx | 63 +++++++ .../quick-search/QuickSearchInput.tsx | 3 + .../docs/doc-header/components/DocTitle.tsx | 20 +- .../docs/doc-management/api/useDocs.tsx | 13 +- .../components/ModalRemoveDoc.tsx | 17 +- .../components/DocSearchFilters.tsx | 66 +++++++ .../doc-search/components/DocSearchModal.tsx | 51 ++++- .../components/DocShareAddMemberList.tsx | 32 +++- .../components/DocShareInvitationItem.tsx | 8 + .../components/DocShareMemberItem.tsx | 8 + .../docs/doc-tree/api/useCreateChildren.tsx | 44 +++++ .../docs/doc-tree/api/useDocChildren.tsx | 58 ++++++ .../features/docs/doc-tree/api/useDocTree.tsx | 45 +++++ .../docs/doc-tree/assets/sub-page-logo.svg | 3 + .../doc-tree/components/DocSubPageItem.tsx | 144 ++++++++++++++ .../docs/doc-tree/components/DocTree.tsx | 175 ++++++++++++++++++ .../components/DocTreeItemActions.tsx | 116 ++++++++++++ .../docs/doc-tree/context/DocTreeContext.tsx | 86 +++++++++ .../components/LeftPanelDocContent.tsx | 30 +-- .../left-panel/components/LeftPanelHeader.tsx | 52 +++++- .../impress/src/pages/docs/[id]/index.tsx | 21 ++- 24 files changed, 1163 insertions(+), 65 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/doc-tree-content.spec.ts create mode 100644 src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/context/DocTreeContext.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree-content.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree-content.spec.ts new file mode 100644 index 00000000..b3ea6273 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree-content.spec.ts @@ -0,0 +1,149 @@ +/* eslint-disable playwright/no-conditional-in-test */ +import { expect, test } from '@playwright/test'; + +import { createDoc } from './common'; + +test.describe('Doc Tree', () => { + test('create new sub pages', async ({ page, browserName }) => { + await page.goto('/'); + await createDoc(page, 'doc-tree-content', browserName, 1); + const addButton = page.getByRole('button', { name: 'New page' }); + const docTree = page.getByTestId('doc-tree'); + + await expect(addButton).toBeVisible(); + + // Attendre et intercepter la requête POST pour créer une nouvelle page + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + + await addButton.click(); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const subPageJson = await response.json(); + + await expect(docTree).toBeVisible(); + const subPageItem = docTree + .getByTestId(`doc-sub-page-item-${subPageJson.id}`) + .first(); + + await expect(subPageItem).toBeVisible(); + await subPageItem.click(); + const input = page.getByRole('textbox', { name: 'doc title input' }); + await input.click(); + await input.fill('Test'); + await input.press('Enter'); + await expect(subPageItem.getByText('Test')).toBeVisible(); + await page.reload(); + await expect(subPageItem.getByText('Test')).toBeVisible(); + }); + + test('check the reorder of sub pages', async ({ page, browserName }) => { + await page.goto('/'); + await createDoc(page, 'doc-tree-content', browserName, 1); + const addButton = page.getByRole('button', { name: 'New page' }); + await expect(addButton).toBeVisible(); + + const docTree = page.getByTestId('doc-tree'); + + // Create first sub page + const firstResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + + await addButton.click(); + const firstResponse = await firstResponsePromise; + expect(firstResponse.ok()).toBeTruthy(); + + const secondResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + + // Create second sub page + await addButton.click(); + const secondResponse = await secondResponsePromise; + expect(secondResponse.ok()).toBeTruthy(); + + const secondSubPageJson = await secondResponse.json(); + const firstSubPageJson = await firstResponse.json(); + + const firstSubPageItem = docTree + .getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`) + .first(); + + const secondSubPageItem = docTree + .getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`) + .first(); + + // check that the sub pages are visible in the tree + await expect(firstSubPageItem).toBeVisible(); + await expect(secondSubPageItem).toBeVisible(); + + // get the bounding boxes of the sub pages + const firstSubPageBoundingBox = await firstSubPageItem.boundingBox(); + const secondSubPageBoundingBox = await secondSubPageItem.boundingBox(); + + expect(firstSubPageBoundingBox).toBeDefined(); + expect(secondSubPageBoundingBox).toBeDefined(); + + if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) { + throw new Error('Impossible de déterminer la position des éléments'); + } + + // move the first sub page to the second position + await page.mouse.move( + firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2, + firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2, + ); + + await page.mouse.down(); + + await page.mouse.move( + secondSubPageBoundingBox.x + secondSubPageBoundingBox.width / 2, + secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 4, + { steps: 10 }, + ); + + await page.mouse.up(); + + // check that the sub pages are visible in the tree + await expect(firstSubPageItem).toBeVisible(); + await expect(secondSubPageItem).toBeVisible(); + + // reload the page + await page.reload(); + + // check that the sub pages are visible in the tree + await expect(firstSubPageItem).toBeVisible(); + await expect(secondSubPageItem).toBeVisible(); + + // Check the position of the sub pages + const allSubPageItems = await docTree + .getByTestId(/^doc-sub-page-item/) + .all(); + + expect(allSubPageItems.length).toBe(2); + + // Vérifier que le premier élément a l'ID de la deuxième sous-page après le drag and drop + + await expect(allSubPageItems[0]).toHaveAttribute( + 'data-testid', + `doc-sub-page-item-${secondSubPageJson.id}`, + ); + + // Vérifier que le deuxième élément a l'ID de la première sous-page après le drag and drop + await expect(allSubPageItems[1]).toHaveAttribute( + 'data-testid', + `doc-sub-page-item-${firstSubPageJson.id}`, + ); + }); +}); diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index 2664e928..96f77a7b 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -8,6 +8,7 @@ export type DropdownMenuOption = { icon?: string; label: string; testId?: string; + value?: string; callback?: () => void | Promise; danger?: boolean; isSelected?: boolean; @@ -23,6 +24,8 @@ export type DropdownMenuProps = { buttonCss?: BoxProps['$css']; disabled?: boolean; topMessage?: string; + selectedValues?: string[]; + afterOpenChange?: (isOpen: boolean) => void; }; export const DropdownMenu = ({ @@ -34,6 +37,8 @@ export const DropdownMenu = ({ buttonCss, label, topMessage, + afterOpenChange, + selectedValues, }: PropsWithChildren) => { const theme = useCunninghamTheme(); const spacings = theme.spacingsTokens(); @@ -43,6 +48,7 @@ export const DropdownMenu = ({ const onOpenChange = (isOpen: boolean) => { setIsOpen(isOpen); + afterOpenChange?.(isOpen); }; if (disabled) { @@ -161,7 +167,8 @@ export const DropdownMenu = ({ {option.label} - {option.isSelected && ( + {(option.isSelected || + selectedValues?.includes(option.value ?? '')) && ( )} diff --git a/src/frontend/apps/impress/src/components/Icon.tsx b/src/frontend/apps/impress/src/components/Icon.tsx index 224f87b6..b3444661 100644 --- a/src/frontend/apps/impress/src/components/Icon.tsx +++ b/src/frontend/apps/impress/src/components/Icon.tsx @@ -5,10 +5,19 @@ import { useCunninghamTheme } from '@/cunningham'; type IconProps = TextType & { iconName: string; + isFilled?: boolean; }; -export const Icon = ({ iconName, ...textProps }: IconProps) => { +export const Icon = ({ iconName, isFilled, ...textProps }: IconProps) => { return ( - + {iconName} ); @@ -27,7 +36,7 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => { $size="36px" $theme="primary" $variation="600" - $background={colorsTokens()['primary-bg']} + $background={colorsTokens()['greyscale-000']} $css={` border: 1px solid ${colorsTokens()['primary-200']}; user-select: none; diff --git a/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx new file mode 100644 index 00000000..313209bf --- /dev/null +++ b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx @@ -0,0 +1,63 @@ +import { css } from 'styled-components'; + +import { Box } from '../Box'; +import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu'; +import { Icon } from '../Icon'; +import { Text } from '../Text'; + +export type FilterDropdownProps = { + options: DropdownMenuOption[]; + selectedValue?: string; +}; + +export const FilterDropdown = ({ + options, + selectedValue, +}: FilterDropdownProps) => { + const selectedOption = options.find( + (option) => option.value === selectedValue, + ); + + if (options.length === 0) { + return null; + } + + return ( + + + + {selectedOption?.label ?? options[0].label} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx index 2fa88e54..1334577e 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx @@ -57,6 +57,9 @@ export const QuickSearchInput = ({ /* eslint-disable-next-line jsx-a11y/no-autofocus */ autoFocus={true} aria-label={t('Quick search input')} + onClick={(e) => { + e.stopPropagation(); + }} value={inputValue} role="combobox" placeholder={placeholder ?? t('Search')} diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index 82463ccd..ee3caf00 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -1,10 +1,6 @@ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -import { - Tooltip, - VariantType, - useToastProvider, -} from '@openfun/cunningham-react'; +import { Tooltip } from '@openfun/cunningham-react'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -20,6 +16,8 @@ import { } from '@/docs/doc-management'; import { useBroadcastStore, useResponsiveStore } from '@/stores'; +import { useDocTreeData } from '../../doc-tree/context/DocTreeContext'; + interface DocTitleProps { doc: Doc; } @@ -57,18 +55,20 @@ const DocTitleInput = ({ doc }: DocTitleProps) => { const { t } = useTranslation(); const { colorsTokens } = useCunninghamTheme(); const [titleDisplay, setTitleDisplay] = useState(doc.title); - const { toast } = useToastProvider(); + const data = useDocTreeData(); const { untitledDocument } = useTrans(); const { broadcast } = useBroadcastStore(); const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_DOC, KEY_LIST_DOC], - onSuccess(data) { - toast(t('Document title updated successfully'), VariantType.SUCCESS); - + onSuccess(updatedDoc) { // Broadcast to every user connected to the document - broadcast(`${KEY_DOC}-${data.id}`); + broadcast(`${KEY_DOC}-${updatedDoc.id}`); + data?.tree?.updateNode(updatedDoc.id, { title: updatedDoc.title }); + if (updatedDoc.id === data?.root?.id) { + void data?.refreshRoot(); + } }, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index c9881ad7..81a64729 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -8,6 +8,7 @@ import { useAPIInfiniteQuery, } from '@/api'; +import { DocSearchTarget } from '../../doc-search/components/DocSearchFilters'; import { Doc } from '../types'; export const isDocsOrdering = (data: string): data is DocsOrdering => { @@ -31,6 +32,8 @@ export type DocsParams = { is_creator_me?: boolean; title?: string; is_favorite?: boolean; + target?: DocSearchTarget; + parent_id?: string; }; export type DocsResponse = APIList; @@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise => { if (params.is_favorite !== undefined) { searchParams.set('is_favorite', params.is_favorite.toString()); } - - const response = await fetchAPI(`documents/?${searchParams.toString()}`); + let response: Response; + if (params.parent_id && params.target === DocSearchTarget.CURRENT) { + response = await fetchAPI( + `documents/${params.parent_id}/descendants/?${searchParams.toString()}`, + ); + } else { + response = await fetchAPI(`documents/?${searchParams.toString()}`); + } if (!response.ok) { throw new APIError('Failed to get the docs', await errorCauses(response)); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index 38e6a2f3..53115c9d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -17,16 +17,20 @@ import { Doc } from '../types'; interface ModalRemoveDocProps { onClose: () => void; doc: Doc; + afterDelete?: (doc: Doc) => void; } -export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { +export const ModalRemoveDoc = ({ + onClose, + doc, + afterDelete, +}: ModalRemoveDocProps) => { const { toast } = useToastProvider(); const { push } = useRouter(); const pathname = usePathname(); const { mutate: removeDoc, - isError, error, } = useRemoveDoc({ @@ -34,6 +38,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { toast(t('The document has been deleted.'), VariantType.SUCCESS, { duration: 4000, }); + if (afterDelete) { + afterDelete(doc); + return; + } + if (pathname === '/') { onClose(); } else { @@ -87,7 +96,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { {!isError && ( - {t('Are you sure you want to delete this document ?')} + {t('Are you sure you want to delete the document "{{title}}"?', { + title: doc.title, + })} )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx new file mode 100644 index 00000000..ef206284 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx @@ -0,0 +1,66 @@ +import { Button } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components'; +import { FilterDropdown } from '@/components/filter/FilterDropdown'; + +export enum DocSearchTarget { + ALL = 'all', + CURRENT = 'current', +} + +export type DocSearchFiltersValues = { + target?: DocSearchTarget; +}; + +export type DocSearchFiltersProps = { + values?: DocSearchFiltersValues; + onValuesChange?: (values: DocSearchFiltersValues) => void; + onReset?: () => void; +}; + +export const DocSearchFilters = ({ + values, + onValuesChange, + onReset, +}: DocSearchFiltersProps) => { + const { t } = useTranslation(); + const hasFilters = Object.keys(values ?? {}).length > 0; + const handleTargetChange = (target: DocSearchTarget) => { + onValuesChange?.({ ...values, target }); + }; + + return ( + + + handleTargetChange(DocSearchTarget.ALL), + }, + { + label: t('Current doc'), + value: DocSearchTarget.CURRENT, + callback: () => handleTargetChange(DocSearchTarget.CURRENT), + }, + ]} + /> + + {hasFilters && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx index 10c2391d..b9ebc320 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx @@ -15,17 +15,36 @@ import { import { Doc, useInfiniteDocs } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; +import { useDocTreeData } from '../../doc-tree/context/DocTreeContext'; import EmptySearchIcon from '../assets/illustration-docs-empty.png'; +import { + DocSearchFilters, + DocSearchFiltersValues, + DocSearchTarget, +} from './DocSearchFilters'; import { DocSearchItem } from './DocSearchItem'; -type DocSearchModalProps = ModalProps & {}; +type DocSearchModalProps = ModalProps & { + showFilters?: boolean; + defaultFilters?: DocSearchFiltersValues; +}; -export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => { +export const DocSearchModal = ({ + showFilters = false, + defaultFilters, + ...modalProps +}: DocSearchModalProps) => { const { t } = useTranslation(); + const tree = useDocTreeData(); const router = useRouter(); + const [search, setSearch] = useState(''); + const [filters, setFilters] = useState( + defaultFilters ?? {}, + ); const { isDesktop } = useResponsiveStore(); + const { data, isFetching, @@ -36,27 +55,42 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => { } = useInfiniteDocs({ page: 1, title: search, + ...filters, + parent_id: tree?.root?.id, }); const loading = isFetching || isRefetching || isLoading; const handleInputSearch = useDebouncedCallback(setSearch, 300); const handleSelect = (doc: Doc) => { + if (tree?.initialRootId !== doc.id) { + tree?.tree.resetTree([]); + tree?.tree.setSelectedNode(doc); + tree?.setRoot(doc); + tree?.setInitialTargetId(doc.id); + } router.push(`/docs/${doc.id}`); modalProps.onClose?.(); }; + const handleResetFilters = () => { + setFilters({}); + }; + const docsData: QuickSearchData = useMemo(() => { const docs = data?.pages.flatMap((page) => page.results) || []; - + const groupName = + filters.target === DocSearchTarget.CURRENT + ? t('Select a page') + : t('Select a document'); return { - groupName: docs.length > 0 ? t('Select a document') : '', + groupName: docs.length > 0 ? groupName : '', elements: search ? docs : [], emptyString: t('No document found'), endActions: hasNextPage ? [{ content: void fetchNextPage()} /> }] : [], }; - }, [data, hasNextPage, fetchNextPage, t, search]); + }, [data, hasNextPage, fetchNextPage, t, search, filters.target]); return ( { onFilter={handleInputSearch} > + {showFilters && ( + + )} {search.length === 0 && ( { const { t } = useTranslation(); const { toast } = useToastProvider(); + const treeData = useDocTreeData(); const [isLoading, setIsLoading] = useState(false); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const [invitationRole, setInvitationRole] = useState(Role.EDITOR); @@ -91,14 +93,28 @@ export const DocShareAddMemberList = ({ }; return isInvitationMode - ? createInvitation({ - ...payload, - email: user.email, - }) - : createDocAccess({ - ...payload, - memberId: user.id, - }); + ? createInvitation( + { + ...payload, + email: user.email, + }, + { + onSuccess: () => { + void treeData?.tree.refreshNode(doc.id); + }, + }, + ) + : createDocAccess( + { + ...payload, + memberId: user.id, + }, + { + onSuccess: () => { + void treeData?.tree.refreshNode(doc.id); + }, + }, + ); }); const settledPromises = await Promise.allSettled(promises); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx index 1a6151e1..56974a30 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx @@ -11,6 +11,7 @@ import { useCunninghamTheme } from '@/cunningham'; import { Doc, Role } from '@/docs/doc-management'; import { User } from '@/features/auth'; +import { useDocTreeData } from '../../doc-tree/context/DocTreeContext'; import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api'; import { Invitation } from '../types'; @@ -25,6 +26,7 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { const { t } = useTranslation(); const { spacingsTokens } = useCunninghamTheme(); const spacing = spacingsTokens(); + const treeData = useDocTreeData(); const fakeUser: User = { id: invitation.email, full_name: invitation.email, @@ -37,6 +39,9 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { const canUpdate = doc.abilities.accesses_manage; const { mutate: updateDocInvitation } = useUpdateDocInvitation({ + onSuccess: () => { + void treeData?.tree.refreshNode(doc.id); + }, onError: (error) => { toast( error?.data?.role?.[0] ?? t('Error during update invitation'), @@ -49,6 +54,9 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { }); const { mutate: removeDocInvitation } = useDeleteDocInvitation({ + onSuccess: () => { + void treeData?.tree.refreshNode(doc.id); + }, onError: (error) => { toast( error?.data?.role?.[0] ?? t('Error during delete invitation'), diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx index 52f4ac0c..a9f3c195 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx @@ -11,6 +11,7 @@ import { useCunninghamTheme } from '@/cunningham'; import { Access, Doc, Role } from '@/docs/doc-management/'; import { useResponsiveStore } from '@/stores'; +import { useDocTreeData } from '../../doc-tree/context/DocTreeContext'; import { useDeleteDocAccess, useUpdateDocAccess } from '../api'; import { useWhoAmI } from '../hooks/'; @@ -25,6 +26,7 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { const { t } = useTranslation(); const { isLastOwner, isOtherOwner } = useWhoAmI(access); const { toast } = useToastProvider(); + const treeData = useDocTreeData(); const { isDesktop } = useResponsiveStore(); const { spacingsTokens } = useCunninghamTheme(); const spacing = spacingsTokens(); @@ -32,6 +34,9 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { isOtherOwner || !!isLastOwner || !doc.abilities.accesses_manage; const { mutate: updateDocAccess } = useUpdateDocAccess({ + onSuccess: () => { + void treeData?.tree.refreshNode(doc.id); + }, onError: () => { toast(t('Error during invitation update'), VariantType.ERROR, { duration: 4000, @@ -40,6 +45,9 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }); const { mutate: removeDocAccess } = useDeleteDocAccess({ + onSuccess: () => { + void treeData?.tree.refreshNode(doc.id); + }, onError: () => { toast(t('Error while deleting invitation'), VariantType.ERROR, { duration: 4000, diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx new file mode 100644 index 00000000..b9f774a8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc, KEY_LIST_DOC } from '../../doc-management'; + +export type CreateDocParam = Pick & { + parentId: string; +}; + +export const createDocChildren = async ({ + title, + parentId, +}: CreateDocParam): Promise => { + const response = await fetchAPI(`documents/${parentId}/children/`, { + method: 'POST', + body: JSON.stringify({ + title, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to create the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +interface CreateDocProps { + onSuccess: (data: Doc) => void; +} + +export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createDocChildren, + onSuccess: (data) => { + void queryClient.resetQueries({ + queryKey: [KEY_LIST_DOC], + }); + onSuccess(data); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx new file mode 100644 index 00000000..406c32a7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx @@ -0,0 +1,58 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api'; + +import { DocsResponse } from '../../doc-management'; + +export type DocsChildrenParams = { + docId: string; + page?: number; + page_size?: number; +}; + +export const getDocChildren = async ( + params: DocsChildrenParams, +): Promise => { + const { docId, page, page_size } = params; + const searchParams = new URLSearchParams(); + + if (page) { + searchParams.set('page', page.toString()); + } + if (page_size) { + searchParams.set('page_size', page_size.toString()); + } + + const response = await fetchAPI( + `documents/${docId}/children/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc children', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-children'; + +export function useDocChildren( + params: DocsChildrenParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocChildren(params), + ...queryConfig, + }); +} + +export const useInfiniteDocChildren = (params: DocsChildrenParams) => { + return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx new file mode 100644 index 00000000..37d71bbe --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx @@ -0,0 +1,45 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc } from '../../doc-management'; + +export type DocsTreeParams = { + docId: string; +}; + +export const getDocTree = async (params: DocsTreeParams): Promise => { + const { docId } = params; + const searchParams = new URLSearchParams(); + + const response = await fetchAPI( + `documents/${docId}/tree/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc tree', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-tree'; + +export function useDocTree( + params: DocsTreeParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocTree(params), + staleTime: 0, + refetchOnWindowFocus: false, + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg new file mode 100644 index 00000000..790684c6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx new file mode 100644 index 00000000..c66f1d4f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -0,0 +1,144 @@ +import { TreeViewItem, TreeViewNodeProps } from '@gouvfr-lasuite/ui-kit'; +import { useRouter } from 'next/navigation'; +import { css } from 'styled-components'; + +import { Box, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { Doc } from '@/features/docs/doc-management'; +import { useLeftPanelStore } from '@/features/left-panel'; + +import { useDocTreeData } from '../context/DocTreeContext'; + +import Logo from './../assets/sub-page-logo.svg'; +import { DocTreeItemActions } from './DocTreeItemActions'; + +const ItemTextCss = css` + overflow: hidden; + text-overflow: ellipsis; + white-space: initial; + display: -webkit-box; + line-clamp: 1; + /* width: 100%; */ + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +`; + +type Props = TreeViewNodeProps & { + doc: Doc; + setSelectedNode: (node: Doc) => void; +}; + +export const DocSubPageItem = ({ doc, setSelectedNode, ...props }: Props) => { + const { loadChildren, node } = props; + + const { spacingsTokens } = useCunninghamTheme(); + const spacing = spacingsTokens(); + const router = useRouter(); + const { togglePanel } = useLeftPanelStore(); + const treeData = useDocTreeData(); + + const afterCreate = (createdDoc: Doc) => { + const actualChildren = node.data.children ?? []; + + if (actualChildren.length === 0 && loadChildren) { + loadChildren(node?.data.value) + .then((allChildren) => { + node.open(); + + router.push(`/docs/${doc.id}`); + treeData?.tree.setChildren(node.data.value.id, allChildren); + togglePanel(); + }) + .catch(console.error); + } else { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: node.id, + }; + treeData?.tree.addChild(node.data.value.id, newDoc); + node.open(); + router.push(`/docs/${createdDoc.id}`); + togglePanel(); + } + }; + + if (!treeData) { + return null; + } + + return ( + + treeData?.tree.handleLoadChildren(props.node.data.value.id) + } + onClick={() => { + setSelectedNode(props.node.data.value as Doc); + router.push(`/docs/${props.node.data.value.id}`); + }} + > + + + + + + + + {doc.title} + + {doc.nb_accesses_direct > 1 && ( + + )} + + + + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx new file mode 100644 index 00000000..ad38c49a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -0,0 +1,175 @@ +import { OpenMap, TreeView, TreeViewMoveResult } from '@gouvfr-lasuite/ui-kit'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, SeparatedSection, StyledLink } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { Doc } from '../../doc-management'; +import { SimpleDocItem } from '../../docs-grid'; +import { useDocTree } from '../api/useDocTree'; +import { useMoveDoc } from '../api/useMove'; +import { useDocTreeData } from '../context/DocTreeContext'; + +import { DocSubPageItem } from './DocSubPageItem'; +import { DocTreeItemActions } from './DocTreeItemActions'; + +type DocTreeProps = { + initialTargetId: string; +}; +export const DocTree = ({ initialTargetId }: DocTreeProps) => { + const { spacingsTokens } = useCunninghamTheme(); + const spacing = spacingsTokens(); + const treeData = useDocTreeData(); + const router = useRouter(); + const [initialOpenState, setInitialOpenState] = useState( + undefined, + ); + + const { mutate: moveDoc } = useMoveDoc(); + + const { data } = useDocTree({ + docId: initialTargetId, + }); + + const handleMove = (result: TreeViewMoveResult) => { + moveDoc({ + sourceDocumentId: result.sourceId, + targetDocumentId: result.targetModeId, + position: result.mode, + }); + treeData?.tree.handleMove(result); + }; + useEffect(() => { + if (!data) { + return; + } + const { children: rootChildren, ...root } = data; + const children = rootChildren ?? []; + treeData?.setRoot(root); + const initialOpenState: OpenMap = {}; + initialOpenState[root.id] = true; + const serialize = (children: Doc[]) => { + children.forEach((child) => { + child.childrenCount = child.numchild ?? 0; + if (child?.children?.length && child?.children?.length > 0) { + initialOpenState[child.id] = true; + } + serialize(child.children ?? []); + }); + }; + serialize(children); + + treeData?.tree.resetTree(children); + setInitialOpenState(initialOpenState); + if (initialTargetId === root.id) { + treeData?.tree.setSelectedNode(root); + } else { + treeData?.tree.selectNodeById(initialTargetId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + const rootIsSelected = treeData?.tree.selectedNode?.id === treeData?.root?.id; + + if (!initialTargetId || !treeData) { + return null; + } + + return ( + + + + + {treeData.root !== null && ( + { + e.stopPropagation(); + e.preventDefault(); + treeData.tree.setSelectedNode(treeData.root ?? undefined); + router.push(`/docs/${treeData?.root?.id}`); + }} + > + + +
+ { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: treeData.root?.id ?? undefined, + }; + treeData?.tree.addChild(null, newDoc); + }} + /> +
+
+
+ )} +
+
+
+ + {initialOpenState && ( + { + return ( + + treeData.tree.handleLoadChildren(node.id) + } + setSelectedNode={treeData.tree.setSelectedNode} + /> + ); + }} + /> + )} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx new file mode 100644 index 00000000..1a19a3ab --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -0,0 +1,116 @@ +import { DropdownMenu, DropdownMenuOption } from '@gouvfr-lasuite/ui-kit'; +import { useModal } from '@openfun/cunningham-react'; +import { useRouter } from 'next/navigation'; +import { Fragment, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, BoxButton, Icon } from '@/components'; +import { useLeftPanelStore } from '@/features/left-panel'; + +import { Doc, ModalRemoveDoc } from '../../doc-management'; +import { useCreateChildrenDoc } from '../api/useCreateChildren'; +import { useDocTreeData } from '../context/DocTreeContext'; + +type DocTreeItemActionsProps = { + doc: Doc; + parentId?: string | null; + onCreateSuccess?: (newDoc: Doc) => void; +}; + +export const DocTreeItemActions = ({ + doc, + parentId, + onCreateSuccess, +}: DocTreeItemActionsProps) => { + const [isOpen, setIsOpen] = useState(false); + const router = useRouter(); + const { t } = useTranslation(); + const deleteModal = useModal(); + const { togglePanel } = useLeftPanelStore(); + + const treeData = useDocTreeData(); + const options: DropdownMenuOption[] = [ + { + label: t('Delete'), + icon: , + callback: deleteModal.open, + }, + ]; + + const { mutate: createChildrenDoc } = useCreateChildrenDoc({ + onSuccess: (doc) => { + onCreateSuccess?.(doc); + + togglePanel(); + treeData?.tree.setSelectedNode(doc); + }, + }); + + const afterDelete = () => { + if (parentId) { + router.push(`/docs/${parentId}`); + treeData?.tree.selectNodeById(parentId); + treeData?.tree.deleteNode(doc.id); + void treeData?.tree.refreshNode(parentId); + } else if (doc.id === treeData?.root?.id && !parentId) { + router.push(`/docs/`); + } else if (treeData && treeData.root) { + router.push(`/docs/${treeData.root.id}`); + treeData?.tree.deleteNode(doc.id); + treeData?.tree.setSelectedNode(treeData.root); + } + }; + + return ( + + + + { + e.stopPropagation(); + e.preventDefault(); + setIsOpen(!isOpen); + }} + iconName="more_horiz" + isFilled + $theme="primary" + $variation="600" + /> + + { + e.stopPropagation(); + e.preventDefault(); + + createChildrenDoc({ + title: t('Untitled page'), + parentId: doc.id, + }); + }} + color="primary" + > + + + + {deleteModal.isOpen && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/context/DocTreeContext.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/context/DocTreeContext.tsx new file mode 100644 index 00000000..180fe5a2 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/context/DocTreeContext.tsx @@ -0,0 +1,86 @@ +import { TreeViewDataType, useTree } from '@gouvfr-lasuite/ui-kit'; +import React, { ReactNode, createContext, useContext, useState } from 'react'; + +import { Doc, getDoc } from '../../doc-management'; +import { getDocChildren } from '../api/useDocChildren'; + +// import { useTree2 } from './useTree2'; + +// Interface pour le contexte de l'arbre de documents +interface DocTreeContextType { + tree: ReturnType>; + root: Doc | null; + initialTargetId?: string | null; + initialRootId?: string | null; + setRoot: (doc: Doc | null) => void; + setInitialTargetId: (id: string) => void; + refreshRoot: () => Promise; +} + +// Création du contexte avec une valeur par défaut +const DocTreeContext = createContext(undefined); + +// Props pour le provider +interface DocTreeProviderProps { + children: ReactNode; + initialData?: TreeViewDataType[]; + initialRootId?: string; + initialTargetId?: string; +} + +// Provider qui expose les fonctionnalités de l'arbre +export const DocTreeProvider: React.FC = ({ + children, + initialData = [], + initialRootId, + initialTargetId: targetId, +}) => { + const [root, setRoot] = useState(null); + const [initialTargetId, setInitialTargetId] = useState( + targetId ?? null, + ); + + const tree = useTree( + initialData, + async (docId) => { + const doc = await getDoc({ id: docId }); + + return { ...doc, childrenCount: doc.numchild }; + }, + async (docId) => { + const doc = await getDocChildren({ docId: docId }); + return doc.results ?? []; + }, + ); + + const refreshRoot = async () => { + if (!root) { + return; + } + const doc = await getDoc({ id: root.id }); + setRoot(doc); + }; + + const value: DocTreeContextType = { + tree, + root, + setRoot, + initialTargetId, + initialRootId, + setInitialTargetId, + refreshRoot, + }; + + return ( + {children} + ); +}; + +// Hook personnalisé pour utiliser le contexte +export const useDocTreeData = (): DocTreeContextType | undefined => { + const context = useContext(DocTreeContext); + if (context === undefined) { + return; + } + return context; +}; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx index c14edf9f..339096e6 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx @@ -1,15 +1,13 @@ -import { css } from 'styled-components'; - -import { Box, SeparatedSection } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; +import { Box } from '@/components'; import { useDocStore } from '@/docs/doc-management'; -import { SimpleDocItem } from '@/docs/docs-grid'; +import { DocTree } from '@/features/docs/doc-tree/components/DocTree'; +import { useDocTreeData } from '@/features/docs/doc-tree/context/DocTreeContext'; export const LeftPanelDocContent = () => { const { currentDoc } = useDocStore(); - const { spacingsTokens } = useCunninghamTheme(); - const spacing = spacingsTokens(); - if (!currentDoc) { + const tree = useDocTreeData(); + + if (!currentDoc || !tree) { return null; } @@ -19,19 +17,9 @@ export const LeftPanelDocContent = () => { $width="100%" $css="width: 100%; overflow-y: auto; overflow-x: hidden;" > - - - - - - - + {tree.initialTargetId && ( + + )}
); }; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx index 1ef117e9..eb160d6f 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx @@ -1,12 +1,15 @@ import { Button, ModalSize, useModal } from '@openfun/cunningham-react'; import { t } from 'i18next'; -import { useRouter } from 'next/navigation'; +import { useRouter } from 'next/router'; import { PropsWithChildren } from 'react'; import { Box, Icon, SeparatedSection } from '@/components'; -import { useCreateDoc } from '@/docs/doc-management'; import { DocSearchModal } from '@/docs/doc-search'; import { useAuth } from '@/features/auth'; +import { useCreateDoc, useDocStore } from '@/features/docs/doc-management'; +import { DocSearchTarget } from '@/features/docs/doc-search/components/DocSearchFilters'; +import { useCreateChildrenDoc } from '@/features/docs/doc-tree/api/useCreateChildren'; +import { useDocTreeData } from '@/features/docs/doc-tree/context/DocTreeContext'; import { useCmdK } from '@/hook/useCmdK'; import { useLeftPanelStore } from '../stores'; @@ -15,6 +18,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { const router = useRouter(); const searchModal = useModal(); const { authenticated } = useAuth(); + const docTreeData = useDocTreeData(); + const tree = docTreeData?.tree; + const { currentDoc } = useDocStore(); + const isDoc = router.pathname === '/docs/[id]'; + useCmdK(() => { const isEditorToolbarOpen = document.getElementsByClassName('bn-formatting-toolbar').length > 0; @@ -28,18 +36,34 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { const { mutate: createDoc } = useCreateDoc({ onSuccess: (doc) => { - router.push(`/docs/${doc.id}`); + void router.push(`/docs/${doc.id}`); + togglePanel(); + }, + }); + + const { mutate: createChildrenDoc } = useCreateChildrenDoc({ + onSuccess: (doc) => { + tree?.addRootNode(doc); + tree?.selectNodeById(doc.id); + void router.push(`/docs/${doc.id}`); togglePanel(); }, }); const goToHome = () => { - router.push('/'); + void router.push('/'); togglePanel(); }; const createNewDoc = () => { - createDoc(); + if (docTreeData && docTreeData.root && isDoc) { + createChildrenDoc({ + title: t('Untitled page'), + parentId: docTreeData.root.id, + }); + } else { + createDoc(); + } }; return ( @@ -73,15 +97,29 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { /> )}
+ {authenticated && ( - + )}
{children} {searchModal.isOpen && ( - + )} ); diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 71eed626..6998c7f7 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -7,14 +7,15 @@ import { useTranslation } from 'react-i18next'; import { Box, Text, TextErrors } from '@/components'; import { DocEditor } from '@/docs/doc-editor'; +import { KEY_AUTH, setAuthUrl } from '@/features/auth'; import { Doc, KEY_DOC, useCollaboration, useDoc, useDocStore, -} from '@/docs/doc-management/'; -import { KEY_AUTH, setAuthUrl } from '@/features/auth'; +} from '@/features/docs/doc-management/'; +import { DocTreeProvider } from '@/features/docs/doc-tree/context/DocTreeContext'; import { MainLayout } from '@/layouts'; import { useBroadcastStore } from '@/stores'; import { NextPageWithLayout } from '@/types/next'; @@ -34,9 +35,11 @@ export function DocLayout() { - - - + + + + + ); } @@ -84,6 +87,14 @@ const DocPage = ({ id }: DocProps) => { setCurrentDoc(docQuery); }, [docQuery, setCurrentDoc, isFetching]); + useEffect(() => { + return () => { + console.log('unmount'); + setCurrentDoc(undefined); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + /** * We add a broadcast task to reset the query cache * when the document visibility changes.