(frontend) added subpage management and document tree features

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.
This commit is contained in:
Nathan Panchout
2025-03-24 08:55:36 +01:00
parent a32ee20249
commit 0a6502a77d
24 changed files with 1163 additions and 65 deletions
@@ -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}`,
);
});
});
@@ -8,6 +8,7 @@ export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
value?: string;
callback?: () => void | Promise<unknown>;
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<DropdownMenuProps>) => {
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}
</Text>
</Box>
{option.isSelected && (
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
)}
</BoxButton>
@@ -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 (
<Text $isMaterialIcon {...textProps}>
<Text
$isMaterialIcon={!isFilled}
{...textProps}
className={
isFilled
? `material-icons-filled ${textProps.className}`
: textProps.className
}
>
{iconName}
</Text>
);
@@ -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;
@@ -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 (
<DropdownMenu
selectedValues={selectedValue ? [selectedValue] : undefined}
options={options}
>
<Box
$css={css`
border: 1px solid
${selectedOption
? 'var(--c--theme--colors--primary-500)'
: 'var(--c--theme--colors--greyscale-250)'};
border-radius: 4px;
background-color: ${selectedOption
? 'var(--c--theme--colors--primary-100)'
: 'var(--c--theme--colors--greyscale-000)'};
gap: var(--c--theme--spacings--2xs);
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
`}
color="secondary"
$direction="row"
$align="center"
>
<Text
$weight={400}
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
>
{selectedOption?.label ?? options[0].label}
</Text>
<Icon
$size="16px"
iconName="keyboard_arrow_down"
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
/>
</Box>
</DropdownMenu>
);
};
@@ -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')}
@@ -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();
}
},
});
@@ -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<Doc>;
@@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
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));
@@ -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) => {
<Box aria-label={t('Content modal to delete document')}>
{!isError && (
<Text $size="sm" $variation="600">
{t('Are you sure you want to delete this document ?')}
{t('Are you sure you want to delete the document "{{title}}"?', {
title: doc.title,
})}
</Text>
)}
@@ -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 (
<Box
$direction="row"
$align="center"
$height="35px"
$justify="space-between"
$gap="10px"
$margin={{ vertical: 'base' }}
>
<Box $direction="row" $align="center" $gap="10px">
<FilterDropdown
selectedValue={values?.target}
options={[
{
label: t('All docs'),
value: DocSearchTarget.ALL,
callback: () => handleTargetChange(DocSearchTarget.ALL),
},
{
label: t('Current doc'),
value: DocSearchTarget.CURRENT,
callback: () => handleTargetChange(DocSearchTarget.CURRENT),
},
]}
/>
</Box>
{hasFilters && (
<Button color="primary-text" size="small" onClick={onReset}>
{t('Reset')}
</Button>
)}
</Box>
);
};
@@ -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<DocSearchFiltersValues>(
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<Doc> = 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: <InView onChange={() => void fetchNextPage()} /> }]
: [],
};
}, [data, hasNextPage, fetchNextPage, t, search]);
}, [data, hasNextPage, fetchNextPage, t, search, filters.target]);
return (
<Modal
@@ -75,6 +109,13 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
onFilter={handleInputSearch}
>
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
{showFilters && (
<DocSearchFilters
values={filters}
onValuesChange={setFilters}
onReset={handleResetFilters}
/>
)}
{search.length === 0 && (
<Box
$direction="column"
@@ -13,6 +13,7 @@ import { useCunninghamTheme } from '@/cunningham';
import { User } from '@/features/auth';
import { Doc, Role } from '@/features/docs';
import { useDocTreeData } from '../../doc-tree/context/DocTreeContext';
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
import { OptionType } from '../types';
@@ -39,6 +40,7 @@ export const DocShareAddMemberList = ({
}: Props) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const treeData = useDocTreeData();
const [isLoading, setIsLoading] = useState(false);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [invitationRole, setInvitationRole] = useState<Role>(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);
@@ -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'),
@@ -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,
@@ -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<Doc, 'title'> & {
parentId: string;
};
export const createDocChildren = async ({
title,
parentId,
}: CreateDocParam): Promise<Doc> => {
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<Doc>;
};
interface CreateDocProps {
onSuccess: (data: Doc) => void;
}
export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, CreateDocParam>({
mutationFn: createDocChildren,
onSuccess: (data) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
onSuccess(data);
},
});
}
@@ -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<DocsResponse> => {
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<DocsResponse>;
};
export const KEY_LIST_DOC_CHILDREN = 'doc-children';
export function useDocChildren(
params: DocsChildrenParams,
queryConfig?: Omit<
UseQueryOptions<DocsResponse, APIError, DocsResponse>,
'queryKey' | 'queryFn'
>,
) {
return useQuery<DocsResponse, APIError, DocsResponse>({
queryKey: [KEY_LIST_DOC_CHILDREN, params],
queryFn: () => getDocChildren(params),
...queryConfig,
});
}
export const useInfiniteDocChildren = (params: DocsChildrenParams) => {
return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params);
};
@@ -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<Doc> => {
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<Doc>;
};
export const KEY_LIST_DOC_CHILDREN = 'doc-tree';
export function useDocTree(
params: DocsTreeParams,
queryConfig?: Omit<
UseQueryOptions<Doc, APIError, Doc>,
'queryKey' | 'queryFn'
>,
) {
return useQuery<Doc, APIError, Doc>({
queryKey: [KEY_LIST_DOC_CHILDREN, params],
queryFn: () => getDocTree(params),
staleTime: 0,
refetchOnWindowFocus: false,
...queryConfig,
});
}
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.40918 4.69434C5.28613 4.69434 5.18359 4.65332 5.10156 4.57129C5.02409 4.48926 4.98535 4.389 4.98535 4.27051C4.98535 4.15202 5.02409 4.05404 5.10156 3.97656C5.18359 3.89453 5.28613 3.85352 5.40918 3.85352H10.5977C10.7161 3.85352 10.8141 3.89453 10.8916 3.97656C10.9736 4.05404 11.0146 4.15202 11.0146 4.27051C11.0146 4.389 10.9736 4.48926 10.8916 4.57129C10.8141 4.65332 10.7161 4.69434 10.5977 4.69434H5.40918ZM5.40918 7.08008C5.28613 7.08008 5.18359 7.03906 5.10156 6.95703C5.02409 6.875 4.98535 6.77474 4.98535 6.65625C4.98535 6.53776 5.02409 6.43978 5.10156 6.3623C5.18359 6.28027 5.28613 6.23926 5.40918 6.23926H10.5977C10.7161 6.23926 10.8141 6.28027 10.8916 6.3623C10.9736 6.43978 11.0146 6.53776 11.0146 6.65625C11.0146 6.77474 10.9736 6.875 10.8916 6.95703C10.8141 7.03906 10.7161 7.08008 10.5977 7.08008H5.40918ZM5.40918 9.46582C5.28613 9.46582 5.18359 9.42708 5.10156 9.34961C5.02409 9.26758 4.98535 9.1696 4.98535 9.05566C4.98535 8.93262 5.02409 8.83008 5.10156 8.74805C5.18359 8.66602 5.28613 8.625 5.40918 8.625H7.86328C7.98633 8.625 8.08659 8.66602 8.16406 8.74805C8.24609 8.83008 8.28711 8.93262 8.28711 9.05566C8.28711 9.1696 8.24609 9.26758 8.16406 9.34961C8.08659 9.42708 7.98633 9.46582 7.86328 9.46582H5.40918ZM2.25098 13.2529V2.88281C2.25098 2.17188 2.42643 1.63639 2.77734 1.27637C3.13281 0.916341 3.66374 0.736328 4.37012 0.736328H11.6299C12.3363 0.736328 12.8649 0.916341 13.2158 1.27637C13.5713 1.63639 13.749 2.17188 13.749 2.88281V13.2529C13.749 13.9684 13.5713 14.5039 13.2158 14.8594C12.8649 15.2148 12.3363 15.3926 11.6299 15.3926H4.37012C3.66374 15.3926 3.13281 15.2148 2.77734 14.8594C2.42643 14.5039 2.25098 13.9684 2.25098 13.2529ZM3.35156 13.2324C3.35156 13.5742 3.44043 13.8363 3.61816 14.0186C3.80046 14.2008 4.06934 14.292 4.4248 14.292H11.5752C11.9307 14.292 12.1973 14.2008 12.375 14.0186C12.5573 13.8363 12.6484 13.5742 12.6484 13.2324V2.90332C12.6484 2.56152 12.5573 2.29948 12.375 2.11719C12.1973 1.93034 11.9307 1.83691 11.5752 1.83691H4.4248C4.06934 1.83691 3.80046 1.93034 3.61816 2.11719C3.44043 2.29948 3.35156 2.56152 3.35156 2.90332V13.2324Z" fill="#8585F6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -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: 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 (
<TreeViewItem
{...props}
loadChildren={() =>
treeData?.tree.handleLoadChildren(props.node.data.value.id)
}
onClick={() => {
setSelectedNode(props.node.data.value as Doc);
router.push(`/docs/${props.node.data.value.id}`);
}}
>
<Box
data-testid={`doc-sub-page-item-${props.node.data.value.id}`}
$width="100%"
$direction="row"
$gap={spacing['xs']}
role="button"
tabIndex={0}
$align="center"
$css={css`
.light-doc-item-actions {
display: 'flex';
opacity: 0;
&:has(.isOpen) {
opacity: 1;
}
}
&:hover {
.light-doc-item-actions {
opacity: 1;
}
}
`}
>
<Box $width={16} $height={16}>
<Logo />
</Box>
<Box
$direction="row"
$align="center"
$css={css`
display: flex;
flex-direction: row;
width: 100%;
gap: 0.5rem;
align-items: center;
`}
>
<Text $css={ItemTextCss} $size="sm">
{doc.title}
</Text>
{doc.nb_accesses_direct > 1 && (
<Icon isFilled iconName="group" $size="16px" $variation="400" />
)}
</Box>
<Box
$direction="row"
$gap={spacing['xs']}
$align="center"
className="light-doc-item-actions"
>
<DocTreeItemActions
doc={doc}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</Box>
</TreeViewItem>
);
};
@@ -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<OpenMap | undefined>(
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 (
<Box data-testid="doc-tree" $height="100%">
<SeparatedSection showSeparator={false}>
<Box $padding={{ horizontal: 'sm' }}>
<Box
$css={css`
padding: ${spacing['2xs']};
border-radius: 4px;
width: 100%;
background-color: ${rootIsSelected
? 'var(--c--theme--colors--greyscale-100)'
: 'transparent'};
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
}
.doc-tree-root-item-actions {
display: 'flex';
opacity: 0;
&:has(.isOpen) {
opacity: 1;
}
}
&:hover {
.doc-tree-root-item-actions {
opacity: 1;
}
}
`}
>
{treeData.root !== null && (
<StyledLink
$css={css`
width: 100%;
`}
href={`/docs/${treeData.root.id}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
treeData.tree.setSelectedNode(treeData.root ?? undefined);
router.push(`/docs/${treeData?.root?.id}`);
}}
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={treeData.root} showAccesses={true} />
<div className="doc-tree-root-item-actions">
<DocTreeItemActions
doc={treeData.root}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: treeData.root?.id ?? undefined,
};
treeData?.tree.addChild(null, newDoc);
}}
/>
</div>
</Box>
</StyledLink>
)}
</Box>
</Box>
</SeparatedSection>
{initialOpenState && (
<TreeView
handleMove={handleMove}
initialOpenState={initialOpenState}
selectedNodeId={
treeData.tree.selectedNode?.id ??
treeData.initialTargetId ??
undefined
}
treeData={treeData.tree.nodes ?? []}
rootNodeId={treeData.root?.id ?? ''}
renderNode={(props) => {
return (
<DocSubPageItem
{...props}
doc={props.node.data.value as Doc}
loadChildren={(node) =>
treeData.tree.handleLoadChildren(node.id)
}
setSelectedNode={treeData.tree.setSelectedNode}
/>
);
}}
/>
)}
</Box>
);
};
@@ -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: <Icon iconName="delete" />,
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 (
<Fragment>
<Box
$direction="row"
$align="center"
className={` ${isOpen ? 'isOpen' : ''}`}
$css={css`
gap: var(--c--theme----c--theme--spacings--xs);
`}
>
<DropdownMenu
options={options}
isOpen={isOpen}
onOpenChange={setIsOpen}
>
<Icon
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setIsOpen(!isOpen);
}}
iconName="more_horiz"
isFilled
$theme="primary"
$variation="600"
/>
</DropdownMenu>
<BoxButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
createChildrenDoc({
title: t('Untitled page'),
parentId: doc.id,
});
}}
color="primary"
>
<Icon $variation="800" $theme="primary" isFilled iconName="add_box" />
</BoxButton>
</Box>
{deleteModal.isOpen && (
<ModalRemoveDoc
onClose={deleteModal.onClose}
doc={doc}
afterDelete={afterDelete}
/>
)}
</Fragment>
);
};
@@ -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<typeof useTree<Doc>>;
root: Doc | null;
initialTargetId?: string | null;
initialRootId?: string | null;
setRoot: (doc: Doc | null) => void;
setInitialTargetId: (id: string) => void;
refreshRoot: () => Promise<void>;
}
// Création du contexte avec une valeur par défaut
const DocTreeContext = createContext<DocTreeContextType | undefined>(undefined);
// Props pour le provider
interface DocTreeProviderProps {
children: ReactNode;
initialData?: TreeViewDataType<Doc>[];
initialRootId?: string;
initialTargetId?: string;
}
// Provider qui expose les fonctionnalités de l'arbre
export const DocTreeProvider: React.FC<DocTreeProviderProps> = ({
children,
initialData = [],
initialRootId,
initialTargetId: targetId,
}) => {
const [root, setRoot] = useState<Doc | null>(null);
const [initialTargetId, setInitialTargetId] = useState<string | null>(
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 (
<DocTreeContext.Provider value={value}>{children}</DocTreeContext.Provider>
);
};
// Hook personnalisé pour utiliser le contexte
export const useDocTreeData = (): DocTreeContextType | undefined => {
const context = useContext(DocTreeContext);
if (context === undefined) {
return;
}
return context;
};
@@ -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;"
>
<SeparatedSection showSeparator={false}>
<Box $padding={{ horizontal: 'sm' }}>
<Box
$css={css`
padding: ${spacing['2xs']};
border-radius: 4px;
background-color: var(--c--theme--colors--greyscale-100);
`}
>
<SimpleDocItem doc={currentDoc} showAccesses={true} />
</Box>
</Box>
</SeparatedSection>
{tree.initialTargetId && (
<DocTree initialTargetId={tree.initialTargetId} />
)}
</Box>
);
};
@@ -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) => {
/>
)}
</Box>
{authenticated && (
<Button onClick={createNewDoc}>{t('New doc')}</Button>
<Button
color={!isDoc ? 'primary' : 'tertiary'}
onClick={createNewDoc}
disabled={currentDoc && !currentDoc.abilities.update}
>
{t(isDoc ? 'New page' : 'New doc')}
</Button>
)}
</Box>
</SeparatedSection>
{children}
</Box>
{searchModal.isOpen && (
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
<DocSearchModal
{...searchModal}
size={ModalSize.LARGE}
showFilters={isDoc}
defaultFilters={{
target: isDoc ? DocSearchTarget.CURRENT : undefined,
}}
/>
)}
</>
);
@@ -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() {
<meta name="robots" content="noindex" />
</Head>
<MainLayout>
<DocPage id={id} />
</MainLayout>
<DocTreeProvider initialTargetId={id}>
<MainLayout>
<DocPage id={id} />
</MainLayout>
</DocTreeProvider>
</>
);
}
@@ -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.