✨(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:
committed by
Anthony LC
parent
13696ffbd7
commit
cb2ecfcea3
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -41,6 +41,7 @@ export const SimpleDocItem = ({
|
||||
$direction="row"
|
||||
$gap={spacingsTokens.sm}
|
||||
$overflow="auto"
|
||||
$width="100%"
|
||||
className="--docs--simple-doc-item"
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user