From 3b69e894fb0bbdc562fae9d7812f3f4e3f4aa8ea Mon Sep 17 00:00:00 2001 From: gearnode Date: Sat, 1 Mar 2025 18:08:38 +0100 Subject: [PATCH] Add drag and drop support Signed-off-by: gearnode --- .../console/src/pages/ControlOverviewPage.tsx | 312 ++++++++++++++---- .../ControlOverviewPageQuery.graphql.ts | 17 +- ...rviewPageUploadEvidenceMutation.graphql.ts | 4 +- 3 files changed, 249 insertions(+), 84 deletions(-) diff --git a/apps/console/src/pages/ControlOverviewPage.tsx b/apps/console/src/pages/ControlOverviewPage.tsx index d718d80e9..5b517dfea 100644 --- a/apps/console/src/pages/ControlOverviewPage.tsx +++ b/apps/console/src/pages/ControlOverviewPage.tsx @@ -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 { graphql, @@ -7,7 +14,14 @@ import { useQueryLoader, useMutation, } 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 { useToast } from "@/hooks/use-toast"; 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 { ControlOverviewPageDeleteTaskMutation as ControlOverviewPageDeleteTaskMutationType } from "./__generated__/ControlOverviewPageDeleteTaskMutation.graphql"; import type { ControlOverviewPageUploadEvidenceMutation as ControlOverviewPageUploadEvidenceMutationType } from "./__generated__/ControlOverviewPageUploadEvidenceMutation.graphql"; + const controlOverviewPageQuery = graphql` query ControlOverviewPageQuery($controlId: ID!) { control: node(id: $controlId) { @@ -39,7 +54,6 @@ const controlOverviewPageQuery = graphql` state category tasks(first: 100) @connection(key: "ControlOverviewPage_tasks") { - __id edges { node { id @@ -123,14 +137,14 @@ function ControlOverviewPageContent({ }) { const data = usePreloadedQuery( controlOverviewPageQuery, - queryRef, + queryRef ); const { toast } = useToast(); const { organizationId, frameworkId, controlId } = useParams(); const navigate = useNavigate(); const [updateTaskState] = useMutation( - updateTaskStateMutation, + updateTaskStateMutation ); const [createTask] = useMutation(createTaskMutation); @@ -138,7 +152,7 @@ function ControlOverviewPageContent({ useMutation(deleteTaskMutation); const [uploadEvidence] = useMutation( - uploadEvidenceMutation, + uploadEvidenceMutation ); const control = data.control; const tasks = control?.tasks?.edges.map((edge) => edge?.node) ?? []; @@ -161,6 +175,57 @@ function ControlOverviewPageContent({ const [evidenceName, setEvidenceName] = useState(""); const fileInputRef = useRef(null); + const [draggedOverTaskId, setDraggedOverTaskId] = useState( + null + ); + const [uploadingTaskId, setUploadingTaskId] = useState(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 newState = currentState === "DONE" ? "TODO" : "DONE"; @@ -220,7 +285,7 @@ function ControlOverviewPageContent({ createTask({ variables: { - connections: [`${data.control?.tasks?.__id}`], + connections: [], input: { controlId: control.id, name: newTaskName, @@ -256,7 +321,7 @@ function ControlOverviewPageContent({ deleteTask({ variables: { - connections: [`${data.control?.tasks?.__id}`], + connections: connectionId ? [connectionId] : [], input: { taskId: taskToDelete.id, }, @@ -281,7 +346,7 @@ function ControlOverviewPageContent({ const handleEditControl = () => { 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, file: null, }, - connections: [`client:${taskForEvidence.id}`], + connections: connectionId ? [connectionId] : [], }, uploadables: { "input.file": file, @@ -331,6 +396,68 @@ function ControlOverviewPageContent({ }); }; + const handleDragOver = (e: DragEvent, taskId: string) => { + e.preventDefault(); + e.stopPropagation(); + if (draggedOverTaskId !== taskId) { + setDraggedOverTaskId(taskId); + } + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDraggedOverTaskId(null); + }; + + const handleDrop = async (e: DragEvent, 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 ( <> @@ -384,72 +511,121 @@ function ControlOverviewPageContent({

Tasks

- - - - - - - Create New Task - - Add a new task to this control. Click save when you're - done. - - -
-
- - setNewTaskName(e.target.value)} - placeholder="Enter task name" - /> -
-
- - setNewTaskDescription(e.target.value)} - placeholder="Enter task description" - /> -
-
- - - - -
-
+ + + + Create New Task + + Add a new task to this control. Click save when + you're done. + + +
+
+ + setNewTaskName(e.target.value)} + placeholder="Enter task name" + /> +
+
+ + setNewTaskDescription(e.target.value)} + placeholder="Enter task description" + /> +
+
+ + + + +
+ +
-
+
{tasks.map((task) => (
task?.id && handleDragOver(e, task.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => task?.id && handleDrop(e, task.id)} > + {isDraggingFile && draggedOverTaskId !== task?.id && ( +
+
+ +

Drop file here

+
+
+ )} + + {draggedOverTaskId === task?.id && ( +
+
+ +

+ Drop file to upload evidence +

+
+
+ )} + + {uploadingTaskId === task?.id && ( +
+
+ +

Uploading evidence...

+
+
+ )} +
task?.id && task?.state && @@ -461,7 +637,9 @@ function ControlOverviewPageContent({ )}
task?.id && task?.state && @@ -629,7 +807,7 @@ function ControlOverviewPageFallback() { export default function ControlOverviewPage() { const { controlId } = useParams(); const [queryRef, loadQuery] = useQueryLoader( - controlOverviewPageQuery, + controlOverviewPageQuery ); useEffect(() => { diff --git a/apps/console/src/pages/__generated__/ControlOverviewPageQuery.graphql.ts b/apps/console/src/pages/__generated__/ControlOverviewPageQuery.graphql.ts index 8a38dde43..a991efb10 100644 --- a/apps/console/src/pages/__generated__/ControlOverviewPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/ControlOverviewPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<91fa70daabd409bc43d5e25a2fa98a13>> + * @generated SignedSource<<297a88bb87d59c4a4ed2f8dce93667c6>> * @lightSyntaxTransform * @nogrep */ @@ -22,7 +22,6 @@ export type ControlOverviewPageQuery$data = { readonly name?: string; readonly state?: ControlState; readonly tasks?: { - readonly __id: string; readonly edges: ReadonlyArray<{ readonly node: { readonly description: string; @@ -155,18 +154,6 @@ v8 = [ } ], "storageKey": null - }, - { - "kind": "ClientExtension", - "selections": [ - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "__id", - "storageKey": null - } - ] } ], v9 = [ @@ -294,6 +281,6 @@ return { }; })(); -(node as any).hash = "dfdddd9d98741d1e2bfabf4d4430756f"; +(node as any).hash = "dc8c7670eafe558966932c3fb4d45cd8"; export default node; diff --git a/apps/console/src/pages/__generated__/ControlOverviewPageUploadEvidenceMutation.graphql.ts b/apps/console/src/pages/__generated__/ControlOverviewPageUploadEvidenceMutation.graphql.ts index a7649a7b6..7736b59e0 100644 --- a/apps/console/src/pages/__generated__/ControlOverviewPageUploadEvidenceMutation.graphql.ts +++ b/apps/console/src/pages/__generated__/ControlOverviewPageUploadEvidenceMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<09af19b5a7657e04606a42962db9db2c>> * @lightSyntaxTransform * @nogrep */ @@ -23,7 +23,7 @@ export type ControlOverviewPageUploadEvidenceMutation$data = { readonly uploadEvidence: { readonly evidenceEdge: { readonly node: { - readonly createdAt: string; + readonly createdAt: any; readonly fileUrl: string; readonly id: string; readonly mimeType: string;