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.