✨(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:
committed by
Anthony LC
parent
cb2ecfcea3
commit
9a64ebc1e9
@@ -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();
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
279
src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts
Normal file
279
src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from './DocSearchModal';
|
||||
export * from './DocSearchFilters';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './useCreateChildren';
|
||||
export * from './useDocChildren';
|
||||
@@ -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,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,
|
||||
});
|
||||
}
|
||||
@@ -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 |
@@ -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,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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useTreeUtils';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api';
|
||||
export * from './hooks';
|
||||
export * from './utils';
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user