(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-17 15:13:02 +01:00
committed by Anthony LC
parent cb2ecfcea3
commit 9a64ebc1e9
41 changed files with 1703 additions and 127 deletions

View File

@@ -10,6 +10,7 @@ and this project adheres to
### Added
- ✨(frontend) multi-pages #701
- ✨(backend) add ancestors links definitions to document abilities #846
- ✨(backend) include ancestors accesses on document accesses list view # 846
- ✨(backend) add ancestors links reach and role to document API #846

View File

@@ -78,16 +78,19 @@ export const createDoc = async (
docName: string,
browserName: string,
length: number = 1,
isChild: boolean = false,
) => {
const randomDocs = randomName(docName, browserName, length);
for (let i = 0; i < randomDocs.length; i++) {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
if (!isChild) {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
}
await page
.getByRole('button', {
name: 'New doc',
name: isChild ? 'New page' : 'New doc',
})
.click();

View File

@@ -14,10 +14,10 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Create', () => {
test('it creates a doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
const [docTitle] = await createDoc(page, 'my-new-doc', browserName, 1);
await page.waitForFunction(
() => document.title.match(/My new doc - Docs/),
() => document.title.match(/my-new-doc - Docs/),
{ timeout: 5000 },
);

View File

@@ -172,6 +172,7 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
await page.goto('/');
await page
.getByRole('button', {
name: 'New doc',

View File

@@ -60,7 +60,7 @@ test.describe('Doc Routing', () => {
});
test('checks 401 on docs/[id] page', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
const [docTitle] = await createDoc(page, '401-doc', browserName, 1);
await verifyDocName(page, docTitle);
const responsePromise = page.route(

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
import { createDoc, randomName, verifyDocName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -94,4 +94,85 @@ test.describe('Document search', () => {
page.getByLabel('Search modal').getByText('search'),
).toBeHidden();
});
test("it checks we don't see filters in search modal", async ({ page }) => {
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' });
await expect(searchButton).toBeVisible();
await page.getByRole('button', { name: 'search', exact: true }).click();
await expect(
page.getByRole('combobox', { name: 'Quick search input' }),
).toBeVisible();
await expect(page.getByTestId('doc-search-filters')).toBeHidden();
});
});
test.describe('Sub page search', () => {
test('it check the precense of filters in search modal', async ({
page,
browserName,
}) => {
await page.goto('/');
const [doc1Title] = await createDoc(
page,
'My sub page search',
browserName,
1,
);
await verifyDocName(page, doc1Title);
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' });
await searchButton.click();
const filters = page.getByTestId('doc-search-filters');
await expect(filters).toBeVisible();
await filters.click();
await filters.getByRole('button', { name: 'Current doc' }).click();
await expect(
page.getByRole('menuitem', { name: 'All docs' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Current doc' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Current doc' }).click();
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
});
test('it searches sub pages', async ({ page, browserName }) => {
await page.goto('/');
const [doc1Title] = await createDoc(
page,
'My sub page search',
browserName,
1,
);
await verifyDocName(page, doc1Title);
await page.getByRole('button', { name: 'New page' }).click();
await verifyDocName(page, '');
await page.getByRole('textbox', { name: 'doc title input' }).click();
await page
.getByRole('textbox', { name: 'doc title input' })
.press('ControlOrMeta+a');
const [randomDocName] = randomName('doc-sub-page', browserName, 1);
await page
.getByRole('textbox', { name: 'doc title input' })
.fill(randomDocName);
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' });
await searchButton.click();
await expect(
page.getByRole('button', { name: 'Current doc' }),
).toBeVisible();
await page.getByRole('combobox', { name: 'Quick search input' }).click();
await page
.getByRole('combobox', { name: 'Quick search input' })
.fill('sub');
await expect(page.getByLabel(randomDocName)).toBeVisible();
});
});

View File

@@ -0,0 +1,279 @@
/* eslint-disable playwright/no-conditional-in-test */
import { expect, test } from '@playwright/test';
import {
createDoc,
expectLoginPage,
keyCloakSignIn,
randomName,
verifyDocName,
} from './common';
test.describe('Doc Tree', () => {
test('create new sub pages', async ({ page, browserName }) => {
await page.goto('/');
const [titleParent] = await createDoc(
page,
'doc-tree-content',
browserName,
1,
);
await verifyDocName(page, titleParent);
const addButton = page.getByRole('button', { name: 'New page' });
const docTree = page.getByTestId('doc-tree');
await expect(addButton).toBeVisible();
// Wait for and intercept the POST request to create a new 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();
await verifyDocName(page, '');
const input = page.getByRole('textbox', { name: 'doc title input' });
await input.click();
const [randomDocName] = randomName('doc-tree-test', browserName, 1);
await input.fill(randomDocName);
await input.press('Enter');
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
await page.reload();
await expect(subPageItem.getByText(randomDocName)).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);
// Check that the first element has the ID of the second sub page after the drag and drop
await expect(allSubPageItems[0]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${secondSubPageJson.id}`,
);
// Check that the second element has the ID of the first sub page after the drag and drop
await expect(allSubPageItems[1]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${firstSubPageJson.id}`,
);
});
});
test.describe('Doc Tree: Inheritance', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A child inherit from the parent', async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docParent] = await createDoc(
page,
'doc-tree-inheritance-parent',
browserName,
1,
);
await verifyDocName(page, docParent);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const [docChild] = await createDoc(
page,
'doc-tree-inheritance-child',
browserName,
1,
true,
);
await verifyDocName(page, docChild);
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expectLoginPage(page);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docChild)).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docParent)).toBeVisible();
});
test('Do not show private parent from children', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docParent] = await createDoc(
page,
'doc-tree-inheritance-private-parent',
browserName,
1,
);
await verifyDocName(page, docParent);
const [docChild] = await createDoc(
page,
'doc-tree-inheritance-private-child',
browserName,
1,
true,
);
await verifyDocName(page, docChild);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expectLoginPage(page);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docChild)).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docParent)).toBeHidden();
});
});

View File

@@ -246,7 +246,7 @@ test.describe('Doc Visibility: Public', () => {
).toBeVisible();
await expect(page.getByRole('button', { name: 'search' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New page' })).toBeVisible();
const urlDoc = page.url();
@@ -262,7 +262,8 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'search' })).toBeHidden();
await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden();
await expect(page.getByRole('button', { name: 'New page' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();

View File

@@ -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 { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
@@ -41,6 +46,7 @@ export const DropdownMenu = ({
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
afterOpenChange?.(isOpen);
};
if (disabled) {
@@ -163,7 +169,8 @@ export const DropdownMenu = ({
{option.label}
</Text>
</Box>
{option.isSelected && (
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
)}
</BoxButton>

View File

@@ -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>
);
};

View File

@@ -56,6 +56,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')}

View File

@@ -1,10 +1,7 @@
/* 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 { useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -15,6 +12,7 @@ import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
KEY_SUB_PAGE,
useTrans,
useUpdateDoc,
} from '@/docs/doc-management';
@@ -54,21 +52,24 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const { toast } = useToastProvider();
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}`);
queryClient.setQueryData(
[KEY_SUB_PAGE, { id: updatedDoc.id }],
updatedDoc,
);
},
});

View File

@@ -19,6 +19,7 @@ export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
};
export const KEY_DOC = 'doc';
export const KEY_SUB_PAGE = 'sub-page';
export const KEY_DOC_VISIBILITY = 'doc-visibility';
export function useDoc(
@@ -26,7 +27,7 @@ export function useDoc(
queryConfig?: UseQueryOptions<Doc, APIError, Doc>,
) {
return useQuery<Doc, APIError, Doc>({
queryKey: [KEY_DOC, param],
queryKey: queryConfig?.queryKey ?? [KEY_DOC, param],
queryFn: () => getDoc(param),
...queryConfig,
});

View File

@@ -53,7 +53,6 @@ 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()}`);
if (!response.ok) {

View File

@@ -12,21 +12,27 @@ import { useRouter } from 'next/router';
import { Box, Text, TextErrors } from '@/components';
import { useRemoveDoc } from '../api/useRemoveDoc';
import { useTrans } from '../hooks';
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 { untitledDocument } = useTrans();
const {
mutate: removeDoc,
isError,
error,
} = useRemoveDoc({
@@ -34,6 +40,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 {
@@ -90,7 +101,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
>
{!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 ?? untitledDocument,
})}
</Text>
)}

View File

@@ -0,0 +1,68 @@
import { t } from 'i18next';
import { useEffect, useMemo } from 'react';
import { InView } from 'react-intersection-observer';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { Doc, useInfiniteDocs } from '../../doc-management';
import { DocSearchFiltersValues } from './DocSearchFilters';
import { DocSearchItem } from './DocSearchItem';
type DocSearchContentProps = {
search: string;
filters: DocSearchFiltersValues;
onSelect: (doc: Doc) => void;
onLoadingChange?: (loading: boolean) => void;
};
export const DocSearchContent = ({
search,
filters,
onSelect,
onLoadingChange,
}: DocSearchContentProps) => {
const {
data,
isFetching,
isRefetching,
isLoading,
fetchNextPage,
hasNextPage,
} = useInfiniteDocs({
page: 1,
title: search,
...filters,
});
const loading = isFetching || isRefetching || isLoading;
const docsData: QuickSearchData<Doc> = useMemo(() => {
const docs = data?.pages.flatMap((page) => page.results) || [];
return {
groupName: docs.length > 0 ? t('Select a document') : '',
elements: search ? docs : [],
emptyString: t('No document found'),
endActions: hasNextPage
? [
{
content: <InView onChange={() => void fetchNextPage()} />,
},
]
: [],
};
}, [search, data?.pages, fetchNextPage, hasNextPage]);
useEffect(() => {
onLoadingChange?.(loading);
}, [loading, onLoadingChange]);
return (
<QuickSearchGroup
onSelect={onSelect}
group={docsData}
renderElement={(doc) => <DocSearchItem doc={doc} />}
/>
);
};

View File

@@ -0,0 +1,67 @@
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"
data-testid="doc-search-filters"
$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>
);
};

View File

@@ -1,65 +1,61 @@
import { Modal, ModalSize } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
import { useDebouncedCallback } from 'use-debounce';
import { Box } from '@/components';
import {
QuickSearch,
QuickSearchData,
QuickSearchGroup,
} from '@/components/quick-search';
import { Doc, useInfiniteDocs } from '@/docs/doc-management';
import { QuickSearch } from '@/components/quick-search';
import { useResponsiveStore } from '@/stores';
import { Doc } from '../../doc-management';
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
import { DocSearchItem } from './DocSearchItem';
import { DocSearchContent } from './DocSearchContent';
import {
DocSearchFilters,
DocSearchFiltersValues,
DocSearchTarget,
} from './DocSearchFilters';
import { DocSearchSubPageContent } from './DocSearchSubPageContent';
type DocSearchModalProps = {
onClose: () => void;
isOpen: boolean;
showFilters?: boolean;
defaultFilters?: DocSearchFiltersValues;
};
export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
export const DocSearchModal = ({
showFilters = false,
defaultFilters,
...modalProps
}: DocSearchModalProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const router = useRouter();
const isDocPage = router.pathname === '/docs/[id]';
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<DocSearchFiltersValues>(
defaultFilters ?? {},
);
const target = filters.target ?? DocSearchTarget.ALL;
const { isDesktop } = useResponsiveStore();
const {
data,
isFetching,
isRefetching,
isLoading,
fetchNextPage,
hasNextPage,
} = useInfiniteDocs({
page: 1,
title: search,
});
const loading = isFetching || isRefetching || isLoading;
const handleInputSearch = useDebouncedCallback(setSearch, 300);
const handleSelect = (doc: Doc) => {
router.push(`/docs/${doc.id}`);
void router.push(`/docs/${doc.id}`);
modalProps.onClose?.();
};
const docsData: QuickSearchData<Doc> = useMemo(() => {
const docs = data?.pages.flatMap((page) => page.results) || [];
return {
groupName: docs.length > 0 ? t('Select a document') : '',
elements: search ? docs : [],
emptyString: t('No document found'),
endActions: hasNextPage
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
: [],
};
}, [data, hasNextPage, fetchNextPage, t, search]);
const handleResetFilters = () => {
setFilters({});
};
return (
<Modal
@@ -79,6 +75,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"
@@ -95,11 +98,24 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
</Box>
)}
{search && (
<QuickSearchGroup
onSelect={handleSelect}
group={docsData}
renderElement={(doc) => <DocSearchItem doc={doc} />}
/>
<>
{target === DocSearchTarget.ALL && (
<DocSearchContent
search={search}
filters={filters}
onSelect={handleSelect}
onLoadingChange={setLoading}
/>
)}
{isDocPage && target === DocSearchTarget.CURRENT && (
<DocSearchSubPageContent
search={search}
filters={filters}
onSelect={handleSelect}
onLoadingChange={setLoading}
/>
)}
</>
)}
</Box>
</QuickSearch>

View File

@@ -0,0 +1,73 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { t } from 'i18next';
import { useEffect, useMemo } from 'react';
import { InView } from 'react-intersection-observer';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { Doc } from '../../doc-management';
import { useInfiniteSubDocs } from '../../doc-management/api/useSubDocs';
import { DocSearchFiltersValues } from './DocSearchFilters';
import { DocSearchItem } from './DocSearchItem';
type DocSearchSubPageContentProps = {
search: string;
filters: DocSearchFiltersValues;
onSelect: (doc: Doc) => void;
onLoadingChange?: (loading: boolean) => void;
};
export const DocSearchSubPageContent = ({
search,
filters,
onSelect,
onLoadingChange,
}: DocSearchSubPageContentProps) => {
const treeContext = useTreeContext<Doc>();
const {
data: subDocsData,
isFetching,
isRefetching,
isLoading,
fetchNextPage: subDocsFetchNextPage,
hasNextPage: subDocsHasNextPage,
} = useInfiniteSubDocs({
page: 1,
title: search,
...filters,
parent_id: treeContext?.root?.id ?? '',
});
const loading = isFetching || isRefetching || isLoading;
const docsData: QuickSearchData<Doc> = useMemo(() => {
const subDocs = subDocsData?.pages.flatMap((page) => page.results) || [];
return {
groupName: subDocs.length > 0 ? t('Select a page') : '',
elements: search ? subDocs : [],
emptyString: t('No document found'),
endActions: subDocsHasNextPage
? [
{
content: <InView onChange={() => void subDocsFetchNextPage()} />,
},
]
: [],
};
}, [search, subDocsData, subDocsFetchNextPage, subDocsHasNextPage]);
useEffect(() => {
onLoadingChange?.(loading);
}, [loading, onLoadingChange]);
return (
<QuickSearchGroup
onSelect={onSelect}
group={docsData}
renderElement={(doc) => <DocSearchItem doc={doc} />}
/>
);
};

View File

@@ -1 +1,2 @@
export * from './DocSearchModal';
export * from './DocSearchFilters';

View File

@@ -3,6 +3,7 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -10,7 +11,7 @@ import { css } from 'styled-components';
import { APIError } from '@/api';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/docs/doc-management';
import { Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
@@ -39,11 +40,12 @@ export const DocShareAddMemberList = ({
}: Props) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [isLoading, setIsLoading] = useState(false);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
const canShare = doc.abilities.accesses_manage;
const queryClient = useQueryClient();
const { mutateAsync: createInvitation } = useCreateDocInvitation();
const { mutateAsync: createDocAccess } = useCreateDocAccess();
@@ -89,14 +91,32 @@ export const DocShareAddMemberList = ({
};
return isInvitationMode
? createInvitation({
...payload,
email: user.email,
})
: createDocAccess({
...payload,
memberId: user.id,
});
? createInvitation(
{
...payload,
email: user.email,
},
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
},
)
: createDocAccess(
{
...payload,
memberId: user.id,
},
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
},
);
});
const settledPromises = await Promise.allSettled(promises);

View File

@@ -1,4 +1,5 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -14,7 +15,7 @@ import {
} from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/docs/doc-management';
import { Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import {
@@ -37,6 +38,7 @@ const DocShareInvitationItem = ({
invitation,
}: DocShareInvitationItemProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { spacingsTokens } = useCunninghamTheme();
const invitedUser: User = {
id: invitation.email,
@@ -50,6 +52,11 @@ const DocShareInvitationItem = ({
const canUpdate = doc.abilities.accesses_manage;
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during update invitation'),
@@ -62,6 +69,11 @@ const DocShareInvitationItem = ({
});
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during delete invitation'),

View File

@@ -1,4 +1,5 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,7 +12,7 @@ import {
} from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, Role } from '@/docs/doc-management/';
import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/';
import { useResponsiveStore } from '@/stores';
import {
@@ -31,8 +32,10 @@ type Props = {
const DocShareMemberItem = ({ doc, access }: Props) => {
const { t } = useTranslation();
const { isLastOwner } = useWhoAmI(access);
const queryClient = useQueryClient();
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
const { toast } = useToastProvider();
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
@@ -43,6 +46,11 @@ const DocShareMemberItem = ({ doc, access }: Props) => {
: undefined;
const { mutate: updateDocAccess } = useUpdateDocAccess({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: () => {
toast(t('Error while updating the member role.'), VariantType.ERROR, {
duration: 4000,
@@ -51,6 +59,11 @@ const DocShareMemberItem = ({ doc, access }: Props) => {
});
const { mutate: removeDocAccess } = useDeleteDocAccess({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
});
},
onError: () => {
toast(t('Error while deleting the member.'), VariantType.ERROR, {
duration: 4000,

View File

@@ -0,0 +1,2 @@
export * from './useCreateChildren';
export * from './useDocChildren';

View File

@@ -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);
},
});
}

View File

@@ -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);
};

View File

@@ -0,0 +1,44 @@
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 ({ docId }: DocsTreeParams): Promise<Doc> => {
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,
});
}

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="doc-extract-bold">
<g id="v">
<path d="M6.55506 1.00488C5.47953 1.00488 4.65315 1.27911 4.09474 1.84468C3.54378 2.40996 3.27635 3.243 3.27635 4.32461V9.32492C3.27635 9.80842 3.66829 10.2004 4.15179 10.2004C4.63528 10.2004 5.02723 9.80841 5.02723 9.32492V4.35537C5.02723 3.84268 5.16053 3.44961 5.42713 3.17617C5.70057 2.8959 6.10389 2.75576 6.63709 2.75576H17.3627C17.8959 2.75576 18.2958 2.8959 18.5624 3.17617C18.8358 3.44961 18.9725 3.84268 18.9725 4.35537V19.849C18.9725 20.3617 18.8358 20.7548 18.5624 21.0282C18.2958 21.3017 17.8959 21.4384 17.3627 21.4384H6.15179C5.66829 21.4384 5.27635 21.8303 5.27635 22.3138C5.27635 22.7973 5.66829 23.1893 6.15179 23.1893H17.4447C18.5196 23.1893 19.3427 22.9188 19.8945 22.36C20.4531 21.8013 20.7234 20.9681 20.7234 19.8798V4.32461C20.7234 3.24283 20.4529 2.41014 19.895 1.84491C19.3433 1.27899 18.52 1.00488 17.4447 1.00488H6.55506Z" fill="#3A3A3A"/>
<path d="M7.57952 6.92596C7.44484 6.78335 7.37791 6.60799 7.37791 6.40613C7.37791 6.20426 7.44493 6.03109 7.58152 5.8945C7.72454 5.75147 7.90435 5.68064 8.11365 5.68064H15.8964C16.0991 5.68064 16.2722 5.75171 16.408 5.89447C16.5508 6.03032 16.6219 6.20341 16.6219 6.40613C16.6219 6.60927 16.5506 6.78494 16.409 6.92701C16.273 7.07046 16.0996 7.14187 15.8964 7.14187H8.11365C7.90435 7.14187 7.72454 7.07104 7.58152 6.92801L7.57952 6.92596Z" fill="#3A3A3A"/>
<path d="M7.57952 10.5046C7.44484 10.362 7.37791 10.1866 7.37791 9.98474C7.37791 9.78287 7.44493 9.6097 7.58152 9.47311C7.72454 9.33009 7.90435 9.25925 8.11365 9.25925H12.8964C13.0991 9.25925 13.2722 9.33033 13.408 9.47309C13.5508 9.60894 13.6219 9.78203 13.6219 9.98474C13.6219 10.1879 13.5506 10.3635 13.409 10.5056C13.273 10.6491 13.0996 10.7205 12.8964 10.7205H8.11365C7.90435 10.7205 7.72454 10.6497 7.58152 10.5066L7.57952 10.5046Z" fill="#3A3A3A"/>
<path d="M9.00585 15.2969C9.25312 15.2969 9.46502 15.3871 9.63536 15.5651L9.63681 15.5667C9.80413 15.7492 9.89012 15.9622 9.89012 16.2018C9.89012 16.4476 9.80462 16.6615 9.63536 16.8385C9.46502 17.0166 9.25312 17.1067 9.00585 17.1067L3.8356 17.1068L2.55652 17.0467L3.17319 17.6476L3.99584 18.46C4.08854 18.538 4.15905 18.6324 4.20614 18.7423C4.25266 18.8508 4.27614 18.964 4.27614 19.0809C4.27614 19.3164 4.20319 19.5176 4.05259 19.6761L4.05078 19.6779C3.89321 19.8355 3.69523 19.9136 3.4641 19.9136C3.33425 19.9136 3.21525 19.887 3.10984 19.8309C3.00798 19.7833 2.91578 19.7133 2.83314 19.6232L0.305043 16.8784L0.303364 16.8765C0.203911 16.7628 0.128885 16.6522 0.0820713 16.5445C0.0268526 16.4404 0 16.3254 0 16.2018C0 16.0777 0.0270643 15.9624 0.0827236 15.8579C0.129533 15.7575 0.204382 15.6505 0.303343 15.5374L2.83314 12.7805C2.91656 12.6895 3.00961 12.619 3.11262 12.5715C3.21742 12.5231 3.33536 12.5004 3.4641 12.5004C3.69367 12.5004 3.89115 12.5739 4.04904 12.7238L4.05257 12.7276C4.20317 12.8861 4.27614 13.0873 4.27614 13.3227C4.27614 13.4397 4.25266 13.5528 4.20614 13.6614C4.15905 13.7713 4.08854 13.8656 3.99583 13.9437L3.17271 14.7565L2.55652 15.3569L3.8309 15.2969H9.00585Z" fill="#3A3A3A"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -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

View File

@@ -0,0 +1,169 @@
import {
TreeViewItem,
TreeViewNodeProps,
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_SUB_PAGE,
useDoc,
useTrans,
} from '@/features/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel';
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;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
`;
type Props = TreeViewNodeProps<Doc>;
export const DocSubPageItem = (props: Props) => {
const doc = props.node.data.value as Doc;
const treeContext = useTreeContext<Doc>();
const { untitledDocument } = useTrans();
const { node } = props;
const { spacingsTokens } = useCunninghamTheme();
const [isHover, setIsHover] = useState(false);
const spacing = spacingsTokens();
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
const isInitialLoad = useRef(false);
const { data: docQuery } = useDoc(
{ id: doc.id },
{
initialData: doc,
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
useEffect(() => {
if (docQuery && isInitialLoad.current === true) {
treeContext?.treeData.updateNode(docQuery.id, docQuery);
}
if (docQuery) {
isInitialLoad.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docQuery]);
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
if (actualChildren.length === 0) {
treeContext?.treeData
.handleLoadChildren(node?.data.value.id)
.then((allChildren) => {
node.open();
router.push(`/docs/${doc.id}`);
treeContext?.treeData.setChildren(node.data.value.id, allChildren);
togglePanel();
})
.catch(console.error);
} else {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: node.id,
};
treeContext?.treeData.addChild(node.data.value.id, newDoc);
node.open();
router.push(`/docs/${createdDoc.id}`);
togglePanel();
}
};
return (
<Box
className="--docs-sub-page-item"
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
$css={css`
&:not(:has(.isSelected)):has(.light-doc-item-actions) {
background-color: var(--c--theme--colors--greyscale-100);
}
`}
>
<TreeViewItem
{...props}
onClick={() => {
treeContext?.treeData.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"
$minHeight="24px"
>
<Box $width="16px" $height="16px">
<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" $variation="1000">
{doc.title || untitledDocument}
</Text>
{doc.nb_accesses_direct > 1 && (
<Icon
variant="filled"
iconName="group"
$size="16px"
$variation="400"
/>
)}
</Box>
{isHover && (
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
>
<DocTreeItemActions
doc={doc}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
)}
</Box>
</TreeViewItem>
</Box>
);
};

View File

@@ -0,0 +1,228 @@
import {
OpenMap,
TreeView,
TreeViewMoveResult,
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, KEY_SUB_PAGE, useDoc, useDocStore } from '../../doc-management';
import { SimpleDocItem } from '../../docs-grid';
import { useDocTree } from '../api/useDocTree';
import { useMoveDoc } from '../api/useMove';
import { canDrag, canDrop } from '../utils';
import { DocSubPageItem } from './DocSubPageItem';
import { DocTreeItemActions } from './DocTreeItemActions';
type DocTreeProps = {
initialTargetId: string;
};
export const DocTree = ({ initialTargetId }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme();
const treeContext = useTreeContext<Doc>();
const { currentDoc } = useDocStore();
const router = useRouter();
const previousDocId = useRef<string | null>(initialTargetId);
const { data: rootNode } = useDoc(
{ id: treeContext?.root?.id ?? '' },
{
enabled: !!treeContext?.root?.id,
initialData: treeContext?.root ?? undefined,
queryKey: [KEY_SUB_PAGE, { id: treeContext?.root?.id ?? '' }],
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
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,
});
treeContext?.treeData.handleMove(result);
};
useEffect(() => {
if (!data) {
return;
}
const { children: rootChildren, ...root } = data;
const children = rootChildren ?? [];
treeContext?.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);
treeContext?.treeData.resetTree(children);
setInitialOpenState(initialOpenState);
if (initialTargetId === root.id) {
treeContext?.treeData.setSelectedNode(root);
} else {
treeContext?.treeData.selectNodeById(initialTargetId);
}
// Because treeData change in the treeContext, we have a infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, initialTargetId]);
useEffect(() => {
if (
!currentDoc ||
(previousDocId.current && previousDocId.current === currentDoc.id)
) {
return;
}
const item = treeContext?.treeData.getNode(currentDoc?.id ?? '');
if (!item && currentDoc.id !== rootNode?.id) {
treeContext?.treeData.resetTree([]);
treeContext?.setRoot(currentDoc);
treeContext?.setInitialTargetId(currentDoc.id);
} else if (item) {
const { children: _children, ...leftDoc } = currentDoc;
treeContext?.treeData.updateNode(currentDoc.id, {
...leftDoc,
childrenCount: leftDoc.numchild,
});
}
if (currentDoc?.id && currentDoc?.id !== previousDocId.current) {
previousDocId.current = currentDoc?.id;
}
treeContext?.treeData.setSelectedNode(currentDoc);
}, [currentDoc, rootNode?.id, treeContext]);
const rootIsSelected =
treeContext?.treeData.selectedNode?.id === treeContext?.root?.id;
if (!initialTargetId || !treeContext) {
return null;
}
return (
<Box data-testid="doc-tree" $height="100%">
<Box $padding={{ horizontal: 'sm', top: 'sm', bottom: '-1px' }}>
<Box
$css={css`
padding: ${spacingsTokens['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;
}
}
`}
>
{treeContext.root !== null && rootNode && (
<StyledLink
$css={css`
width: 100%;
`}
href={`/docs/${treeContext.root.id}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
treeContext.treeData.setSelectedNode(
treeContext.root ?? undefined,
);
router.push(`/docs/${treeContext?.root?.id}`);
}}
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={rootNode} showAccesses={true} />
<div className="doc-tree-root-item-actions">
<DocTreeItemActions
doc={rootNode}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: treeContext.root?.id ?? undefined,
};
treeContext?.treeData.addChild(null, newDoc);
}}
/>
</div>
</Box>
</StyledLink>
)}
</Box>
</Box>
{initialOpenState && treeContext.treeData.nodes.length > 0 && (
<TreeView
initialOpenState={initialOpenState}
afterMove={handleMove}
selectedNodeId={
treeContext.treeData.selectedNode?.id ??
treeContext.initialTargetId ??
undefined
}
canDrop={({ parentNode }) => {
if (!rootNode) {
return false;
}
const parentDoc = parentNode?.data.value as Doc;
if (!parentDoc) {
return canDrop(rootNode);
}
return canDrop(parentDoc);
}}
canDrag={(node) => {
const doc = node.value as Doc;
return canDrag(doc);
}}
rootNodeId={treeContext.root?.id ?? ''}
renderNode={DocSubPageItem}
/>
)}
</Box>
);
};

View File

@@ -0,0 +1,171 @@
import {
DropdownMenu,
DropdownMenuOption,
useTreeContext,
} 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, useCopyDocLink } from '../../doc-management';
import { useCreateChildrenDoc } from '../api/useCreateChildren';
import { useDetachDoc } from '../api/useDetach';
import MoveDocIcon from '../assets/doc-extract-bold.svg';
import { useTreeUtils } from '../hooks';
import { isOwnerOrAdmin } from '../utils';
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 copyLink = useCopyDocLink(doc.id);
const canUpdate = isOwnerOrAdmin(doc);
const { isChild } = useTreeUtils(doc);
const { mutate: detachDoc } = useDetachDoc();
const treeContext = useTreeContext<Doc>();
const handleDetachDoc = () => {
if (!treeContext?.root) {
return;
}
detachDoc(
{ documentId: doc.id, rootId: treeContext.root.id },
{
onSuccess: () => {
treeContext.treeData.deleteNode(doc.id);
if (treeContext.root) {
treeContext.treeData.setSelectedNode(treeContext.root);
router.push(`/docs/${treeContext.root.id}`);
}
},
},
);
};
const options: DropdownMenuOption[] = [
{
label: t('Copy link'),
icon: <Icon iconName="link" $size="24px" />,
callback: copyLink,
},
...(isChild
? [
{
label: t('Convert to doc'),
isDisabled: !canUpdate,
icon: (
<Box
$css={css`
transform: scale(0.8);
`}
>
<MoveDocIcon />
</Box>
),
callback: handleDetachDoc,
},
]
: []),
{
label: t('Delete'),
isDisabled: !canUpdate,
icon: <Icon iconName="delete" $size="24px" />,
callback: deleteModal.open,
},
];
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
onSuccess: (doc) => {
onCreateSuccess?.(doc);
togglePanel();
router.push(`/docs/${doc.id}`);
treeContext?.treeData.setSelectedNode(doc);
},
});
const afterDelete = () => {
if (parentId) {
treeContext?.treeData.deleteNode(doc.id);
router.push(`/docs/${parentId}`);
} else if (doc.id === treeContext?.root?.id && !parentId) {
router.push(`/docs/`);
} else if (treeContext && treeContext.root) {
treeContext?.treeData.deleteNode(doc.id);
router.push(`/docs/${treeContext.root.id}`);
}
};
return (
<Fragment>
<Box
$direction="row"
$align="center"
className="--docs--doc-tree-item-actions"
$gap="4px"
>
<DropdownMenu
options={options}
isOpen={isOpen}
onOpenChange={setIsOpen}
>
<Icon
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setIsOpen(!isOpen);
}}
iconName="more_horiz"
variant="filled"
$theme="primary"
$variation="600"
/>
</DropdownMenu>
{canUpdate && (
<BoxButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
createChildrenDoc({
parentId: doc.id,
});
}}
color="primary"
>
<Icon
variant="filled"
$variation="800"
$theme="primary"
iconName="add_box"
/>
</BoxButton>
)}
</Box>
{deleteModal.isOpen && (
<ModalRemoveDoc
onClose={deleteModal.onClose}
doc={doc}
afterDelete={afterDelete}
/>
)}
</Fragment>
);
};

View File

@@ -0,0 +1 @@
export * from './useTreeUtils';

View File

@@ -0,0 +1,13 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Doc } from '@/docs/doc-management';
export const useTreeUtils = (doc: Doc) => {
const treeContext = useTreeContext<Doc>();
return {
isParent: doc.nb_accesses_ancestors <= 1, // it is a parent
isChild: doc.nb_accesses_ancestors > 1, // it is a child
isCurrentParent: treeContext?.root?.id === doc.id, // it can be a child but not for the current user
} as const;
};

View File

@@ -0,0 +1,3 @@
export * from './api';
export * from './hooks';
export * from './utils';

View File

@@ -0,0 +1,25 @@
import { TreeViewDataType } from '@gouvfr-lasuite/ui-kit';
import { Doc, Role } from '../doc-management';
export const subPageToTree = (children: Doc[]): TreeViewDataType<Doc>[] => {
children.forEach((child) => {
child.childrenCount = child.numchild ?? 0;
subPageToTree(child.children ?? []);
});
return children;
};
export const isOwnerOrAdmin = (doc: Doc): boolean => {
return doc.user_roles.some(
(role) => role === Role.OWNER || role === Role.ADMIN,
);
};
export const canDrag = (doc: Doc): boolean => {
return isOwnerOrAdmin(doc);
};
export const canDrop = (doc: Doc): boolean => {
return isOwnerOrAdmin(doc);
};

View File

@@ -1,14 +1,15 @@
import { css } from 'styled-components';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Box, SeparatedSection } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDocStore } from '@/docs/doc-management';
import { SimpleDocItem } from '@/docs/docs-grid';
import { Box } from '@/components';
import { Doc, useDocStore } from '@/docs/doc-management';
import { DocTree } from '@/features/docs/doc-tree/components/DocTree';
export const LeftPanelDocContent = () => {
const { currentDoc } = useDocStore();
const { spacingsTokens } = useCunninghamTheme();
if (!currentDoc) {
const tree = useTreeContext<Doc>();
if (!currentDoc || !tree) {
return null;
}
@@ -19,19 +20,9 @@ export const LeftPanelDocContent = () => {
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
className="--docs--left-panel-doc-content"
>
<SeparatedSection showSeparator={false}>
<Box $padding={{ horizontal: 'sm' }}>
<Box
$css={css`
padding: ${spacingsTokens['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>
);
};

View File

@@ -1,19 +1,21 @@
import { Button } from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import { PropsWithChildren, useCallback, useState } from 'react';
import { Box, Icon, SeparatedSection } from '@/components';
import { useCreateDoc } from '@/docs/doc-management';
import { DocSearchModal } from '@/docs/doc-search';
import { DocSearchModal, DocSearchTarget } from '@/docs/doc-search/';
import { useAuth } from '@/features/auth';
import { useCmdK } from '@/hook/useCmdK';
import { useLeftPanelStore } from '../stores';
import { LeftPanelHeaderButton } from './LeftPanelHeaderButton';
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
const router = useRouter();
const { authenticated } = useAuth();
const isDoc = router.pathname === '/docs/[id]';
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const openSearchModal = useCallback(() => {
@@ -33,22 +35,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
useCmdK(openSearchModal);
const { togglePanel } = useLeftPanelStore();
const { mutate: createDoc, isPending: isCreatingDoc } = useCreateDoc({
onSuccess: (doc) => {
router.push(`/docs/${doc.id}`);
togglePanel();
},
});
const goToHome = () => {
router.push('/');
void router.push('/');
togglePanel();
};
const createNewDoc = () => {
createDoc();
};
return (
<>
<Box $width="100%" className="--docs--left-panel-header">
@@ -80,17 +71,21 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
/>
)}
</Box>
{authenticated && (
<Button onClick={createNewDoc} disabled={isCreatingDoc}>
{t('New doc')}
</Button>
)}
{authenticated && <LeftPanelHeaderButton />}
</Box>
</SeparatedSection>
{children}
</Box>
{isSearchModalOpen && (
<DocSearchModal onClose={closeSearchModal} isOpen={isSearchModalOpen} />
<DocSearchModal
onClose={closeSearchModal}
isOpen={isSearchModalOpen}
showFilters={isDoc}
defaultFilters={{
target: isDoc ? DocSearchTarget.CURRENT : undefined,
}}
/>
)}
</>
);

View File

@@ -0,0 +1,77 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { Doc, useCreateDoc, useDocStore } from '@/docs/doc-management';
import { isOwnerOrAdmin, useCreateChildrenDoc } from '@/features/docs/doc-tree';
import { useLeftPanelStore } from '../stores';
export const LeftPanelHeaderButton = () => {
const router = useRouter();
const isDoc = router.pathname === '/docs/[id]';
if (isDoc) {
return <LeftPanelHeaderDocButton />;
}
return <LeftPanelHeaderHomeButton />;
};
export const LeftPanelHeaderHomeButton = () => {
const router = useRouter();
const { t } = useTranslation();
const { togglePanel } = useLeftPanelStore();
const { mutate: createDoc, isPending: isDocCreating } = useCreateDoc({
onSuccess: (doc) => {
void router.push(`/docs/${doc.id}`);
togglePanel();
},
});
return (
<Button
color="primary"
onClick={() => createDoc()}
disabled={isDocCreating}
>
{t('New doc')}
</Button>
);
};
export const LeftPanelHeaderDocButton = () => {
const router = useRouter();
const { currentDoc } = useDocStore();
const { t } = useTranslation();
const { togglePanel } = useLeftPanelStore();
const treeContext = useTreeContext<Doc>();
const tree = treeContext?.treeData;
const { mutate: createChildrenDoc, isPending: isDocCreating } =
useCreateChildrenDoc({
onSuccess: (doc) => {
tree?.addRootNode(doc);
tree?.selectNodeById(doc.id);
void router.push(`/docs/${doc.id}`);
togglePanel();
},
});
const onCreateDoc = () => {
if (treeContext && treeContext.root) {
createChildrenDoc({
parentId: treeContext.root.id,
});
}
};
return (
<Button
color="tertiary"
onClick={onCreateDoc}
disabled={(currentDoc && !isOwnerOrAdmin(currentDoc)) || isDocCreating}
>
{t('New page')}
</Button>
);
};

View File

@@ -1,3 +1,4 @@
import { TreeProvider } from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import Head from 'next/head';
import { useRouter } from 'next/router';
@@ -15,6 +16,7 @@ import {
useDocStore,
} from '@/docs/doc-management/';
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
import { MainLayout } from '@/layouts';
import { useBroadcastStore } from '@/stores';
import { NextPageWithLayout } from '@/types/next';
@@ -34,9 +36,17 @@ export function DocLayout() {
<meta name="robots" content="noindex" />
</Head>
<MainLayout>
<DocPage id={id} />
</MainLayout>
<TreeProvider
initialNodeId={id}
onLoadChildren={async (docId: string) => {
const doc = await getDocChildren({ docId });
return subPageToTree(doc.results);
}}
>
<MainLayout>
<DocPage id={id} />
</MainLayout>
</TreeProvider>
</>
);
}
@@ -85,6 +95,12 @@ const DocPage = ({ id }: DocProps) => {
setCurrentDoc(docQuery);
}, [docQuery, setCurrentDoc, isFetching]);
useEffect(() => {
return () => {
setCurrentDoc(undefined);
};
}, [setCurrentDoc]);
/**
* We add a broadcast task to reset the query cache
* when the document visibility changes.

View File

@@ -1,3 +1,4 @@
import { TreeProvider } from '@gouvfr-lasuite/ui-kit';
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PropsWithChildren } from 'react';
@@ -14,8 +15,10 @@ export const AppWrapper = ({ children }: PropsWithChildren) => {
});
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme="default">{children}</CunninghamProvider>
</QueryClientProvider>
<TreeProvider initialTreeData={[]}>
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme="default">{children}</CunninghamProvider>
</QueryClientProvider>
</TreeProvider>
);
};