diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fda37d9..9802e4e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 36ee1448..dfbcfb33 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -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(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts index e77615bc..4242caf9 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts @@ -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 }, ); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 12da91e8..9a187abb 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -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', diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts index 6eec23d4..e1be875a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts @@ -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( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts index 27088c93..73534b27 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts @@ -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(); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts new file mode 100644 index 00000000..bf4b3134 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -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(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts index 5915d697..c56f8b3f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts @@ -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(); diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index 8758588e..5fc3d64b 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -8,6 +8,7 @@ export type DropdownMenuOption = { icon?: string; label: string; testId?: string; + value?: string; callback?: () => void | Promise; danger?: boolean; isSelected?: boolean; @@ -23,6 +24,8 @@ export type DropdownMenuProps = { buttonCss?: BoxProps['$css']; disabled?: boolean; topMessage?: string; + selectedValues?: string[]; + afterOpenChange?: (isOpen: boolean) => void; }; export const DropdownMenu = ({ @@ -34,6 +37,8 @@ export const DropdownMenu = ({ buttonCss, label, topMessage, + afterOpenChange, + selectedValues, }: PropsWithChildren) => { const { 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} - {option.isSelected && ( + {(option.isSelected || + selectedValues?.includes(option.value ?? '')) && ( )} diff --git a/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx new file mode 100644 index 00000000..313209bf --- /dev/null +++ b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx @@ -0,0 +1,63 @@ +import { css } from 'styled-components'; + +import { Box } from '../Box'; +import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu'; +import { Icon } from '../Icon'; +import { Text } from '../Text'; + +export type FilterDropdownProps = { + options: DropdownMenuOption[]; + selectedValue?: string; +}; + +export const FilterDropdown = ({ + options, + selectedValue, +}: FilterDropdownProps) => { + const selectedOption = options.find( + (option) => option.value === selectedValue, + ); + + if (options.length === 0) { + return null; + } + + return ( + + + + {selectedOption?.label ?? options[0].label} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx index 9ab52f53..e78f6a56 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx @@ -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')} diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index b838ef44..5175cfe7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -1,10 +1,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, + ); }, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx index ebbb1d54..5365ad4d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx @@ -19,6 +19,7 @@ export const getDoc = async ({ id }: DocParams): Promise => { }; 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, ) { return useQuery({ - queryKey: [KEY_DOC, param], + queryKey: queryConfig?.queryKey ?? [KEY_DOC, param], queryFn: () => getDoc(param), ...queryConfig, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index c9881ad7..5f5636d5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -53,7 +53,6 @@ export const getDocs = async (params: DocsParams): Promise => { if (params.is_favorite !== undefined) { searchParams.set('is_favorite', params.is_favorite.toString()); } - const response = await fetchAPI(`documents/?${searchParams.toString()}`); if (!response.ok) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index c3ff4b5b..f83b1aff 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -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 && ( - {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, + })} )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx new file mode 100644 index 00000000..fc14d8bc --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx @@ -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 = 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: void fetchNextPage()} />, + }, + ] + : [], + }; + }, [search, data?.pages, fetchNextPage, hasNextPage]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + return ( + } + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx new file mode 100644 index 00000000..96edcf71 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx @@ -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 ( + + + handleTargetChange(DocSearchTarget.ALL), + }, + { + label: t('Current doc'), + value: DocSearchTarget.CURRENT, + callback: () => handleTargetChange(DocSearchTarget.CURRENT), + }, + ]} + /> + + {hasFilters && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx index 28230c5b..28a01767 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx @@ -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( + 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 = 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: void fetchNextPage()} /> }] - : [], - }; - }, [data, hasNextPage, fetchNextPage, t, search]); + const handleResetFilters = () => { + setFilters({}); + }; return ( { onFilter={handleInputSearch} > + {showFilters && ( + + )} {search.length === 0 && ( { )} {search && ( - } - /> + <> + {target === DocSearchTarget.ALL && ( + + )} + {isDocPage && target === DocSearchTarget.CURRENT && ( + + )} + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx new file mode 100644 index 00000000..e4fa2c7e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx @@ -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(); + + 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 = 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: void subDocsFetchNextPage()} />, + }, + ] + : [], + }; + }, [search, subDocsData, subDocsFetchNextPage, subDocsHasNextPage]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + return ( + } + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts index a5cb9885..1a088923 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts @@ -1 +1,2 @@ export * from './DocSearchModal'; +export * from './DocSearchFilters'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx index dac98831..2e1df3aa 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx @@ -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.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); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx index 91507915..12b20b82 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx @@ -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'), diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx index 8109325d..6fa843d9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx @@ -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, diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts new file mode 100644 index 00000000..e3ca92cb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts @@ -0,0 +1,2 @@ +export * from './useCreateChildren'; +export * from './useDocChildren'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx new file mode 100644 index 00000000..b9f774a8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc, KEY_LIST_DOC } from '../../doc-management'; + +export type CreateDocParam = Pick & { + parentId: string; +}; + +export const createDocChildren = async ({ + title, + parentId, +}: CreateDocParam): Promise => { + const response = await fetchAPI(`documents/${parentId}/children/`, { + method: 'POST', + body: JSON.stringify({ + title, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to create the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +interface CreateDocProps { + onSuccess: (data: Doc) => void; +} + +export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createDocChildren, + onSuccess: (data) => { + void queryClient.resetQueries({ + queryKey: [KEY_LIST_DOC], + }); + onSuccess(data); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx new file mode 100644 index 00000000..406c32a7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx @@ -0,0 +1,58 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api'; + +import { DocsResponse } from '../../doc-management'; + +export type DocsChildrenParams = { + docId: string; + page?: number; + page_size?: number; +}; + +export const getDocChildren = async ( + params: DocsChildrenParams, +): Promise => { + const { docId, page, page_size } = params; + const searchParams = new URLSearchParams(); + + if (page) { + searchParams.set('page', page.toString()); + } + if (page_size) { + searchParams.set('page_size', page_size.toString()); + } + + const response = await fetchAPI( + `documents/${docId}/children/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc children', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-children'; + +export function useDocChildren( + params: DocsChildrenParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocChildren(params), + ...queryConfig, + }); +} + +export const useInfiniteDocChildren = (params: DocsChildrenParams) => { + return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx new file mode 100644 index 00000000..bebb1d82 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx @@ -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 => { + const searchParams = new URLSearchParams(); + + const response = await fetchAPI( + `documents/${docId}/tree/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc tree', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-tree'; + +export function useDocTree( + params: DocsTreeParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocTree(params), + staleTime: 0, + refetchOnWindowFocus: false, + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg new file mode 100644 index 00000000..47d4fa1a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg new file mode 100644 index 00000000..790684c6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx new file mode 100644 index 00000000..789686ba --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -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; +export const DocSubPageItem = (props: Props) => { + const doc = props.node.data.value as Doc; + const treeContext = useTreeContext(); + 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 ( + setIsHover(true)} + onMouseLeave={() => setIsHover(false)} + $css={css` + &:not(:has(.isSelected)):has(.light-doc-item-actions) { + background-color: var(--c--theme--colors--greyscale-100); + } + `} + > + { + treeContext?.treeData.setSelectedNode(props.node.data.value as Doc); + router.push(`/docs/${props.node.data.value.id}`); + }} + > + + + + + + + + {doc.title || untitledDocument} + + {doc.nb_accesses_direct > 1 && ( + + )} + + + {isHover && ( + + + + )} + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx new file mode 100644 index 00000000..90af2319 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -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(); + const { currentDoc } = useDocStore(); + const router = useRouter(); + + const previousDocId = useRef(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( + 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 ( + + + + {treeContext.root !== null && rootNode && ( + { + e.stopPropagation(); + e.preventDefault(); + treeContext.treeData.setSelectedNode( + treeContext.root ?? undefined, + ); + router.push(`/docs/${treeContext?.root?.id}`); + }} + > + + +
+ { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: treeContext.root?.id ?? undefined, + }; + treeContext?.treeData.addChild(null, newDoc); + }} + /> +
+
+
+ )} +
+
+ + {initialOpenState && treeContext.treeData.nodes.length > 0 && ( + { + 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} + /> + )} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx new file mode 100644 index 00000000..78551364 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -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(); + + 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: , + callback: copyLink, + }, + ...(isChild + ? [ + { + label: t('Convert to doc'), + isDisabled: !canUpdate, + icon: ( + + + + ), + callback: handleDetachDoc, + }, + ] + : []), + { + label: t('Delete'), + isDisabled: !canUpdate, + icon: , + 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 ( + + + + { + e.stopPropagation(); + e.preventDefault(); + setIsOpen(!isOpen); + }} + iconName="more_horiz" + variant="filled" + $theme="primary" + $variation="600" + /> + + {canUpdate && ( + { + e.stopPropagation(); + e.preventDefault(); + + createChildrenDoc({ + parentId: doc.id, + }); + }} + color="primary" + > + + + )} + + {deleteModal.isOpen && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts new file mode 100644 index 00000000..3fb57a34 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts @@ -0,0 +1 @@ +export * from './useTreeUtils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx new file mode 100644 index 00000000..086f5b6b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx @@ -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(); + + 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; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts new file mode 100644 index 00000000..608f00da --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './hooks'; +export * from './utils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts new file mode 100644 index 00000000..60d17d86 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts @@ -0,0 +1,25 @@ +import { TreeViewDataType } from '@gouvfr-lasuite/ui-kit'; + +import { Doc, Role } from '../doc-management'; + +export const subPageToTree = (children: Doc[]): TreeViewDataType[] => { + 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); +}; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx index 50573578..928af5ec 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx @@ -1,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(); + + 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" > - - - - - - - + {tree.initialTargetId && ( + + )} ); }; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx index 7177bc2c..5733b0df 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx @@ -1,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 ( <> @@ -80,17 +71,21 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { /> )} - {authenticated && ( - - )} + + {authenticated && } {children} {isSearchModalOpen && ( - + )} ); diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx new file mode 100644 index 00000000..ac5bf169 --- /dev/null +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx @@ -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 ; + } + + return ; +}; + +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 ( + + ); +}; + +export const LeftPanelHeaderDocButton = () => { + const router = useRouter(); + const { currentDoc } = useDocStore(); + const { t } = useTranslation(); + const { togglePanel } = useLeftPanelStore(); + const treeContext = useTreeContext(); + 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 ( + + ); +}; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 41931518..73ed0a92 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -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() { - - - + { + const doc = await getDocChildren({ docId }); + return subPageToTree(doc.results); + }} + > + + + + ); } @@ -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. diff --git a/src/frontend/apps/impress/src/tests/utils.tsx b/src/frontend/apps/impress/src/tests/utils.tsx index b0d7c7de..523fa01d 100644 --- a/src/frontend/apps/impress/src/tests/utils.tsx +++ b/src/frontend/apps/impress/src/tests/utils.tsx @@ -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 ( - - {children} - + + + {children} + + ); };