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:
@@ -29,6 +29,7 @@
|
|||||||
"minisearch": "^7.1.2",
|
"minisearch": "^7.1.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-error-boundary": "^6.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
"react-pdf": "^10.3.0",
|
"react-pdf": "^10.3.0",
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ import {
|
|||||||
DropdownItem,
|
DropdownItem,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
|
IconUpload,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Tbody,
|
Tbody,
|
||||||
Td,
|
Td,
|
||||||
Th,
|
Th,
|
||||||
Thead,
|
Thead,
|
||||||
Tr,
|
Tr,
|
||||||
|
useDialogRef,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
import {
|
import {
|
||||||
graphql,
|
graphql,
|
||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
@@ -104,15 +108,50 @@ export default function AuditsPage(props: Props) {
|
|||||||
audit => audit.canDelete || audit.canUpdate,
|
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 (
|
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
|
<PageHeader
|
||||||
title={__("Audits")}
|
title={__("Audits")}
|
||||||
description={__(
|
description={__(
|
||||||
"Manage your organization's compliance audits and their progress.",
|
"Manage your organization's compliance audits and their progress.",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{data.node.canCreateAudit && (
|
{canCreateAudit && (
|
||||||
<CreateAuditDialog
|
<CreateAuditDialog
|
||||||
connection={connectionId}
|
connection={connectionId}
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
@@ -144,6 +183,15 @@ export default function AuditsPage(props: Props) {
|
|||||||
))}
|
))}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
</SortableTable>
|
</SortableTable>
|
||||||
|
{canCreateAudit && (
|
||||||
|
<CreateAuditDialog
|
||||||
|
ref={dropDialogRef}
|
||||||
|
connection={connectionId}
|
||||||
|
organizationId={organizationId}
|
||||||
|
file={droppedFile}
|
||||||
|
onClose={handleDropDialogClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import {
|
|||||||
formatError,
|
formatError,
|
||||||
getAuditStateLabel,
|
getAuditStateLabel,
|
||||||
type GraphQLError,
|
type GraphQLError,
|
||||||
|
promisifyMutation,
|
||||||
} from "@probo/helpers";
|
} from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
|
type DialogRef,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
Field,
|
Field,
|
||||||
|
IconUpload,
|
||||||
Input,
|
Input,
|
||||||
Option,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
@@ -21,13 +24,16 @@ import {
|
|||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { type Control, Controller } from "react-hook-form";
|
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 { graphql } from "relay-runtime";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { CreateAuditDialogFrameworksQuery } from "#/__generated__/core/CreateAuditDialogFrameworksQuery.graphql";
|
import type { CreateAuditDialogFrameworksQuery } from "#/__generated__/core/CreateAuditDialogFrameworksQuery.graphql";
|
||||||
import { ControlledField } from "#/components/form/ControlledField";
|
import { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { useCreateAudit } from "#/hooks/graph/AuditGraph";
|
import {
|
||||||
|
useCreateAudit,
|
||||||
|
uploadAuditReportMutation,
|
||||||
|
} from "#/hooks/graph/AuditGraph";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
|
|
||||||
const frameworksQuery = graphql`
|
const frameworksQuery = graphql`
|
||||||
@@ -62,15 +68,21 @@ const schema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
connection: string;
|
connection: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
file?: File | null;
|
||||||
|
ref?: DialogRef;
|
||||||
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CreateAuditDialog({
|
export function CreateAuditDialog({
|
||||||
children,
|
children,
|
||||||
connection,
|
connection,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
file,
|
||||||
|
ref: externalRef,
|
||||||
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -84,12 +96,15 @@ export function CreateAuditDialog({
|
|||||||
state: "NOT_STARTED",
|
state: "NOT_STARTED",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const ref = useDialogRef();
|
const internalRef = useDialogRef();
|
||||||
|
const ref = externalRef ?? internalRef;
|
||||||
const createAudit = useCreateAudit(connection);
|
const createAudit = useCreateAudit(connection);
|
||||||
|
// eslint-disable-next-line relay/generated-typescript-types
|
||||||
|
const [uploadMutate] = useMutation(uploadAuditReportMutation);
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||||
try {
|
try {
|
||||||
await createAudit({
|
const response = await createAudit({
|
||||||
organizationId,
|
organizationId,
|
||||||
frameworkId: data.frameworkId,
|
frameworkId: data.frameworkId,
|
||||||
name: data.name || null,
|
name: data.name || null,
|
||||||
@@ -97,13 +112,46 @@ export function CreateAuditDialog({
|
|||||||
validUntil: formatDatetime(data.validUntil),
|
validUntil: formatDatetime(data.validUntil),
|
||||||
state: data.state,
|
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();
|
ref.current?.close();
|
||||||
reset();
|
reset();
|
||||||
toast({
|
onClose?.();
|
||||||
title: __("Success"),
|
|
||||||
description: __("Audit created successfully"),
|
|
||||||
variant: "success",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: __("Error"),
|
title: __("Error"),
|
||||||
@@ -116,14 +164,33 @@ export function CreateAuditDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
ref={ref}
|
ref={ref}
|
||||||
trigger={children}
|
trigger={children}
|
||||||
title={<Breadcrumb items={[__("Audits"), __("New Audit")]} />}
|
title={<Breadcrumb items={[__("Audits"), __("New Audit")]} />}
|
||||||
|
onClose={handleClose}
|
||||||
>
|
>
|
||||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)} className="space-y-4">
|
<form onSubmit={e => void handleSubmit(onSubmit)(e)} className="space-y-4">
|
||||||
<DialogContent padded 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")}>
|
<Field label={__("Framework")}>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -40,6 +40,7 @@
|
|||||||
"minisearch": "^7.1.2",
|
"minisearch": "^7.1.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-error-boundary": "^6.0.0",
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
"react-pdf": "^10.3.0",
|
"react-pdf": "^10.3.0",
|
||||||
@@ -14950,6 +14951,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user