(frontend) Added drag-and-drop functionality for document management

Added a new feature for moving documents within the user interface via
drag-and-drop. This includes the creation of Draggable and Droppable
components, as well as tests to verify document creation and movement
behavior. Changes have also been made to document types to include user
roles and child management capabilities.
This commit is contained in:
Nathan Panchout
2025-03-17 15:12:12 +01:00
committed by Anthony LC
parent 13696ffbd7
commit cb2ecfcea3
14 changed files with 736 additions and 30 deletions

View File

@@ -239,6 +239,7 @@ export const mockedDocument = async (page: Page, json: object) => {
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
user_roles: ['owner'],
...json,
},
});
@@ -248,6 +249,22 @@ export const mockedDocument = async (page: Page, json: object) => {
});
};
export const mockedListDocs = async (page: Page, data: object[] = []) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
json: {
count: data.length,
next: null,
previous: null,
results: data,
},
});
}
});
};
export const mockedInvitations = async (page: Page, json?: object) => {
await page.route('**/invitations/**/', async (route) => {
const request = route.request();

View File

@@ -0,0 +1,311 @@
import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs } from './common';
test.describe('Doc grid dnd', () => {
test('it creates a doc', async ({ page, browserName }) => {
await page.goto('/');
const header = page.locator('header').first();
await createDoc(page, 'Draggable doc', browserName, 1);
await header.locator('h2').getByText('Docs').click();
await createDoc(page, 'Droppable doc', browserName, 1);
await header.locator('h2').getByText('Docs').click();
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const responseJson = await response.json();
const items = responseJson.results;
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const draggableElement = page.getByTestId(`draggable-doc-${items[1].id}`);
const dropZone = page.getByTestId(`droppable-doc-${items[0].id}`);
await expect(draggableElement).toBeVisible();
await expect(dropZone).toBeVisible();
// Obtenir les positions des éléments
const draggableBoundingBox = await draggableElement.boundingBox();
const dropZoneBoundingBox = await dropZone.boundingBox();
expect(draggableBoundingBox).toBeDefined();
expect(dropZoneBoundingBox).toBeDefined();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!draggableBoundingBox || !dropZoneBoundingBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
await page.mouse.move(
draggableBoundingBox.x + draggableBoundingBox.width / 2,
draggableBoundingBox.y + draggableBoundingBox.height / 2,
);
await page.mouse.down();
// Déplacer vers la zone cible
await page.mouse.move(
dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2,
dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2,
{ steps: 10 }, // Rendre le mouvement plus fluide
);
const dragOverlay = page.getByTestId('drag-doc-overlay');
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(items[1].title as string);
await page.mouse.up();
await expect(dragOverlay).toBeHidden();
});
test('it checks cant drop when we have not the minimum role', async ({
page,
}) => {
await mockedListDocs(page, data);
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
const noDropAndNoDrag = page.getByTestId(
'droppable-doc-no-drop-and-no-drag',
);
await expect(canDropAndDrag).toBeVisible();
await expect(noDropAndNoDrag).toBeVisible();
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
await page.mouse.move(
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
{ steps: 10 },
);
const dragOverlay = page.getByTestId('drag-doc-overlay');
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(
'You must be at least the editor of the target document',
);
await page.mouse.up();
});
test('it checks cant drag when we have not the minimum role', async ({
page,
}) => {
await mockedListDocs(page, data);
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
const noDropAndNoDrag = page.getByTestId(
'droppable-doc-no-drop-and-no-drag',
);
await expect(canDropAndDrag).toBeVisible();
await expect(noDropAndNoDrag).toBeVisible();
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
await page.mouse.move(
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
{ steps: 10 },
);
const dragOverlay = page.getByTestId('drag-doc-overlay');
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(
'You must have admin rights to move the document',
);
await page.mouse.up();
});
});
const data = [
{
id: 'can-drop-and-drag',
abilities: {
accesses_manage: true,
accesses_view: true,
ai_transform: true,
ai_translate: true,
attachment_upload: true,
children_list: true,
children_create: true,
collaboration_auth: true,
descendants: true,
destroy: true,
favorite: true,
link_configuration: true,
invite_owner: true,
move: true,
partial_update: true,
restore: true,
retrieve: true,
media_auth: true,
link_select_options: {
restricted: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
public: ['reader', 'editor'],
},
tree: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
created_at: '2025-03-14T14:45:22.527221Z',
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
depth: 1,
excerpt: null,
is_favorite: false,
link_role: 'reader',
link_reach: 'restricted',
nb_accesses_ancestors: 1,
nb_accesses_direct: 1,
numchild: 5,
path: '000000o',
title: 'Can drop and drag',
updated_at: '2025-03-14T14:45:27.699542Z',
user_roles: ['owner'],
},
{
id: 'can-only-drop',
title: 'Can only drop',
abilities: {
accesses_manage: true,
accesses_view: true,
ai_transform: true,
ai_translate: true,
attachment_upload: true,
children_list: true,
children_create: true,
collaboration_auth: true,
descendants: true,
destroy: true,
favorite: true,
link_configuration: true,
invite_owner: true,
move: true,
partial_update: true,
restore: true,
retrieve: true,
media_auth: true,
link_select_options: {
restricted: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
public: ['reader', 'editor'],
},
tree: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
created_at: '2025-03-14T14:45:22.527221Z',
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
depth: 1,
excerpt: null,
is_favorite: false,
link_role: 'reader',
link_reach: 'restricted',
nb_accesses_ancestors: 1,
nb_accesses_direct: 1,
numchild: 5,
path: '000000o',
updated_at: '2025-03-14T14:45:27.699542Z',
user_roles: ['editor'],
},
{
id: 'no-drop-and-no-drag',
abilities: {
accesses_manage: false,
accesses_view: true,
ai_transform: false,
ai_translate: false,
attachment_upload: false,
children_list: true,
children_create: false,
collaboration_auth: true,
descendants: true,
destroy: false,
favorite: true,
link_configuration: false,
invite_owner: false,
move: false,
partial_update: false,
restore: false,
retrieve: true,
media_auth: true,
link_select_options: {
restricted: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
public: ['reader', 'editor'],
},
tree: true,
update: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
},
created_at: '2025-03-14T14:44:16.032773Z',
creator: '9264f420-f018-4bd6-96ae-4788f41af56d',
depth: 1,
excerpt: null,
is_favorite: false,
link_role: 'reader',
link_reach: 'restricted',
nb_accesses_ancestors: 14,
nb_accesses_direct: 14,
numchild: 0,
path: '000000l',
title: 'No drop and no drag',
updated_at: '2025-03-14T14:44:16.032774Z',
user_roles: ['reader'],
},
];

View File

@@ -59,6 +59,7 @@ test.describe('Documents Grid mobile', () => {
link_reach: 'public',
created_at: '2024-10-07T13:02:41.085298Z',
updated_at: '2024-10-07T13:30:21.829690Z',
user_roles: ['owner'],
},
],
},
@@ -168,6 +169,7 @@ test.describe('Document grid item options', () => {
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
user_roles: ['editor'],
},
],
},

View File

@@ -70,7 +70,9 @@ test.describe('Doc Header', () => {
).toBeVisible();
await expect(
page.getByText(`Are you sure you want to delete this document ?`),
page.getByText(
`Are you sure you want to delete the document "${randomDoc}"?`,
),
).toBeVisible();
await page

View File

@@ -42,10 +42,14 @@ export interface Doc {
is_favorite: boolean;
link_reach: LinkReach;
link_role: LinkRole;
nb_accesses_ancestors: number;
nb_accesses_direct: number;
user_roles: Role[];
created_at: string;
updated_at: string;
nb_accesses_direct: number;
nb_accesses_ancestors: number;
children?: Doc[];
childrenCount?: number;
numchild: number;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;

View File

@@ -0,0 +1,36 @@
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
export type MoveDocParam = {
sourceDocumentId: string;
targetDocumentId: string;
position: TreeViewMoveModeEnum;
};
export const moveDoc = async ({
sourceDocumentId,
targetDocumentId,
position,
}: MoveDocParam): Promise<void> => {
const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, {
method: 'POST',
body: JSON.stringify({
target_document_id: targetDocumentId,
position,
}),
});
if (!response.ok) {
throw new APIError('Failed to move the doc', await errorCauses(response));
}
return response.json() as Promise<void>;
};
export function useMoveDoc() {
return useMutation<void, APIError, MoveDocParam>({
mutationFn: moveDoc,
});
}

View File

@@ -0,0 +1,170 @@
import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core';
import { getEventCoordinates } from '@dnd-kit/utilities';
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { Doc, KEY_LIST_DOC } from '@/docs/doc-management';
import { useMoveDoc } from '@/docs/doc-tree/api/useMove';
import { useDragAndDrop } from '../hooks/useDragAndDrop';
import { DocsGridItem } from './DocsGridItem';
import { Draggable } from './Draggable';
import { Droppable } from './Droppable';
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[];
};
export const DocGridContentList = ({ docs }: DocGridContentListProps) => {
const { mutate: handleMove, isError } = useMoveDoc();
const queryClient = useQueryClient();
const onDrag = (sourceDocumentId: string, targetDocumentId: string) =>
handleMove(
{
sourceDocumentId,
targetDocumentId,
position: TreeViewMoveModeEnum.FIRST_CHILD,
},
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
},
},
);
const {
selectedDoc,
canDrag,
canDrop,
sensors,
handleDragStart,
handleDragEnd,
updateCanDrop,
} = useDragAndDrop(onDrag);
const { t } = useTranslation();
const overlayText = useMemo(() => {
if (!canDrag) {
return t('You must have admin rights to move the document');
}
if (!canDrop) {
return t('You must be at least the editor of the target document');
}
return selectedDoc?.title || t('Unnamed document');
}, [canDrag, canDrop, selectedDoc, t]);
const overlayBgColor = useMemo(() => {
if (!canDrag) {
return 'var(--c--theme--colors--danger-600)';
}
if (canDrop !== undefined && !canDrop) {
return 'var(--c--theme--colors--danger-600)';
}
if (isError) {
return 'var(--c--theme--colors--danger-600)';
}
return '#5858D3';
}, [canDrag, canDrop, isError]);
if (docs.length === 0) {
return null;
}
return (
<DndContext
sensors={sensors}
modifiers={[snapToTopLeft]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{docs.map((doc) => (
<DraggableDocGridItem
key={doc.id}
doc={doc}
dragMode={!!selectedDoc}
canDrag={!!canDrag}
updateCanDrop={updateCanDrop}
/>
))}
<DragOverlay dropAnimation={null}>
<Box
$width="fit-content"
$padding={{ horizontal: 'xs', vertical: '3xs' }}
$radius="12px"
$background={overlayBgColor}
data-testid="drag-doc-overlay"
$height="auto"
role="alert"
>
<Text $size="xs" $variation="000" $weight="500">
{overlayText}
</Text>
</Box>
</DragOverlay>
</DndContext>
);
};
interface DocGridItemProps {
doc: Doc;
dragMode: boolean;
canDrag: boolean;
updateCanDrop: (canDrop: boolean, isOver: boolean) => void;
}
export const DraggableDocGridItem = ({
doc,
dragMode,
canDrag,
updateCanDrop,
}: DocGridItemProps) => {
const canDrop = doc.abilities.move;
return (
<Droppable
enabledDrop={canDrag}
canDrop={canDrag && canDrop}
onOver={(isOver) => updateCanDrop(canDrop, isOver)}
id={doc.id}
data={doc}
>
<Draggable id={doc.id} data={doc}>
<DocsGridItem dragMode={dragMode} doc={doc} />
</Draggable>
</Droppable>
);
};

View File

@@ -9,7 +9,7 @@ import { useResponsiveStore } from '@/stores';
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
import { DocsGridItem } from './DocsGridItem';
import { DocGridContentList } from './DocGridContentList';
import { DocsGridLoader } from './DocsGridLoader';
type DocsGridProps = {
@@ -37,6 +37,9 @@ export const DocsGrid = ({
is_creator_me: target === DocDefaultFilter.MY_DOCS,
}),
});
const docs = data?.pages.flatMap((page) => page.results) ?? [];
const loading = isFetching || isLoading;
const hasDocs = data?.pages.some((page) => page.results.length > 0);
const loadMore = (inView: boolean) => {
@@ -115,11 +118,7 @@ export const DocsGrid = ({
)}
</Box>
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
<DocGridContentList docs={docs} />
{hasNextPage && !loading && (
<InView

View File

@@ -16,8 +16,9 @@ import { DocsGridItemSharedButton } from './DocsGridItemSharedButton';
import { SimpleDocItem } from './SimpleDocItem';
type DocsGridItemProps = {
doc: Doc;
dragMode?: boolean;
};
export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { flexLeft, flexRight } = useResponsiveDocGrid();
@@ -45,7 +46,9 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
cursor: pointer;
border-radius: 4px;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
background-color: ${dragMode
? 'none'
: 'var(--c--theme--colors--greyscale-100)'};
}
`}
className="--docs--doc-grid-item"
@@ -79,25 +82,35 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
: undefined
}
>
<Tooltip
content={
<Text $textAlign="center" $variation="000">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</Text>
}
placement="top"
>
<div>
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
</div>
</Tooltip>
{dragMode && (
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
)}
{!dragMode && (
<Tooltip
content={
<Text $textAlign="center" $variation="000">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</Text>
}
placement="top"
>
<div>
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
</div>
</Tooltip>
)}
</Box>
)}
</Box>

View File

@@ -0,0 +1,26 @@
import { Data, useDraggable } from '@dnd-kit/core';
type DraggableProps<T> = {
id: string;
data?: Data<T>;
children: React.ReactNode;
};
export const Draggable = <T,>(props: DraggableProps<T>) => {
const { attributes, listeners, setNodeRef } = useDraggable({
id: props.id,
data: props.data,
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
data-testid={`draggable-doc-${props.id}`}
className="--docs--grid-draggable"
>
{props.children}
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { Data, useDroppable } from '@dnd-kit/core';
import { PropsWithChildren, useEffect } from 'react';
import { css } from 'styled-components';
import { Box } from '@/components';
import { Doc } from '@/docs/doc-management';
type DroppableProps = {
id: string;
onOver?: (isOver: boolean, data?: Data<Doc>) => void;
data?: Data<Doc>;
enabledDrop?: boolean;
canDrop?: boolean;
};
export const Droppable = ({
onOver,
canDrop,
data,
children,
id,
}: PropsWithChildren<DroppableProps>) => {
const { isOver, setNodeRef } = useDroppable({
id,
data,
});
const enableHover = canDrop && isOver;
useEffect(() => {
onOver?.(isOver, data);
}, [isOver, data, onOver]);
return (
<Box
ref={setNodeRef}
data-testid={`droppable-doc-${id}`}
$css={css`
border-radius: 4px;
background-color: ${enableHover
? 'var(--c--theme--colors--primary-100)'
: 'transparent'};
border: 1.5px solid
${enableHover
? 'var(--c--theme--colors--primary-500)'
: 'transparent'};
`}
className="--docs--grid-droppable"
>
{children}
</Box>
);
};

View File

@@ -41,6 +41,7 @@ export const SimpleDocItem = ({
$direction="row"
$gap={spacingsTokens.sm}
$overflow="auto"
$width="100%"
className="--docs--simple-doc-item"
>
<Box

View File

@@ -0,0 +1,70 @@
import {
DragEndEvent,
DragStartEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { useState } from 'react';
import { Doc } from '@/docs/doc-management';
const activationConstraint = {
distance: 20,
};
export function useDragAndDrop(
onDrag: (sourceDocumentId: string, targetDocumentId: string) => void,
) {
const [selectedDoc, setSelectedDoc] = useState<Doc>();
const [canDrop, setCanDrop] = useState<boolean>();
const canDrag = selectedDoc?.abilities.move;
const mouseSensor = useSensor(MouseSensor, { activationConstraint });
const touchSensor = useSensor(TouchSensor, { activationConstraint });
const keyboardSensor = useSensor(KeyboardSensor, {});
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const handleDragStart = (e: DragStartEvent) => {
document.body.style.cursor = 'grabbing';
if (e.active.data.current) {
setSelectedDoc(e.active.data.current as Doc);
}
};
const handleDragEnd = (e: DragEndEvent) => {
setSelectedDoc(undefined);
setCanDrop(undefined);
document.body.style.cursor = 'default';
if (!canDrag || !canDrop) {
return;
}
const { active, over } = e;
if (!over?.id || active.id === over?.id) {
return;
}
onDrag(active.id as string, over.id as string);
};
const updateCanDrop = (docCanDrop: boolean, isOver: boolean) => {
if (isOver) {
setCanDrop(docCanDrop);
}
};
return {
selectedDoc,
canDrag,
canDrop,
sensors,
handleDragStart,
handleDragEnd,
updateCanDrop,
};
}

View File

@@ -175,6 +175,7 @@ export class ApiPlugin implements WorkboxPlugin {
is_favorite: false,
nb_accesses_direct: 1,
nb_accesses_ancestors: 1,
numchild: 0,
updated_at: new Date().toISOString(),
abilities: {
accesses_manage: true,
@@ -202,6 +203,7 @@ export class ApiPlugin implements WorkboxPlugin {
},
link_reach: LinkReach.RESTRICTED,
link_role: LinkRole.READER,
user_roles: [],
};
await DocsDB.cacheResponse(