Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
246 changes: 246 additions & 0 deletions src/frontend/apps/impress/src/features/docs/DocDndContext.tsx
Original file line number Diff line number Diff line change
@@ -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<DocDndContextValue | null>(null);

export const useDocDnd = () => useContext(DocDndCtx);

export const DocDndProvider = ({ children }: PropsWithChildren) => {
const { mutateAsync: handleMove, isError } = useMoveDoc();
const modalConfirmation = useModal();
const onDragData = useRef<DocDragEndData | null>(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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
} finally {
// Only clear if no newer drag has been queued while the request was in-flight.
if (onDragData.current === dragSnapshot) {
onDragData.current = null;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

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,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
},
}),
[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]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 (
<DocDndCtx.Provider
value={{ selectedDoc, canDrag, canDrop, updateCanDrop, isDraggableDisabled }}
>
<DndContext
sensors={sensors}
modifiers={[snapToTopLeft]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
accessibility={dndAccessibility}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{children}
<DragOverlay dropAnimation={null}>
<Card
$width="fit-content"
$radius="12px"
data-testid="drag-doc-overlay"
role="alert"
aria-label={t('Drag and drop status')}
$theme={cannotMoveDoc ? 'error' : 'brand'}
$variation="tertiary"
$scope="semantic"
>
<Text $size="xs" $weight="500" $withThemeInherited>
{overlayText}
</Text>
</Card>
</DragOverlay>
</DndContext>
{modalConfirmation.isOpen && (
<ModalConfirmationMoveDoc
isOpen={modalConfirmation.isOpen}
onClose={modalConfirmation.onClose}
onConfirm={handleMoveDoc}
targetDocumentTitle={
onDragData.current?.target.title || untitledDocument
}
/>
)}
</DocDndCtx.Provider>
);
};
Loading