diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aafeb4ab4..27b005b0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to - ♿️(frontend) restore skip to content link after header redesign #2510 +### Fixed + +- 🐛(frontend) fix DnD hover flash and enable drop on pinned documents #2531 + ## [v5.4.1] - 2026-07-09 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts index 85ffe6e6e1..fd8569006c 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts @@ -402,6 +402,78 @@ test.describe('Doc grid move', () => { await cleanup(); }); + + test('it drags a doc from the grid onto a pinned doc in the left panel', async ({ + page, + browserName, + }) => { + await page.goto('/'); + + // Create source doc (to be dragged) + const [sourceTitle] = await createDoc(page, 'Source doc', browserName, 1); + await page.getByRole('button', { name: 'Back to homepage' }).click(); + + // Create target doc and pin it + const [targetTitle] = await createDoc(page, 'Target doc', browserName, 1); + await page.getByRole('button', { name: 'Back to homepage' }).click(); + + const targetRow = await getGridRow(page, targetTitle); + await targetRow + .getByRole('button', { name: /Open the menu of actions/ }) + .click(); + await page.getByRole('menuitem', { name: 'Pin' }).click(); + + // Confirm the target doc is now pinned in the left panel + const leftPanelFavorites = page.getByTestId('left-panel-favorites'); + await expect(leftPanelFavorites.getByText(targetTitle)).toBeVisible(); + + // Locate source in grid and target in favorites panel + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + + const sourceRow = await getGridRow(page, sourceTitle); + const sourceBox = await sourceRow.boundingBox(); + const targetFavoriteItem = leftPanelFavorites + .getByRole('link', { name: new RegExp(targetTitle) }) + .first(); + const targetBox = await targetFavoriteItem.boundingBox(); + + expect(sourceBox).toBeDefined(); + expect(targetBox).toBeDefined(); + + if (!sourceBox || !targetBox) { + throw new Error('Unable to determine element positions'); + } + + // Drag source doc from grid onto target in the favorites panel + await page.mouse.move( + sourceBox.x + sourceBox.width / 2, + sourceBox.y + sourceBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + targetBox.x + targetBox.width / 2, + targetBox.y + targetBox.height / 2, + { steps: 15 }, + ); + + const dragOverlay = page.getByTestId('drag-doc-overlay'); + await expect(dragOverlay).toBeVisible(); + await expect(dragOverlay).toHaveText(sourceTitle); + + await page.mouse.up(); + + // Source doc should no longer appear in the root grid + await expect(docsGrid.getByText(sourceTitle)).toBeHidden(); + + // Navigate into the target doc and verify source is now a sub-page + await targetRow.getByRole('link').first().click(); + await verifyDocName(page, targetTitle); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(sourceTitle)).toBeVisible(); + }); }); test.describe('Doc grid dnd mobile', () => { diff --git a/src/frontend/apps/impress/src/features/docs/DocDndContext.tsx b/src/frontend/apps/impress/src/features/docs/DocDndContext.tsx new file mode 100644 index 0000000000..d788ea5745 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/DocDndContext.tsx @@ -0,0 +1,246 @@ +import { + DndContext, + DragOverlay, + Modifier, + UniqueIdentifier, +} from '@dnd-kit/core'; +import { getEventCoordinates } from '@dnd-kit/utilities'; +import { useModal } from '@gouvfr-lasuite/cunningham-react'; +import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; +import dynamic from 'next/dynamic'; +import { + PropsWithChildren, + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Card, Text } from '@/components'; +import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management'; + +import { DocDragEndData, useDragAndDrop } from './docs-grid/hooks/useDragAndDrop'; + +const ModalConfirmationMoveDoc = dynamic( + () => + import('./docs-grid/components/ModalConfimationMoveDoc').then((mod) => ({ + default: mod.ModalConfirmationMoveDoc, + })), + { ssr: false }, +); + +const snapToTopLeft: Modifier = ({ + activatorEvent, + draggingNodeRect, + transform, +}) => { + if (draggingNodeRect && activatorEvent) { + const activatorCoordinates = getEventCoordinates(activatorEvent); + if (!activatorCoordinates) { + return transform; + } + const offsetX = activatorCoordinates.x - draggingNodeRect.left; + const offsetY = activatorCoordinates.y - draggingNodeRect.top; + return { + ...transform, + x: transform.x + offsetX - 3, + y: transform.y + offsetY - 3, + }; + } + return transform; +}; + +type DocDndContextValue = { + selectedDoc: Doc | undefined; + canDrag: boolean; + canDrop: boolean | undefined; + updateCanDrop: (canDrop: boolean, isOver: boolean) => void; + isDraggableDisabled: boolean; +}; + +const DocDndCtx = createContext(null); + +export const useDocDnd = () => useContext(DocDndCtx); + +export const DocDndProvider = ({ children }: PropsWithChildren) => { + const { mutateAsync: handleMove, isError } = useMoveDoc(); + const modalConfirmation = useModal(); + const onDragData = useRef(null); + const { untitledDocument } = useTrans(); + const { t } = useTranslation(); + + const handleMoveDoc = async () => { + if (!onDragData.current) { + return; + } + const { sourceDocumentId, target } = onDragData.current; + const targetDocumentId = target.id; + // Strip the `favorite-` prefix that favorite droppables add to avoid + // dnd-kit ID collisions, so we compare raw document IDs. + const normalizedSourceId = sourceDocumentId.replace(/^favorite-/, ''); + const normalizedTargetId = targetDocumentId.replace(/^favorite-/, ''); + const dragSnapshot = onDragData.current; + modalConfirmation.onClose(); + if (!normalizedSourceId || !normalizedTargetId || normalizedSourceId === normalizedTargetId) { + onDragData.current = null; + return; + } + try { + await handleMove({ + sourceDocumentId: normalizedSourceId, + targetDocumentId: normalizedTargetId, + position: TreeViewMoveModeEnum.FIRST_CHILD, + }); + } finally { + // Only clear if no newer drag has been queued while the request was in-flight. + if (onDragData.current === dragSnapshot) { + onDragData.current = null; + } + } + }; + + const onDrag = (data: DocDragEndData) => { + onDragData.current = data; + if (data.source.nb_accesses_direct <= 1) { + void handleMoveDoc(); + return; + } + modalConfirmation.open(); + }; + + const { + selectedDoc, + canDrag, + canDrop, + sensors, + handleDragStart, + handleDragEnd, + handleDragCancel, + updateCanDrop, + } = useDragAndDrop(onDrag); + + const dndAccessibility = useMemo( + () => ({ + screenReaderInstructions: { + draggable: t( + 'To pick up a draggable item, press space or enter. While dragging, use the arrow keys to move the item. Press space or enter again to drop the item in its new position, or press escape to cancel.', + ), + }, + announcements: { + onDragStart({ active }: { active: { id: UniqueIdentifier } }) { + return t('Picked up document {{id}}.', { id: active.id }); + }, + onDragOver({ + active, + over, + }: { + active: { id: UniqueIdentifier }; + over: { id: UniqueIdentifier } | null; + }) { + if (over) { + return t('Document {{activeId}} is over document {{overId}}.', { + activeId: active.id, + overId: over.id, + }); + } + return t('Document {{id}} is no longer over a droppable area.', { + id: active.id, + }); + }, + onDragEnd({ + active, + over, + }: { + active: { id: UniqueIdentifier }; + over: { id: UniqueIdentifier } | null; + }) { + if (over) { + return t( + 'Document {{activeId}} was dropped over document {{overId}}.', + { activeId: active.id, overId: over.id }, + ); + } + return t('Document {{id}} was dropped.', { id: active.id }); + }, + onDragCancel({ active }: { active: { id: UniqueIdentifier } }) { + return t('Dragging was cancelled. Document {{id}} was returned to its original position.', { + id: active.id, + }); + }, + }, + }), + [t], + ); + + const overlayText = useMemo(() => { + if (!canDrag) { + return t('You must be the owner to move the document'); + } + if (canDrop === false) { + return t('You must be at least the administrator of the target document'); + } + return selectedDoc?.title || untitledDocument; + }, [canDrag, canDrop, selectedDoc?.title, t, untitledDocument]); + + const cannotMoveDoc = + !canDrag || (canDrop !== undefined && !canDrop) || isError; + + const [isDraggableDisabled, setIsDraggableDisabled] = useState(false); + + useEffect(() => { + const checkModal = () => { + const modalOpen = document.querySelector('[role="dialog"]'); + setIsDraggableDisabled(!!modalOpen); + }; + checkModal(); + const observer = new MutationObserver(checkModal); + observer.observe(document.body, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, []); + + return ( + + + {children} + + + + {overlayText} + + + + + {modalConfirmation.isOpen && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/__tests__/DocDndContext.test.tsx b/src/frontend/apps/impress/src/features/docs/__tests__/DocDndContext.test.tsx new file mode 100644 index 0000000000..eb398e01de --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/__tests__/DocDndContext.test.tsx @@ -0,0 +1,279 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AppWrapper } from '@/tests/utils'; + +import { DocDndProvider, useDocDnd } from '../DocDndContext'; +import type { DocDragEndData } from '../docs-grid/hooks/useDragAndDrop'; + +// Capture the onDrag callback passed to useDragAndDrop so tests can trigger it directly +let capturedOnDrag: ((data: DocDragEndData) => void) | undefined; + +vi.mock('@/features/docs/docs-grid/hooks/useDragAndDrop', () => ({ + useDragAndDrop: vi.fn((onDrag: (data: DocDragEndData) => void) => { + capturedOnDrag = onDrag; + return { + selectedDoc: undefined, + canDrag: false, + canDrop: undefined, + sensors: [], + handleDragStart: vi.fn(), + handleDragEnd: vi.fn(), + updateCanDrop: vi.fn(), + }; + }), +})); + +// Replace next/dynamic with a transparent React.lazy wrapper so the modal renders in tests +vi.mock('next/dynamic', () => ({ + __esModule: true, + default: (factory: () => Promise<{ default: React.ComponentType }>) => { + const Lazy = React.lazy(factory); + return function DynamicWrapper(props: any) { + return ( + + + + ); + }; + }, +})); + +const sourceDoc = { + id: 'source-id', + title: 'Source doc', + nb_accesses_direct: 1, + user_role: 'owner', +} as any; + +const targetDoc = { + id: 'target-id', + title: 'Target doc', + abilities: { move: true }, +} as any; + +const dragData: DocDragEndData = { + sourceDocumentId: 'source-id', + targetDocumentId: 'target-id', + source: sourceDoc, + target: targetDoc, +}; + +// Consumer that exposes context value for assertions +const ContextConsumer = () => { + const dnd = useDocDnd(); + return ( +
+ ); +}; + +const Wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + +); + +describe('DocDndProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchMock.restore(); + capturedOnDrag = undefined; + }); + + describe('isDraggableDisabled', () => { + it('is false when no dialog is open', async () => { + render(, { wrapper: Wrapper }); + + await waitFor(() => { + expect(screen.getByTestId('disabled').dataset.value).toBe('false'); + }); + }); + + it('is true when a dialog is added to the DOM', async () => { + render(, { wrapper: Wrapper }); + + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + + await waitFor(() => { + expect(screen.getByTestId('disabled').dataset.value).toBe('true'); + }); + + document.body.removeChild(dialog); + + await waitFor(() => { + expect(screen.getByTestId('disabled').dataset.value).toBe('false'); + }); + }); + }); + + describe('onDrag', () => { + it('calls the move API immediately when the source doc has no shared access', async () => { + fetchMock.post('http://test.jest/api/v1.0/documents/source-id/move/', { + status: 200, + body: JSON.stringify({}), + }); + + render(, { wrapper: Wrapper }); + + act(() => { + capturedOnDrag?.({ + ...dragData, + source: { ...sourceDoc, nb_accesses_direct: 1 }, + }); + }); + + await waitFor(() => { + expect(fetchMock.calls()).toHaveLength(1); + expect(fetchMock.calls()[0][0]).toContain('documents/source-id/move/'); + }); + }); + + it('does not call the move API immediately when the source doc is shared', async () => { + fetchMock.post('http://test.jest/api/v1.0/documents/source-id/move/', { + status: 200, + body: JSON.stringify({}), + }); + + render(, { wrapper: Wrapper }); + + act(() => { + capturedOnDrag?.({ + ...dragData, + source: { ...sourceDoc, nb_accesses_direct: 3 }, + }); + }); + + // The modal appearing proves the drag handler ran to completion — if the + // API were going to be called immediately, it would have happened before this. + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + expect(fetchMock.calls()).toHaveLength(0); + }); + + it('shows the confirmation modal when the source doc is shared', async () => { + render(
, { wrapper: Wrapper }); + + act(() => { + capturedOnDrag?.({ + ...dragData, + source: { ...sourceDoc, nb_accesses_direct: 3 }, + }); + }); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + }); + + it('calls the move API after confirming the modal', async () => { + fetchMock.post('http://test.jest/api/v1.0/documents/source-id/move/', { + status: 200, + body: JSON.stringify({}), + }); + + render(
, { wrapper: Wrapper }); + + act(() => { + capturedOnDrag?.({ + ...dragData, + source: { ...sourceDoc, nb_accesses_direct: 3 }, + }); + }); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /^Move$/i })); + + await waitFor(() => { + expect(fetchMock.calls()).toHaveLength(1); + expect(fetchMock.calls()[0][0]).toContain('documents/source-id/move/'); + }); + }); + + it('does not call the move API when cancelling the modal', async () => { + fetchMock.post('http://test.jest/api/v1.0/documents/source-id/move/', { + status: 200, + body: JSON.stringify({}), + }); + + render(
, { wrapper: Wrapper }); + + act(() => { + capturedOnDrag?.({ + ...dragData, + source: { ...sourceDoc, nb_accesses_direct: 3 }, + }); + }); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /Cancel/i })); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(fetchMock.calls()).toHaveLength(0); + }); + + it('does not call the move API when source and target are the same document', async () => { + fetchMock.post('http://test.jest/api/v1.0/documents/source-id/move/', { + status: 200, + body: JSON.stringify({}), + }); + + render(, { wrapper: Wrapper }); + + act(() => { + capturedOnDrag?.({ + ...dragData, + sourceDocumentId: 'source-id', + target: { ...targetDoc, id: 'source-id' }, + source: { ...sourceDoc, nb_accesses_direct: 1 }, + }); + }); + + // The drop is rejected synchronously inside handleMoveDoc before any + // async work, so there is nothing to wait for — check immediately. + expect(fetchMock.calls()).toHaveLength(0); + }); + + it('strips the favorite- prefix before comparing source and target IDs', async () => { + fetchMock.post('http://test.jest/api/v1.0/documents/source-id/move/', { + status: 200, + body: JSON.stringify({}), + }); + + render(, { wrapper: Wrapper }); + + // Artificially inject a favorite- prefix into target.id to exercise the + // normalization branch. Without the replace(/^favorite-/, '') strip, + // 'favorite-source-id' !== 'source-id' and the self-drop guard would + // incorrectly allow the move to proceed. + act(() => { + capturedOnDrag?.({ + ...dragData, + sourceDocumentId: 'source-id', + target: { ...targetDoc, id: 'favorite-source-id' }, + source: { ...sourceDoc, nb_accesses_direct: 1 }, + }); + }); + + expect(fetchMock.calls()).toHaveLength(0); + }); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx index 03fea5ac06..b38374e534 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx @@ -1,59 +1,12 @@ -import { - DndContext, - DragOverlay, - Modifier, - UniqueIdentifier, -} from '@dnd-kit/core'; -import { getEventCoordinates } from '@dnd-kit/utilities'; -import { useModal } from '@gouvfr-lasuite/cunningham-react'; -import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; -import dynamic from 'next/dynamic'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Card, Text } from '@/components'; -import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management'; +import { Doc } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores/useResponsiveStore'; -import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop'; +import { useDocDnd } from '@/features/docs/DocDndContext'; import { DocsGridItem } from './DocsGridItem'; import { Draggable } from './Draggable'; import { Droppable } from './Droppable'; -const ModalConfirmationMoveDoc = dynamic( - () => - import('./ModalConfimationMoveDoc').then((mod) => ({ - default: mod.ModalConfirmationMoveDoc, - })), - { ssr: false }, -); - -const snapToTopLeft: Modifier = ({ - activatorEvent, - draggingNodeRect, - transform, -}) => { - if (draggingNodeRect && activatorEvent) { - const activatorCoordinates = getEventCoordinates(activatorEvent); - - if (!activatorCoordinates) { - return transform; - } - - const offsetX = activatorCoordinates.x - draggingNodeRect.left; - const offsetY = activatorCoordinates.y - draggingNodeRect.top; - - return { - ...transform, - x: transform.x + offsetX - 3, - y: transform.y + offsetY - 3, - }; - } - - return transform; -}; - type DocGridContentListProps = { docs: Doc[]; }; @@ -61,189 +14,21 @@ type DocGridContentListProps = { export const DraggableDocGridContentList = ({ docs, }: DocGridContentListProps) => { - const { mutateAsync: handleMove, isError } = useMoveDoc(); - const modalConfirmation = useModal(); - const onDragData = useRef(null); - const { untitledDocument } = useTrans(); - - const handleMoveDoc = async () => { - if (!onDragData.current) { - return; - } - - const { sourceDocumentId, targetDocumentId } = onDragData.current; - modalConfirmation.onClose(); - if (!sourceDocumentId || !targetDocumentId) { - onDragData.current = null; - - return; - } - - try { - await handleMove({ - sourceDocumentId, - targetDocumentId, - position: TreeViewMoveModeEnum.FIRST_CHILD, - }); - } finally { - onDragData.current = null; - } - }; - - const onDrag = (data: DocDragEndData) => { - onDragData.current = data; - if (data.source.nb_accesses_direct <= 1) { - void handleMoveDoc(); - return; - } - - modalConfirmation.open(); - }; - - const { - selectedDoc, - canDrag, - canDrop, - sensors, - handleDragStart, - handleDragEnd, - updateCanDrop, - } = useDragAndDrop(onDrag); - - const { t } = useTranslation(); - - const dndAccessibility = useMemo( - () => ({ - screenReaderInstructions: { - draggable: t( - 'To pick up a draggable item, press space or enter. While dragging, use the arrow keys to move the item. Press space or enter again to drop the item in its new position, or press escape to cancel.', - ), - }, - announcements: { - onDragStart({ active }: { active: { id: UniqueIdentifier } }) { - return t('Picked up document {{id}}.', { id: active.id }); - }, - onDragOver({ - active, - over, - }: { - active: { id: UniqueIdentifier }; - over: { id: UniqueIdentifier } | null; - }) { - if (over) { - return t('Document {{activeId}} is over document {{overId}}.', { - activeId: active.id, - overId: over.id, - }); - } - return t('Document {{id}} is no longer over a droppable area.', { - id: active.id, - }); - }, - onDragEnd({ - active, - over, - }: { - active: { id: UniqueIdentifier }; - over: { id: UniqueIdentifier } | null; - }) { - if (over) { - return t( - 'Document {{activeId}} was dropped over document {{overId}}.', - { activeId: active.id, overId: over.id }, - ); - } - return t('Document {{id}} was dropped.', { id: active.id }); - }, - onDragCancel({ active }: { active: { id: UniqueIdentifier } }) { - return t('Dragging was cancelled. Document {{id}} was dropped.', { - id: active.id, - }); - }, - }, - }), - [t], - ); - - const overlayText = useMemo(() => { - if (!canDrag) { - return t('You must be the owner to move the document'); - } - if (!canDrop) { - return t('You must be at least the administrator of the target document'); - } - - return selectedDoc?.title || untitledDocument; - }, [canDrag, canDrop, selectedDoc?.title, t, untitledDocument]); - - const cannotMoveDoc = - !canDrag || (canDrop !== undefined && !canDrop) || isError; - - const [isDraggableDisabled, setIsDraggableDisabled] = useState(false); - - useEffect(() => { - const checkModal = () => { - const modalOpen = document.querySelector('[role="dialog"]'); - setIsDraggableDisabled(!!modalOpen); - }; - - checkModal(); - - const observer = new MutationObserver(checkModal); - observer.observe(document.body, { - childList: true, - subtree: true, - }); - - return () => observer.disconnect(); - }, []); + const dnd = useDocDnd(); return ( <> - - {docs.map((doc) => ( - - ))} - - - - {overlayText} - - - - - {modalConfirmation.isOpen && ( - ( + {})} + disabled={dnd?.isDraggableDisabled ?? false} + selectedDocId={dnd?.selectedDoc?.id} /> - )} + ))} ); }; @@ -254,6 +39,7 @@ interface DraggableDocGridItemProps { dragMode: boolean; canDrag: boolean; updateCanDrop: (canDrop: boolean, isOver: boolean) => void; + selectedDocId?: string; } export const DraggableDocGridItem = ({ @@ -262,14 +48,18 @@ export const DraggableDocGridItem = ({ dragMode, canDrag, updateCanDrop, + selectedDocId, }: DraggableDocGridItemProps) => { + const isSelf = selectedDocId === doc.id; const canDrop = doc.abilities.move; return ( updateCanDrop(canDrop, isOver)} + canDrop={canDrag && canDrop && !isSelf} + onOver={(isOver) => { + if (!isSelf) updateCanDrop(canDrop, isOver); + }} id={doc.id} data={doc} > diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DraggableDocGridItem.test.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DraggableDocGridItem.test.tsx new file mode 100644 index 0000000000..c79b2a321c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DraggableDocGridItem.test.tsx @@ -0,0 +1,79 @@ +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Doc } from '@/docs/doc-management'; +import { AppWrapper } from '@/tests/utils'; + +import { DraggableDocGridItem } from '../DocGridContentList'; + +vi.mock('../DocsGridItem', () => ({ + DocsGridItem: () =>
, +})); + +const mockUpdateCanDrop = vi.fn(); + +vi.mock('@dnd-kit/core', async () => { + const actual = await vi.importActual('@dnd-kit/core'); + return { + ...actual, + useDroppable: vi.fn(), + }; +}); + +const mockDoc: Doc = { + id: 'doc-1', + title: 'Test doc', + abilities: { + move: true, + partial_update: false, + }, + updated_at: new Date().toISOString(), + nb_accesses_direct: 1, +} as unknown as Doc; + +const renderItem = (selectedDocId?: string) => + render( + , + { wrapper: AppWrapper }, + ); + +describe('DraggableDocGridItem', () => { + beforeEach(async () => { + vi.clearAllMocks(); + + const { useDroppable } = await import('@dnd-kit/core'); + vi.mocked(useDroppable).mockReturnValue({ + isOver: true, + setNodeRef: vi.fn(), + over: null, + active: null, + rect: { current: null }, + node: { current: null }, + }); + }); + + describe('updateCanDrop', () => { + it('is called when hovering over a different document', async () => { + const { waitFor } = await import('@testing-library/react'); + + renderItem('other-doc-id'); + + await waitFor(() => { + expect(mockUpdateCanDrop).toHaveBeenCalledWith(true, true); + }); + }); + + it('is not called when hovering over the document being dragged (self-drop)', () => { + renderItem(mockDoc.id); + + expect(mockUpdateCanDrop).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/__tests__/useDragAndDrop.test.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/__tests__/useDragAndDrop.test.tsx new file mode 100644 index 0000000000..23e9d25060 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/__tests__/useDragAndDrop.test.tsx @@ -0,0 +1,105 @@ +import { act, renderHook } from '@testing-library/react'; +import { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Doc, Role } from '@/docs/doc-management'; +import { AppWrapper } from '@/tests/utils'; + +import { useDragAndDrop } from '../useDragAndDrop'; + +const mockDoc = { + id: 'doc-1', + user_role: Role.OWNER, + nb_accesses_direct: 1, + title: 'Test doc', +} as Doc; + +const mockDragStartEvent = { + active: { + id: 'doc-1', + data: { current: mockDoc }, + rect: { current: { initial: null, translated: null } }, + }, +} as unknown as DragStartEvent; + +const mockDragEndEvent = { + active: { + id: 'doc-1', + data: { current: mockDoc }, + rect: { current: { initial: null, translated: null } }, + }, + over: null, + delta: { x: 0, y: 0 }, + activatorEvent: null, + collisions: null, +} as unknown as DragEndEvent; + +describe('useDragAndDrop', () => { + beforeEach(() => { + document.body.classList.remove('is-dnd-dragging'); + document.body.style.cursor = ''; + }); + + describe('is-dnd-dragging body class', () => { + it('adds the class on drag start', () => { + const { result } = renderHook(() => useDragAndDrop(vi.fn()), { + wrapper: AppWrapper, + }); + + act(() => { + result.current.handleDragStart(mockDragStartEvent); + }); + + expect(document.body.classList.contains('is-dnd-dragging')).toBe(true); + }); + + it('removes the class on drag end', () => { + const { result } = renderHook(() => useDragAndDrop(vi.fn()), { + wrapper: AppWrapper, + }); + + act(() => { + result.current.handleDragStart(mockDragStartEvent); + }); + + act(() => { + result.current.handleDragEnd(mockDragEndEvent); + }); + + expect(document.body.classList.contains('is-dnd-dragging')).toBe(false); + }); + + it('removes the class on drag end even when no valid drop target was hovered', () => { + // canDrop remains undefined (no droppable was hovered), but the class must still be removed + const { result } = renderHook(() => useDragAndDrop(vi.fn()), { + wrapper: AppWrapper, + }); + + act(() => { + result.current.handleDragStart(mockDragStartEvent); + }); + + act(() => { + result.current.handleDragEnd({ ...mockDragEndEvent, over: null }); + }); + + expect(document.body.classList.contains('is-dnd-dragging')).toBe(false); + }); + + it('removes the class on drag cancel (Escape key)', () => { + const { result } = renderHook(() => useDragAndDrop(vi.fn()), { + wrapper: AppWrapper, + }); + + act(() => { + result.current.handleDragStart(mockDragStartEvent); + }); + + act(() => { + result.current.handleDragCancel(); + }); + + expect(document.body.classList.contains('is-dnd-dragging')).toBe(false); + }); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx index 81410c0f62..722cf6c0e5 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx @@ -35,15 +35,25 @@ export function useDragAndDrop(onDrag: (data: DocDragEndData) => void) { const handleDragStart = (e: DragStartEvent) => { document.body.style.cursor = 'grabbing'; + document.body.classList.add('is-dnd-dragging'); if (e.active.data.current) { setSelectedDoc(e.active.data.current as Doc); } }; - const handleDragEnd = (e: DragEndEvent) => { + const resetDrag = () => { setSelectedDoc(undefined); setCanDrop(undefined); document.body.style.cursor = 'default'; + document.body.classList.remove('is-dnd-dragging'); + }; + + const handleDragCancel = () => { + resetDrag(); + }; + + const handleDragEnd = (e: DragEndEvent) => { + resetDrag(); if (!canDrag || !canDrop) { return; } @@ -75,6 +85,7 @@ export function useDragAndDrop(onDrag: (data: DocDragEndData) => void) { sensors, handleDragStart, handleDragEnd, + handleDragCancel, updateCanDrop, }; } diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx index 56faa00bca..a84b98f1a9 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LefPanelTargetFilters.tsx @@ -93,6 +93,11 @@ export const LeftPanelTargetFilters = () => { --c--contextuals--background--semantic--contextual--primary ); } + body.is-dnd-dragging &:hover { + background-color: ${isActive + ? 'var(--c--contextuals--background--semantic--contextual--primary)' + : 'transparent'}; + } &:focus-visible { outline: none !important; box-shadow: 0 0 0 2px ${colorsTokens['brand-400']} !important; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavorites.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavorites.tsx index 3171bccffa..bb8b867427 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavorites.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavorites.tsx @@ -1,4 +1,6 @@ +import { useDroppable } from '@dnd-kit/core'; import { DateTime } from 'luxon'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -15,6 +17,7 @@ import { SimpleDocItem, useInfiniteDocsFavorite, } from '@/docs/doc-management'; +import { useDocDnd } from '@/features/docs/DocDndContext'; import { DocsGridActions } from '@/features/docs/docs-grid'; import { useResponsiveStore } from '@/stores/useResponsiveStore'; @@ -80,16 +83,40 @@ export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => { const { colorsTokens, spacingsTokens } = useCunninghamTheme(); const { isLargeScreen } = useResponsiveStore(); const { t } = useTranslation(); + const dnd = useDocDnd(); + const canDrag = dnd?.canDrag ?? false; + const updateCanDrop = dnd?.updateCanDrop; + const isSelf = dnd?.selectedDoc?.id === doc.id; + const canDrop = doc.abilities.move; + const { isOver, setNodeRef } = useDroppable({ + id: `favorite-${doc.id}`, + data: doc, + }); + const showDropHint = canDrag && canDrop && isOver && !isSelf; + + useEffect(() => { + if (isOver && !isSelf) { + updateCanDrop?.(canDrop, isOver); + } + }, [isOver, isSelf, canDrop, updateCanDrop]); return ( { opacity: 1; } } + body.is-dnd-dragging &:hover { + background-color: ${showDropHint + ? 'var(--c--globals--colors--brand-100)' + : 'transparent'}; + .pinned-actions { + opacity: ${isLargeScreen ? 0 : 1}; + } + } &:focus-within { cursor: pointer; box-shadow: 0 0 0 2px ${colorsTokens['brand-400']} !important; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/__tests__/LeftPanelFavorites.test.tsx b/src/frontend/apps/impress/src/features/left-panel/components/__tests__/LeftPanelFavorites.test.tsx new file mode 100644 index 0000000000..2e75595212 --- /dev/null +++ b/src/frontend/apps/impress/src/features/left-panel/components/__tests__/LeftPanelFavorites.test.tsx @@ -0,0 +1,206 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Doc } from '@/docs/doc-management'; +import { AppWrapper } from '@/tests/utils'; + +import { LeftPanelFavoriteItem } from '../LeftPanelFavorites'; + +const mockUpdateCanDrop = vi.fn(); + +vi.mock('@dnd-kit/core', async () => { + const actual = await vi.importActual('@dnd-kit/core'); + return { + ...actual, + useDroppable: vi.fn(), + }; +}); + +vi.mock('@/features/docs/DocDndContext', () => ({ + useDocDnd: vi.fn(), +})); + +const mockDoc: Doc = { + id: 'fav-doc-1', + title: 'Pinned doc', + abilities: { + move: true, + partial_update: false, + }, + updated_at: new Date().toISOString(), + nb_accesses_direct: 1, +} as unknown as Doc; + +const renderItem = (doc = mockDoc) => + render( +
    + +
, + { wrapper: AppWrapper }, + ); + +describe('LeftPanelFavoriteItem', () => { + beforeEach(async () => { + vi.clearAllMocks(); + + const { useDroppable } = await import('@dnd-kit/core'); + vi.mocked(useDroppable).mockReturnValue({ + isOver: false, + setNodeRef: vi.fn(), + over: null, + active: null, + rect: { current: null }, + node: { current: null }, + }); + + const { useDocDnd } = await import('@/features/docs/DocDndContext'); + vi.mocked(useDocDnd).mockReturnValue({ + selectedDoc: undefined, + canDrag: false, + canDrop: undefined, + updateCanDrop: mockUpdateCanDrop, + isDraggableDisabled: false, + }); + }); + + it('renders the document title', () => { + renderItem(); + expect(screen.getByText('Pinned doc')).toBeInTheDocument(); + }); + + it('renders a link to the document', () => { + renderItem(); + expect(screen.getByRole('link')).toHaveAttribute('href', '/docs/fav-doc-1'); + }); + + describe('updateCanDrop', () => { + it('is called with true when the cursor is over a droppable-enabled favorite', async () => { + const { useDroppable } = await import('@dnd-kit/core'); + vi.mocked(useDroppable).mockReturnValue({ + isOver: true, + setNodeRef: vi.fn(), + over: null, + active: null, + rect: { current: null }, + node: { current: null }, + }); + + const { useDocDnd } = await import('@/features/docs/DocDndContext'); + vi.mocked(useDocDnd).mockReturnValue({ + selectedDoc: { id: 'dragging' } as Doc, + canDrag: true, + canDrop: undefined, + updateCanDrop: mockUpdateCanDrop, + isDraggableDisabled: false, + }); + + renderItem(); + + await waitFor(() => { + expect(mockUpdateCanDrop).toHaveBeenCalledWith(true, true); + }); + }); + + it('is not called when the cursor is not over the item', async () => { + renderItem(); + expect(mockUpdateCanDrop).not.toHaveBeenCalled(); + }); + + it('is not called when hovering over the document being dragged (self-drop)', async () => { + const { useDroppable } = await import('@dnd-kit/core'); + vi.mocked(useDroppable).mockReturnValue({ + isOver: true, + setNodeRef: vi.fn(), + over: null, + active: null, + rect: { current: null }, + node: { current: null }, + }); + + const { useDocDnd } = await import('@/features/docs/DocDndContext'); + vi.mocked(useDocDnd).mockReturnValue({ + selectedDoc: mockDoc, + canDrag: true, + canDrop: undefined, + updateCanDrop: mockUpdateCanDrop, + isDraggableDisabled: false, + }); + + renderItem(); + + expect(mockUpdateCanDrop).not.toHaveBeenCalled(); + }); + + it('is called with false when the favorite doc cannot be moved into', async () => { + const { useDroppable } = await import('@dnd-kit/core'); + vi.mocked(useDroppable).mockReturnValue({ + isOver: true, + setNodeRef: vi.fn(), + over: null, + active: null, + rect: { current: null }, + node: { current: null }, + }); + + const { useDocDnd } = await import('@/features/docs/DocDndContext'); + vi.mocked(useDocDnd).mockReturnValue({ + selectedDoc: { id: 'dragging' } as Doc, + canDrag: true, + canDrop: undefined, + updateCanDrop: mockUpdateCanDrop, + isDraggableDisabled: false, + }); + + const noMoveDoc = { + ...mockDoc, + abilities: { ...mockDoc.abilities, move: false }, + } as Doc; + + renderItem(noMoveDoc); + + await waitFor(() => { + expect(mockUpdateCanDrop).toHaveBeenCalledWith(false, true); + }); + }); + }); + + describe('droppable registration', () => { + it('registers with a favorite-prefixed ID to avoid collision with grid droppables', async () => { + const { useDroppable } = await import('@dnd-kit/core'); + const mockUseDroppable = vi.mocked(useDroppable); + mockUseDroppable.mockReturnValue({ + isOver: false, + setNodeRef: vi.fn(), + over: null, + active: null, + rect: { current: null }, + node: { current: null }, + }); + + renderItem(); + + expect(mockUseDroppable).toHaveBeenCalledWith( + expect.objectContaining({ id: 'favorite-fav-doc-1' }), + ); + }); + + it('passes the doc object as data so the move API receives the correct target', async () => { + const { useDroppable } = await import('@dnd-kit/core'); + const mockUseDroppable = vi.mocked(useDroppable); + mockUseDroppable.mockReturnValue({ + isOver: false, + setNodeRef: vi.fn(), + over: null, + active: null, + rect: { current: null }, + node: { current: null }, + }); + + renderItem(); + + expect(mockUseDroppable).toHaveBeenCalledWith( + expect.objectContaining({ data: mockDoc }), + ); + }); + }); +}); diff --git a/src/frontend/apps/impress/src/pages/docs/index.tsx b/src/frontend/apps/impress/src/pages/docs/index.tsx index cd594e2634..7265298229 100644 --- a/src/frontend/apps/impress/src/pages/docs/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/index.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { DocDefaultFilter, useTrans } from '@/docs/doc-management'; import { DocsGrid } from '@/docs/docs-grid'; +import { DocDndProvider } from '@/features/docs/DocDndContext'; import { HeaderFloatingBar } from '@/features/header/components/HeaderFloatingBar'; import { MainLayout } from '@/layouts'; import { NextPageWithLayout } from '@/types/next'; @@ -36,13 +37,15 @@ const Page: NextPageWithLayout = () => { Page.getLayout = function getLayout(page: ReactElement) { return ( - - {page} - + + + {page} + + ); };