(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

@@ -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(