Add PDF dropzone to audit list for streamlined report upload

Enable users to drag-and-drop PDF reports onto the audit list page, which automatically opens the create-audit dialog with the file attached. The dialog chains createAudit → uploadAuditReport mutations, with graceful handling for partial failures (audit created but upload failed).

Changes:
- AuditsPage: Add dropzone with visual overlay (dashed border + icon) when dragging PDFs
- CreateAuditDialog: Accept optional file prop, show file info, chain mutations on submit
- Extract audit ID from createAudit response to pass to uploadAuditReport
- Handle upload failure with warning toast, allowing manual upload from audit detail page
- Add react-dropzone dependency to console app

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-18 17:10:29 +01:00
parent 1e5f4012f0
commit b4a6618736
4 changed files with 130 additions and 12 deletions

View File

@@ -29,6 +29,7 @@
"minisearch": "^7.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.4",
"react-pdf": "^10.3.0",

View File

@@ -12,13 +12,17 @@ import {
DropdownItem,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
Tbody,
Td,
Th,
Thead,
Tr,
useDialogRef,
} from "@probo/ui";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import {
graphql,
type PreloadedQuery,
@@ -104,15 +108,50 @@ export default function AuditsPage(props: Props) {
audit => audit.canDelete || audit.canUpdate,
);
const canCreateAudit = data.node.canCreateAudit;
const [droppedFile, setDroppedFile] = useState<File | null>(null);
const dropDialogRef = useDialogRef();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (!canCreateAudit || acceptedFiles.length === 0) return;
setDroppedFile(acceptedFiles[0]);
dropDialogRef.current?.open();
},
[canCreateAudit, dropDialogRef],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
noClick: true,
noKeyboard: true,
accept: { "application/pdf": [".pdf"] },
multiple: false,
onDrop,
disabled: !canCreateAudit,
});
const handleDropDialogClose = () => {
setDroppedFile(null);
};
return (
<div className="space-y-6">
<div {...getRootProps()} className="relative space-y-6">
<input {...getInputProps()} />
{isDragActive && canCreateAudit && (
<div className="border-primary bg-primary/5 pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center rounded-xl border-2 border-dashed">
<IconUpload className="text-primary mb-2 size-8" />
<p className="text-primary text-sm font-medium">
{__("Drop a PDF to create an audit with a report")}
</p>
</div>
)}
<PageHeader
title={__("Audits")}
description={__(
"Manage your organization's compliance audits and their progress.",
)}
>
{data.node.canCreateAudit && (
{canCreateAudit && (
<CreateAuditDialog
connection={connectionId}
organizationId={organizationId}
@@ -144,6 +183,15 @@ export default function AuditsPage(props: Props) {
))}
</Tbody>
</SortableTable>
{canCreateAudit && (
<CreateAuditDialog
ref={dropDialogRef}
connection={connectionId}
organizationId={organizationId}
file={droppedFile}
onClose={handleDropDialogClose}
/>
)}
</div>
);
}

View File

@@ -4,15 +4,18 @@ import {
formatError,
getAuditStateLabel,
type GraphQLError,
promisifyMutation,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
type DialogRef,
Dialog,
DialogContent,
DialogFooter,
Field,
IconUpload,
Input,
Option,
Select,
@@ -21,13 +24,16 @@ import {
} from "@probo/ui";
import { Suspense } from "react";
import { type Control, Controller } from "react-hook-form";
import { useLazyLoadQuery } from "react-relay";
import { useLazyLoadQuery, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { CreateAuditDialogFrameworksQuery } from "#/__generated__/core/CreateAuditDialogFrameworksQuery.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { useCreateAudit } from "#/hooks/graph/AuditGraph";
import {
useCreateAudit,
uploadAuditReportMutation,
} from "#/hooks/graph/AuditGraph";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const frameworksQuery = graphql`
@@ -62,15 +68,21 @@ const schema = z.object({
});
type Props = {
children: React.ReactNode;
children?: React.ReactNode;
connection: string;
organizationId: string;
file?: File | null;
ref?: DialogRef;
onClose?: () => void;
};
export function CreateAuditDialog({
children,
connection,
organizationId,
file,
ref: externalRef,
onClose,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
@@ -84,12 +96,15 @@ export function CreateAuditDialog({
state: "NOT_STARTED",
},
});
const ref = useDialogRef();
const internalRef = useDialogRef();
const ref = externalRef ?? internalRef;
const createAudit = useCreateAudit(connection);
// eslint-disable-next-line relay/generated-typescript-types
const [uploadMutate] = useMutation(uploadAuditReportMutation);
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
await createAudit({
const response = await createAudit({
organizationId,
frameworkId: data.frameworkId,
name: data.name || null,
@@ -97,13 +112,46 @@ export function CreateAuditDialog({
validUntil: formatDatetime(data.validUntil),
state: data.state,
});
const auditId = (response as { createAudit: { auditEdge: { node: { id: string } } } })
.createAudit.auditEdge.node.id;
if (file && auditId) {
try {
await promisifyMutation(uploadMutate)({
variables: {
input: {
auditId,
file: null,
},
},
uploadables: {
"input.file": file,
},
});
toast({
title: __("Success"),
description: __("Audit created and report uploaded successfully"),
variant: "success",
});
} catch {
toast({
title: __("Warning"),
description: __("Audit created but report upload failed. You can upload the report from the audit detail page."),
variant: "warning",
});
}
} else {
toast({
title: __("Success"),
description: __("Audit created successfully"),
variant: "success",
});
}
ref.current?.close();
reset();
toast({
title: __("Success"),
description: __("Audit created successfully"),
variant: "success",
});
onClose?.();
} catch (error) {
toast({
title: __("Error"),
@@ -116,14 +164,33 @@ export function CreateAuditDialog({
}
};
const handleClose = () => {
onClose?.();
};
return (
<Dialog
ref={ref}
trigger={children}
title={<Breadcrumb items={[__("Audits"), __("New Audit")]} />}
onClose={handleClose}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)} className="space-y-4">
<DialogContent padded className="space-y-4">
{file && (
<div className="flex items-center gap-3 rounded-lg border border-border-low bg-level-1 p-3">
<IconUpload className="text-txt-secondary size-5 shrink-0" />
<div className="min-w-0">
<p className="text-txt-primary truncate text-sm font-medium">
{file.name}
</p>
<p className="text-txt-tertiary text-xs">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
)}
<Field label={__("Framework")}>
<Suspense
fallback={

2
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"minisearch": "^7.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.4",
"react-pdf": "^10.3.0",
@@ -14950,6 +14951,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}