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;

View File

@@ -320,7 +320,7 @@ type EvidenceEdge {
type Evidence implements Node {
id: ID!
fileUrl: String!
fileUrl: String! @goField(forceResolver: true)
mimeType: String!
size: Int!
state: EvidenceState!

View File

@@ -382,6 +382,8 @@ type ControlResolver interface {
Tasks(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TaskConnection, error)
}
type EvidenceResolver interface {
FileURL(ctx context.Context, obj *types.Evidence) (string, error)
StateTransisions(ctx context.Context, obj *types.Evidence, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.EvidenceStateTransitionConnection, error)
}
type FrameworkResolver interface {
@@ -2188,7 +2190,7 @@ type EvidenceEdge {
type Evidence implements Node {
id: ID!
fileUrl: String!
fileUrl: String! @goField(forceResolver: true)
mimeType: String!
size: Int!
state: EvidenceState!
@@ -5184,7 +5186,7 @@ func (ec *executionContext) _Evidence_fileUrl(ctx context.Context, field graphql
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.FileURL, nil
return ec.resolvers.Evidence().FileURL(rctx, obj)
})
if err != nil {
ec.Error(ctx, err)
@@ -5205,8 +5207,8 @@ func (ec *executionContext) fieldContext_Evidence_fileUrl(_ context.Context, fie
fc = &graphql.FieldContext{
Object: "Evidence",
Field: field,
IsMethod: false,
IsResolver: false,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
@@ -14061,10 +14063,36 @@ func (ec *executionContext) _Evidence(ctx context.Context, sel ast.SelectionSet,
atomic.AddUint32(&out.Invalids, 1)
}
case "fileUrl":
out.Values[i] = ec._Evidence_fileUrl(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
field := field
innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
res = ec._Evidence_fileUrl(ctx, field, obj)
if res == graphql.Null {
atomic.AddUint32(&fs.Invalids, 1)
}
return res
}
if field.Deferrable != nil {
dfs, ok := deferred[field.Deferrable.Label]
di := 0
if ok {
dfs.AddField(field)
di = len(dfs.Values) - 1
} else {
dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
deferred[field.Deferrable.Label] = dfs
}
dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
return innerFunc(ctx, dfs)
})
// don't run the out.Concurrently() call below
out.Values[i] = graphql.Null
continue
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
case "mimeType":
out.Values[i] = ec._Evidence_mimeType(ctx, field, obj)
if out.Values[i] == graphql.Null {

View File

@@ -7,6 +7,7 @@ package console_v1
import (
"context"
"fmt"
"time"
"github.com/getprobo/probo/pkg/api/console/v1/schema"
"github.com/getprobo/probo/pkg/api/console/v1/types"
@@ -41,6 +42,16 @@ func (r *controlResolver) Tasks(ctx context.Context, obj *types.Control, first *
return types.NewTaskConnection(page), nil
}
// FileURL is the resolver for the fileUrl field.
func (r *evidenceResolver) FileURL(ctx context.Context, obj *types.Evidence) (string, error) {
fileURL, err := r.proboSvc.GetEvidenceFileURL(ctx, obj.ID, 15*time.Minute)
if err != nil {
return "", fmt.Errorf("cannot generate file URL: %w", err)
}
return *fileURL, nil
}
// StateTransisions is the resolver for the stateTransisions field.
func (r *evidenceResolver) StateTransisions(ctx context.Context, obj *types.Evidence, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.EvidenceStateTransitionConnection, error) {
cursor := types.NewCursor(first, after, last, before)

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package probo
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/getprobo/probo/pkg/gid"
)
func (s Service) GetEvidenceFileURL(
ctx context.Context,
evidenceID gid.GID,
expiresIn time.Duration,
) (*string, error) {
evidence, err := s.GetEvidence(ctx, evidenceID)
if err != nil {
return nil, fmt.Errorf("cannot get evidence: %w", err)
}
presignClient := s3.NewPresignClient(s.s3)
presignedReq, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(evidence.ObjectKey),
ResponseContentType: aws.String(evidence.MimeType),
ResponseContentDisposition: aws.String(fmt.Sprintf("attachment; filename=\"%s\"", evidence.Filename)),
}, func(opts *s3.PresignOptions) {
opts.Expires = expiresIn
})
if err != nil {
return nil, fmt.Errorf("cannot presign GetObject request: %w", err)
}
return &presignedReq.URL, nil
}