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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user