Add drag and drop support

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-01 18:08:38 +01:00
parent 05160a1618
commit 3b69e894fb
3 changed files with 249 additions and 84 deletions

View File

@@ -1,4 +1,11 @@
import { Suspense, useEffect, useState, useRef } from "react"; import {
Suspense,
useEffect,
useState,
useRef,
DragEvent,
useMemo,
} from "react";
import { useParams, useNavigate } from "react-router"; import { useParams, useNavigate } from "react-router";
import { import {
graphql, graphql,
@@ -7,7 +14,14 @@ import {
useQueryLoader, useQueryLoader,
useMutation, useMutation,
} from "react-relay"; } from "react-relay";
import { CheckCircle2, Plus, Trash2, Upload } from "lucide-react"; import {
CheckCircle2,
Plus,
Trash2,
Upload,
FileIcon,
Loader2,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -29,6 +43,7 @@ import type { ControlOverviewPageUpdateTaskStateMutation as ControlOverviewPageU
import type { ControlOverviewPageCreateTaskMutation as ControlOverviewPageCreateTaskMutationType } from "./__generated__/ControlOverviewPageCreateTaskMutation.graphql"; import type { ControlOverviewPageCreateTaskMutation as ControlOverviewPageCreateTaskMutationType } from "./__generated__/ControlOverviewPageCreateTaskMutation.graphql";
import type { ControlOverviewPageDeleteTaskMutation as ControlOverviewPageDeleteTaskMutationType } from "./__generated__/ControlOverviewPageDeleteTaskMutation.graphql"; import type { ControlOverviewPageDeleteTaskMutation as ControlOverviewPageDeleteTaskMutationType } from "./__generated__/ControlOverviewPageDeleteTaskMutation.graphql";
import type { ControlOverviewPageUploadEvidenceMutation as ControlOverviewPageUploadEvidenceMutationType } from "./__generated__/ControlOverviewPageUploadEvidenceMutation.graphql"; import type { ControlOverviewPageUploadEvidenceMutation as ControlOverviewPageUploadEvidenceMutationType } from "./__generated__/ControlOverviewPageUploadEvidenceMutation.graphql";
const controlOverviewPageQuery = graphql` const controlOverviewPageQuery = graphql`
query ControlOverviewPageQuery($controlId: ID!) { query ControlOverviewPageQuery($controlId: ID!) {
control: node(id: $controlId) { control: node(id: $controlId) {
@@ -39,7 +54,6 @@ const controlOverviewPageQuery = graphql`
state state
category category
tasks(first: 100) @connection(key: "ControlOverviewPage_tasks") { tasks(first: 100) @connection(key: "ControlOverviewPage_tasks") {
__id
edges { edges {
node { node {
id id
@@ -123,14 +137,14 @@ function ControlOverviewPageContent({
}) { }) {
const data = usePreloadedQuery<ControlOverviewPageQueryType>( const data = usePreloadedQuery<ControlOverviewPageQueryType>(
controlOverviewPageQuery, controlOverviewPageQuery,
queryRef, queryRef
); );
const { toast } = useToast(); const { toast } = useToast();
const { organizationId, frameworkId, controlId } = useParams(); const { organizationId, frameworkId, controlId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [updateTaskState] = const [updateTaskState] =
useMutation<ControlOverviewPageUpdateTaskStateMutationType>( useMutation<ControlOverviewPageUpdateTaskStateMutationType>(
updateTaskStateMutation, updateTaskStateMutation
); );
const [createTask] = const [createTask] =
useMutation<ControlOverviewPageCreateTaskMutationType>(createTaskMutation); useMutation<ControlOverviewPageCreateTaskMutationType>(createTaskMutation);
@@ -138,7 +152,7 @@ function ControlOverviewPageContent({
useMutation<ControlOverviewPageDeleteTaskMutationType>(deleteTaskMutation); useMutation<ControlOverviewPageDeleteTaskMutationType>(deleteTaskMutation);
const [uploadEvidence] = const [uploadEvidence] =
useMutation<ControlOverviewPageUploadEvidenceMutationType>( useMutation<ControlOverviewPageUploadEvidenceMutationType>(
uploadEvidenceMutation, uploadEvidenceMutation
); );
const control = data.control; const control = data.control;
const tasks = control?.tasks?.edges.map((edge) => edge?.node) ?? []; const tasks = control?.tasks?.edges.map((edge) => edge?.node) ?? [];
@@ -161,6 +175,57 @@ function ControlOverviewPageContent({
const [evidenceName, setEvidenceName] = useState(""); const [evidenceName, setEvidenceName] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [draggedOverTaskId, setDraggedOverTaskId] = useState<string | null>(
null
);
const [uploadingTaskId, setUploadingTaskId] = useState<string | null>(null);
const [isDraggingFile, setIsDraggingFile] = useState(false);
// Get the connection ID for Relay
const connectionId = useMemo(() => {
// The connection key is defined in the GraphQL query as "ControlOverviewPage_tasks"
return control?.id ? `client:${control.id}:tasks{"first":100}` : null;
}, [control?.id]);
// Add global drag event handlers to detect when a file is being dragged
useEffect(() => {
const handleDragEnter = (e: globalThis.DragEvent) => {
e.preventDefault();
if (e.dataTransfer?.types.includes("Files")) {
setIsDraggingFile(true);
}
};
const handleDragLeave = (e: globalThis.DragEvent) => {
e.preventDefault();
// Only set to false if we're leaving the window
if (!e.relatedTarget || (e.relatedTarget as Node).nodeName === "HTML") {
setIsDraggingFile(false);
}
};
const handleDragOver = (e: globalThis.DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: globalThis.DragEvent) => {
e.preventDefault();
setIsDraggingFile(false);
};
document.addEventListener("dragenter", handleDragEnter);
document.addEventListener("dragleave", handleDragLeave);
document.addEventListener("dragover", handleDragOver);
document.addEventListener("drop", handleDrop);
return () => {
document.removeEventListener("dragenter", handleDragEnter);
document.removeEventListener("dragleave", handleDragLeave);
document.removeEventListener("dragover", handleDragOver);
document.removeEventListener("drop", handleDrop);
};
}, []);
const handleTaskClick = (taskId: string, currentState: string) => { const handleTaskClick = (taskId: string, currentState: string) => {
const newState = currentState === "DONE" ? "TODO" : "DONE"; const newState = currentState === "DONE" ? "TODO" : "DONE";
@@ -220,7 +285,7 @@ function ControlOverviewPageContent({
createTask({ createTask({
variables: { variables: {
connections: [`${data.control?.tasks?.__id}`], connections: [],
input: { input: {
controlId: control.id, controlId: control.id,
name: newTaskName, name: newTaskName,
@@ -256,7 +321,7 @@ function ControlOverviewPageContent({
deleteTask({ deleteTask({
variables: { variables: {
connections: [`${data.control?.tasks?.__id}`], connections: connectionId ? [connectionId] : [],
input: { input: {
taskId: taskToDelete.id, taskId: taskToDelete.id,
}, },
@@ -281,7 +346,7 @@ function ControlOverviewPageContent({
const handleEditControl = () => { const handleEditControl = () => {
navigate( navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${controlId}/update`, `/organizations/${organizationId}/frameworks/${frameworkId}/controls/${controlId}/update`
); );
}; };
@@ -304,7 +369,7 @@ function ControlOverviewPageContent({
name: evidenceName || file.name, name: evidenceName || file.name,
file: null, file: null,
}, },
connections: [`client:${taskForEvidence.id}`], connections: connectionId ? [connectionId] : [],
}, },
uploadables: { uploadables: {
"input.file": file, "input.file": file,
@@ -331,6 +396,68 @@ function ControlOverviewPageContent({
}); });
}; };
const handleDragOver = (e: DragEvent<HTMLDivElement>, taskId: string) => {
e.preventDefault();
e.stopPropagation();
if (draggedOverTaskId !== taskId) {
setDraggedOverTaskId(taskId);
}
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setDraggedOverTaskId(null);
};
const handleDrop = async (e: DragEvent<HTMLDivElement>, taskId: string) => {
e.preventDefault();
e.stopPropagation();
setDraggedOverTaskId(null);
setIsDraggingFile(false);
const files = e.dataTransfer.files;
if (files.length === 0) return;
const file = files[0];
setUploadingTaskId(taskId);
// Show toast for upload started
toast({
title: "Upload started",
description: `Uploading ${file.name}...`,
});
uploadEvidence({
variables: {
input: {
taskId: taskId,
name: file.name,
file: null,
},
connections: connectionId ? [connectionId] : [],
},
uploadables: {
"input.file": file,
},
onCompleted: () => {
setUploadingTaskId(null);
toast({
title: "Evidence uploaded",
description: "Evidence has been uploaded successfully.",
});
},
onError: (error) => {
setUploadingTaskId(null);
toast({
title: "Error uploading evidence",
description: error.message,
variant: "destructive",
});
},
});
};
return ( return (
<> <>
<Helmet> <Helmet>
@@ -384,7 +511,15 @@ function ControlOverviewPageContent({
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Tasks</h2> <h2 className="text-xl font-semibold">Tasks</h2>
<Dialog open={isCreateTaskOpen} onOpenChange={setIsCreateTaskOpen}> <div className="flex items-center gap-2">
<div className="text-sm text-gray-500 flex items-center">
<FileIcon className="w-4 h-4 mr-1 text-gray-400" />
<span>Drag & drop files onto tasks to upload evidence</span>
</div>
<Dialog
open={isCreateTaskOpen}
onOpenChange={setIsCreateTaskOpen}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm" className="flex items-center gap-1"> <Button size="sm" className="flex items-center gap-1">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@@ -395,8 +530,8 @@ function ControlOverviewPageContent({
<DialogHeader> <DialogHeader>
<DialogTitle>Create New Task</DialogTitle> <DialogTitle>Create New Task</DialogTitle>
<DialogDescription> <DialogDescription>
Add a new task to this control. Click save when you&apos;re Add a new task to this control. Click save when
done. you&apos;re done.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
@@ -438,18 +573,59 @@ function ControlOverviewPageContent({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
<div className="space-y-2"> </div>
<div className={`space-y-2 ${isDraggingFile ? "space-y-4" : ""}`}>
{tasks.map((task) => ( {tasks.map((task) => (
<div <div
key={task?.id} key={task?.id}
className="flex items-center gap-3 py-4 px-2 hover:bg-gray-50 group" className={`flex items-center gap-3 py-4 px-2 hover:bg-gray-50 group relative transition-all duration-200 ${
isDraggingFile && draggedOverTaskId !== task?.id
? "border border-dashed border-blue-300 rounded-md bg-blue-50 bg-opacity-30"
: ""
} ${
draggedOverTaskId === task?.id
? "bg-blue-50 border-2 border-blue-400 shadow-md rounded-md"
: ""
}`}
onDragOver={(e) => task?.id && handleDragOver(e, task.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => task?.id && handleDrop(e, task.id)}
> >
{isDraggingFile && draggedOverTaskId !== task?.id && (
<div className="absolute inset-0 flex items-center justify-center rounded-md z-10">
<div className="flex items-center gap-2 text-blue-600 bg-white px-3 py-1.5 rounded-lg shadow-sm">
<FileIcon className="w-4 h-4" />
<p className="text-sm font-medium">Drop file here</p>
</div>
</div>
)}
{draggedOverTaskId === task?.id && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-50 bg-opacity-80 rounded-md z-10 backdrop-blur-[1px]">
<div className="flex flex-col items-center gap-2 text-blue-600 bg-white p-4 rounded-lg shadow-sm">
<FileIcon className="w-12 h-12" />
<p className="font-medium">
Drop file to upload evidence
</p>
</div>
</div>
)}
{uploadingTaskId === task?.id && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90 rounded-md z-10">
<div className="flex flex-col items-center gap-2 text-blue-600">
<Loader2 className="w-8 h-8 animate-spin" />
<p className="font-medium">Uploading evidence...</p>
</div>
</div>
)}
<div <div
className={`w-5 h-5 rounded border flex items-center justify-center cursor-pointer ${ className={`w-5 h-5 rounded border flex items-center justify-center cursor-pointer ${
task?.state === "DONE" task?.state === "DONE"
? "border-gray-400 bg-gray-100" ? "border-gray-400 bg-gray-100"
: "border-gray-300" : "border-gray-300"
}`} } ${isDraggingFile ? "opacity-50" : ""}`}
onClick={() => onClick={() =>
task?.id && task?.id &&
task?.state && task?.state &&
@@ -461,7 +637,9 @@ function ControlOverviewPageContent({
)} )}
</div> </div>
<div <div
className="flex-1 flex items-center justify-between cursor-pointer" className={`flex-1 flex items-center justify-between cursor-pointer ${
isDraggingFile ? "opacity-50" : ""
}`}
onClick={() => onClick={() =>
task?.id && task?.id &&
task?.state && task?.state &&
@@ -629,7 +807,7 @@ function ControlOverviewPageFallback() {
export default function ControlOverviewPage() { export default function ControlOverviewPage() {
const { controlId } = useParams(); const { controlId } = useParams();
const [queryRef, loadQuery] = useQueryLoader<ControlOverviewPageQueryType>( const [queryRef, loadQuery] = useQueryLoader<ControlOverviewPageQueryType>(
controlOverviewPageQuery, controlOverviewPageQuery
); );
useEffect(() => { useEffect(() => {

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<91fa70daabd409bc43d5e25a2fa98a13>> * @generated SignedSource<<297a88bb87d59c4a4ed2f8dce93667c6>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -22,7 +22,6 @@ export type ControlOverviewPageQuery$data = {
readonly name?: string; readonly name?: string;
readonly state?: ControlState; readonly state?: ControlState;
readonly tasks?: { readonly tasks?: {
readonly __id: string;
readonly edges: ReadonlyArray<{ readonly edges: ReadonlyArray<{
readonly node: { readonly node: {
readonly description: string; readonly description: string;
@@ -155,18 +154,6 @@ v8 = [
} }
], ],
"storageKey": null "storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
} }
], ],
v9 = [ v9 = [
@@ -294,6 +281,6 @@ return {
}; };
})(); })();
(node as any).hash = "dfdddd9d98741d1e2bfabf4d4430756f"; (node as any).hash = "dc8c7670eafe558966932c3fb4d45cd8";
export default node; export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<f1cdd20d14ca9fefe868019e5aa43741>> * @generated SignedSource<<09af19b5a7657e04606a42962db9db2c>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -23,7 +23,7 @@ export type ControlOverviewPageUploadEvidenceMutation$data = {
readonly uploadEvidence: { readonly uploadEvidence: {
readonly evidenceEdge: { readonly evidenceEdge: {
readonly node: { readonly node: {
readonly createdAt: string; readonly createdAt: any;
readonly fileUrl: string; readonly fileUrl: string;
readonly id: string; readonly id: string;
readonly mimeType: string; readonly mimeType: string;