Add evidence preview modal

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-01 19:33:55 +01:00
parent 6a5d82f316
commit 29f23e3f77
8 changed files with 417 additions and 37 deletions

View File

@@ -13,6 +13,8 @@ import {
usePreloadedQuery,
useQueryLoader,
useMutation,
fetchQuery,
useRelayEnvironment,
} from "react-relay";
import {
CheckCircle2,
@@ -28,6 +30,7 @@ import {
File as FileGeneric,
FileText,
Image,
X,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
@@ -74,7 +77,6 @@ const controlOverviewPageQuery = graphql`
edges {
node {
id
fileUrl
mimeType
filename
size
@@ -153,6 +155,18 @@ const uploadEvidenceMutation = graphql`
}
`;
// Add a GraphQL query to fetch the fileUrl for an evidence item
const getEvidenceFileUrlQuery = graphql`
query ControlOverviewPageGetEvidenceFileUrlQuery($evidenceId: ID!) {
node(id: $evidenceId) {
... on Evidence {
id
fileUrl
}
}
}
`;
function ControlOverviewPageContent({
queryRef,
}: {
@@ -165,6 +179,7 @@ function ControlOverviewPageContent({
const { toast } = useToast();
const { organizationId, frameworkId, controlId } = useParams();
const navigate = useNavigate();
const environment = useRelayEnvironment();
const [updateTaskState] =
useMutation<ControlOverviewPageUpdateTaskStateMutationType>(
updateTaskStateMutation
@@ -207,6 +222,16 @@ function ControlOverviewPageContent({
string | null
>(null);
// Add state for the preview modal
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
const [previewEvidence, setPreviewEvidence] = useState<{
id: string;
filename: string;
mimeType: string;
fileUrl?: string;
} | null>(null);
const [isLoadingFileUrl, setIsLoadingFileUrl] = useState(false);
const tasks = data.control.tasks?.edges.map((edge) => edge.node) || [];
const getEvidenceConnectionId = useCallback(
@@ -537,6 +562,54 @@ function ControlOverviewPageContent({
}
};
// Simplified preview handler that opens the modal and sets the evidence
const handlePreviewEvidence = (evidence: {
id: string;
filename: string;
mimeType: string;
}) => {
// Just open the modal with the evidence info
setPreviewEvidence({
id: evidence.id,
filename: evidence.filename,
mimeType: evidence.mimeType,
});
setIsPreviewModalOpen(true);
// We'll fetch the fileUrl when the modal opens
setIsLoadingFileUrl(true);
// Use GraphQL query to fetch the fileUrl
fetchQuery(environment, getEvidenceFileUrlQuery, {
evidenceId: evidence.id,
})
.toPromise()
.then((response) => {
// Type assertion for the response
const data = response as { node?: { id: string; fileUrl?: string } };
if (data?.node?.fileUrl) {
setPreviewEvidence((prev) => {
if (!prev) return null;
return { ...prev, fileUrl: data.node!.fileUrl };
});
} else {
throw new Error("File URL not available in response");
}
})
.catch((error) => {
console.error("Error fetching file URL:", error);
toast({
title: "Error fetching file URL",
description: error.message || "Could not load the file preview",
variant: "destructive",
});
})
.finally(() => {
setIsLoadingFileUrl(false);
});
};
return (
<>
<Helmet>
@@ -829,27 +902,38 @@ function ControlOverviewPageContent({
</div>
</div>
<div className="flex items-center gap-2">
{evidence.mimeType.startsWith("image/") && (
<a
href={evidence.fileUrl}
target="_blank"
rel="noopener noreferrer"
{evidence.mimeType.startsWith("image/") ? (
<button
onClick={() =>
handlePreviewEvidence(evidence)
}
className="p-1 rounded-full hover:bg-gray-100"
title="Preview"
title="Preview Image"
>
<Eye className="w-4 h-4 text-gray-600" />
</a>
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handlePreviewEvidence(evidence);
}}
className="p-1 rounded-full hover:bg-gray-100"
title="View File"
>
<Eye className="w-4 h-4 text-gray-600" />
</button>
)}
<a
href={evidence.fileUrl}
target="_blank"
rel="noopener noreferrer"
<button
onClick={(e) => {
e.preventDefault();
handlePreviewEvidence(evidence);
}}
className="p-1 rounded-full hover:bg-gray-100"
title="Download"
download
>
<Download className="w-4 h-4 text-gray-600" />
</a>
</button>
</div>
</div>
);
@@ -942,6 +1026,85 @@ function ControlOverviewPageContent({
</form>
</DialogContent>
</Dialog>
{/* Add the Preview Modal */}
<Dialog open={isPreviewModalOpen} onOpenChange={setIsPreviewModalOpen}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>{previewEvidence?.filename}</span>
<button
onClick={() => setIsPreviewModalOpen(false)}
className="rounded-full p-1 hover:bg-gray-100"
>
<X className="w-5 h-5" />
</button>
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center min-h-[300px] bg-gray-50 rounded-md p-4">
{isLoadingFileUrl ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
<p className="text-gray-500">Loading preview...</p>
</div>
) : previewEvidence?.fileUrl ? (
previewEvidence.mimeType.startsWith("image/") ? (
<img
src={previewEvidence.fileUrl}
alt={previewEvidence.filename}
className="max-h-[70vh] object-contain"
/>
) : previewEvidence.mimeType.includes("pdf") ? (
<iframe
src={previewEvidence.fileUrl}
className="w-full h-[70vh]"
title={previewEvidence.filename}
/>
) : (
<div className="flex flex-col items-center gap-4">
<FileGeneric className="w-16 h-16 text-gray-400" />
<p className="text-gray-600">
Preview not available for this file type
</p>
<Button
onClick={() => {
if (previewEvidence?.fileUrl) {
window.open(previewEvidence.fileUrl, "_blank");
}
}}
>
Download File
</Button>
</div>
)
) : (
<div className="flex flex-col items-center gap-2">
<FileGeneric className="w-12 h-12 text-gray-400" />
<p className="text-gray-500">Failed to load preview</p>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsPreviewModalOpen(false)}
>
Close
</Button>
{previewEvidence?.fileUrl && (
<Button
onClick={() => {
if (previewEvidence?.fileUrl) {
window.open(previewEvidence.fileUrl, "_blank");
}
}}
>
Download
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
);

View File

@@ -0,0 +1,134 @@
/**
* @generated SignedSource<<4cf4f895bdcd936c74cb70f562210df7>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ControlOverviewPageGetEvidenceFileUrlQuery$variables = {
evidenceId: string;
};
export type ControlOverviewPageGetEvidenceFileUrlQuery$data = {
readonly node: {
readonly fileUrl?: string;
readonly id?: string;
};
};
export type ControlOverviewPageGetEvidenceFileUrlQuery = {
response: ControlOverviewPageGetEvidenceFileUrlQuery$data;
variables: ControlOverviewPageGetEvidenceFileUrlQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "evidenceId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "evidenceId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlOverviewPageGetEvidenceFileUrlQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
(v3/*: any*/)
],
"type": "Evidence",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlOverviewPageGetEvidenceFileUrlQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/)
],
"type": "Evidence",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "debb5fda7cec88257a50998c78ee1e33",
"id": null,
"metadata": {},
"name": "ControlOverviewPageGetEvidenceFileUrlQuery",
"operationKind": "query",
"text": "query ControlOverviewPageGetEvidenceFileUrlQuery(\n $evidenceId: ID!\n) {\n node(id: $evidenceId) {\n __typename\n ... on Evidence {\n id\n fileUrl\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "a97d9390a7415ff87916e268458f5ead";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<005ee5181eeb4c0865e7e76dcb6b3aac>>
* @generated SignedSource<<3c761f476fc4dcbc2f175eefa625bb28>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -31,8 +31,7 @@ export type ControlOverviewPageQuery$data = {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly createdAt: string;
readonly fileUrl: string;
readonly createdAt: any;
readonly filename: string;
readonly id: string;
readonly mimeType: string;
@@ -173,13 +172,6 @@ v11 = [
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -421,7 +413,7 @@ return {
]
},
"params": {
"cacheID": "0747630c87fd59b419e5264fcede33bf",
"cacheID": "4364cf9b985fb746dde62c1e94063390",
"id": null,
"metadata": {
"connection": [
@@ -444,11 +436,11 @@ return {
},
"name": "ControlOverviewPageQuery",
"operationKind": "query",
"text": "query ControlOverviewPageQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n name\n description\n state\n category\n tasks(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n evidences(first: 50) {\n edges {\n node {\n id\n fileUrl\n mimeType\n filename\n size\n state\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
"text": "query ControlOverviewPageQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n name\n description\n state\n category\n tasks(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n evidences(first: 50) {\n edges {\n node {\n id\n mimeType\n filename\n size\n state\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "0fc29eb31e33b025168ac8972109f853";
(node as any).hash = "b91e90b278ccedc6b44d3a3c53593e73";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<f1cdd20d14ca9fefe868019e5aa43741>>
* @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;