Add evidence preview url

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Jonathan
2025-06-17 17:07:32 +02:00
committed by Bryan Frimin
parent d3ff7cc6ac
commit ab54d13b87
8 changed files with 207 additions and 64 deletions

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<7e91345490d4b70ef6668bb6504be1e3>> * @generated SignedSource<<286a7d808d7a9c793cd9e9b6830cb40b>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -598,6 +598,20 @@ return {
"name": "filename", "name": "filename",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "mimeType",
"storageKey": null
},
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -619,20 +633,6 @@ return {
"name": "createdAt", "name": "createdAt",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "mimeType",
"storageKey": null
},
(v13/*: any*/) (v13/*: any*/)
], ],
"storageKey": null "storageKey": null
@@ -667,12 +667,12 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "4a923b265801da22c605af53e744d543", "cacheID": "c36bf27c7f4d6b3ef4a3e063e4bfccea",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "MeasureGraphNodeQuery", "name": "MeasureGraphNodeQuery",
"operationKind": "query", "operationKind": "query",
"text": "query MeasureGraphNodeQuery(\n $measureId: ID!\n) {\n node(id: $measureId) {\n __typename\n ... on Measure {\n id\n name\n description\n state\n category\n evidencesInfos: evidences(first: 0) {\n totalCount\n }\n risksInfos: risks(first: 0) {\n totalCount\n }\n tasksInfos: tasks(first: 0) {\n totalCount\n }\n controlsInfos: controls(first: 0) {\n totalCount\n }\n ...MeasureRisksTabFragment\n ...MeasureTasksTabFragment\n ...MeasureControlsTabFragment\n ...MeasureFormDialogMeasureFragment\n ...MeasureEvidencesTabFragment\n }\n id\n }\n}\n\nfragment LinkedControlsCardFragment on Control {\n id\n name\n sectionTitle\n framework {\n id\n name\n }\n}\n\nfragment LinkedRisksCardFragment on Risk {\n id\n name\n inherentRiskScore\n residualRiskScore\n}\n\nfragment MeasureControlsTabFragment on Measure {\n id\n controls(first: 20) {\n edges {\n node {\n id\n ...LinkedControlsCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment on Measure {\n id\n evidences(first: 50) {\n edges {\n node {\n id\n ...MeasureEvidencesTabFragment_evidence\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment_evidence on Evidence {\n id\n filename\n size\n type\n createdAt\n fileUrl\n mimeType\n}\n\nfragment MeasureFormDialogMeasureFragment on Measure {\n id\n description\n name\n category\n state\n}\n\nfragment MeasureRisksTabFragment on Measure {\n id\n risks(first: 100) {\n edges {\n node {\n id\n ...LinkedRisksCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment MeasureTasksTabFragment on Measure {\n tasks(first: 100) {\n edges {\n node {\n id\n name\n state\n description\n ...TaskFormDialogFragment\n assignedTo {\n id\n fullName\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment TaskFormDialogFragment on Task {\n id\n description\n name\n state\n timeEstimate\n deadline\n assignedTo {\n id\n }\n measure {\n id\n }\n}\n" "text": "query MeasureGraphNodeQuery(\n $measureId: ID!\n) {\n node(id: $measureId) {\n __typename\n ... on Measure {\n id\n name\n description\n state\n category\n evidencesInfos: evidences(first: 0) {\n totalCount\n }\n risksInfos: risks(first: 0) {\n totalCount\n }\n tasksInfos: tasks(first: 0) {\n totalCount\n }\n controlsInfos: controls(first: 0) {\n totalCount\n }\n ...MeasureRisksTabFragment\n ...MeasureTasksTabFragment\n ...MeasureControlsTabFragment\n ...MeasureFormDialogMeasureFragment\n ...MeasureEvidencesTabFragment\n }\n id\n }\n}\n\nfragment LinkedControlsCardFragment on Control {\n id\n name\n sectionTitle\n framework {\n id\n name\n }\n}\n\nfragment LinkedRisksCardFragment on Risk {\n id\n name\n inherentRiskScore\n residualRiskScore\n}\n\nfragment MeasureControlsTabFragment on Measure {\n id\n controls(first: 20) {\n edges {\n node {\n id\n ...LinkedControlsCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment on Measure {\n id\n evidences(first: 50) {\n edges {\n node {\n id\n filename\n fileUrl\n mimeType\n ...MeasureEvidencesTabFragment_evidence\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment_evidence on Evidence {\n id\n filename\n size\n type\n createdAt\n fileUrl\n mimeType\n}\n\nfragment MeasureFormDialogMeasureFragment on Measure {\n id\n description\n name\n category\n state\n}\n\nfragment MeasureRisksTabFragment on Measure {\n id\n risks(first: 100) {\n edges {\n node {\n id\n ...LinkedRisksCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment MeasureTasksTabFragment on Measure {\n tasks(first: 100) {\n edges {\n node {\n id\n name\n state\n description\n ...TaskFormDialogFragment\n assignedTo {\n id\n fullName\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment TaskFormDialogFragment on Task {\n id\n description\n name\n state\n timeEstimate\n deadline\n assignedTo {\n id\n }\n measure {\n id\n }\n}\n"
} }
}; };
})(); })();

View File

@@ -0,0 +1,77 @@
import {
Button,
Dialog,
DialogContent,
IconArrowInbox,
IconFilter,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
type Props = {
evidence: {
filename: string;
fileUrl?: string | null;
mimeType: string;
};
onClose: () => void;
};
export function EvidencePreviewDialog({ evidence, onClose }: Props) {
const { __ } = useTranslate();
return (
<Dialog defaultOpen title={evidence.filename} onClose={onClose}>
<DialogContent padded>
<p>
{__(
"Preview of the evidence file. You can view or download the file from here."
)}
</p>
<EvidencePreviewContent evidence={evidence} />
</DialogContent>
</Dialog>
);
}
function EvidencePreviewContent({ evidence }: Pick<Props, "evidence">) {
const { __ } = useTranslate();
if (!evidence.fileUrl) {
return null;
}
if (evidence.mimeType.startsWith("image/")) {
return (
<img
src={evidence.fileUrl}
alt={evidence.filename}
className="max-h-[70vh] object-contain"
/>
);
}
if (evidence.mimeType.includes("pdf")) {
return (
<iframe
src={evidence.fileUrl}
className="w-full h-[70vh]"
title={evidence.filename}
/>
);
}
return (
<div className="flex flex-col items-center gap-4 justify-center">
<IconFilter size={20} />
<p className="text-secondary text-center">
{__("Preview not availeeable for this file type") +
" " +
evidence.mimeType}
</p>
<Button asChild variant="secondary" icon={IconArrowInbox}>
<a href={evidence.fileUrl} target="_blank">
{__("Download File")}
</a>
</Button>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import { useOutletContext } from "react-router"; import { useNavigate, useOutletContext, useParams } from "react-router";
import type { MeasureEvidencesTabFragment$key } from "./__generated__/MeasureEvidencesTabFragment.graphql"; import type { MeasureEvidencesTabFragment$key } from "./__generated__/MeasureEvidencesTabFragment.graphql";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { usePageTitle } from "@probo/hooks"; import { usePageTitle } from "@probo/hooks";
import { import {
Button, ActionDropdown,
DropdownItem,
Dropzone, Dropzone,
IconArrowInbox, IconArrowInbox,
IconTrashCan, IconTrashCan,
@@ -18,8 +19,9 @@ import { graphql } from "relay-runtime";
import { useFragment, useMutation, useRefetchableFragment } from "react-relay"; import { useFragment, useMutation, useRefetchableFragment } from "react-relay";
import { SortableTable } from "/components/SortableTable"; import { SortableTable } from "/components/SortableTable";
import type { MeasureEvidencesTabFragment_evidence$key } from "./__generated__/MeasureEvidencesTabFragment_evidence.graphql"; import type { MeasureEvidencesTabFragment_evidence$key } from "./__generated__/MeasureEvidencesTabFragment_evidence.graphql";
import { fileSize, fileType } from "@probo/helpers"; import { fileSize, fileType, promisifyMutation, sprintf } from "@probo/helpers";
import { promisifyMutation, sprintf } from "@probo/helpers"; import { EvidencePreviewDialog } from "../dialog/EvidencePreviewDialog";
import { useOrganizationId } from "/hooks/useOrganizationId";
export const evidencesFragment = graphql` export const evidencesFragment = graphql`
fragment MeasureEvidencesTabFragment on Measure fragment MeasureEvidencesTabFragment on Measure
@@ -43,6 +45,9 @@ export const evidencesFragment = graphql`
edges { edges {
node { node {
id id
filename
fileUrl
mimeType
...MeasureEvidencesTabFragment_evidence ...MeasureEvidencesTabFragment_evidence
} }
} }
@@ -50,7 +55,7 @@ export const evidencesFragment = graphql`
} }
`; `;
const evidenceFragment = graphql` export const evidenceFragment = graphql`
fragment MeasureEvidencesTabFragment_evidence on Evidence { fragment MeasureEvidencesTabFragment_evidence on Evidence {
id id
filename filename
@@ -93,12 +98,15 @@ export default function MeasureEvidencesTab() {
const { measure } = useOutletContext<{ const { measure } = useOutletContext<{
measure: MeasureEvidencesTabFragment$key & { id: string; name: string }; measure: MeasureEvidencesTabFragment$key & { id: string; name: string };
}>(); }>();
const { evidenceId } = useParams<{ evidenceId: string }>();
const [data, refetch] = useRefetchableFragment(evidencesFragment, measure); const [data, refetch] = useRefetchableFragment(evidencesFragment, measure);
const connectionId = data.evidences.__id; const connectionId = data.evidences.__id;
const evidences = data.evidences?.edges?.map((edge) => edge.node) ?? []; const evidences = data.evidences?.edges?.map((edge) => edge.node) ?? [];
const navigate = useNavigate();
const { __ } = useTranslate(); const { __ } = useTranslate();
const [mutate, isUpdating] = useMutation(uploadEvidenceMutation); const [mutate, isUpdating] = useMutation(uploadEvidenceMutation);
const evidence = evidences.find((e) => e.id === evidenceId);
const organizationId = useOrganizationId();
usePageTitle(measure.name + " - " + __("Evidences")); usePageTitle(measure.name + " - " + __("Evidences"));
@@ -123,7 +131,7 @@ export default function MeasureEvidencesTab() {
<div className="space-y-6"> <div className="space-y-6">
<Dropzone <Dropzone
description={__( description={__(
"Only PDF, DOCX, XLSX, PPTX, JPG, PNG, WEBP files up to 10MB are allowed" "Only PDF, DOCX, XLSX, PPTX, JPG, PNG, WEBP files up to 10MB are allowed",
)} )}
isUploading={isUpdating} isUploading={isUpdating}
onDrop={handleDrop} onDrop={handleDrop}
@@ -157,17 +165,32 @@ export default function MeasureEvidencesTab() {
<EvidenceRow <EvidenceRow
key={evidence.id} key={evidence.id}
evidenceKey={evidence} evidenceKey={evidence}
measureId={measure.id}
organizationId={organizationId}
connectionId={connectionId} connectionId={connectionId}
/> />
))} ))}
</Tbody> </Tbody>
</SortableTable> </SortableTable>
{evidence && (
<EvidencePreviewDialog
key={evidence?.id}
onClose={() =>
navigate(
`/organizations/${organizationId}/measures/${measure.id}/evidences`,
)
}
evidence={evidence}
/>
)}
</div> </div>
); );
} }
function EvidenceRow(props: { function EvidenceRow(props: {
evidenceKey: MeasureEvidencesTabFragment_evidence$key; evidenceKey: MeasureEvidencesTabFragment_evidence$key;
measureId: string;
organizationId: string;
connectionId: string; connectionId: string;
}) { }) {
const evidence = useFragment(evidenceFragment, props.evidenceKey); const evidence = useFragment(evidenceFragment, props.evidenceKey);
@@ -191,34 +214,40 @@ function EvidenceRow(props: {
{ {
message: sprintf( message: sprintf(
__( __(
'This will permanently delete the evidence "%s". This action cannot be undone.' 'This will permanently delete the evidence "%s". This action cannot be undone.',
), ),
evidence.filename evidence.filename,
), ),
} },
); );
}; };
return ( return (
<Tr> <Tr
to={`/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`}
>
<Td>{evidence.filename}</Td> <Td>{evidence.filename}</Td>
<Td>{fileType(__, evidence)}</Td> <Td>{fileType(__, evidence)}</Td>
<Td>{fileSize(__, evidence.size)}</Td> <Td>{fileSize(__, evidence.size)}</Td>
<Td>{dateFormat(evidence.createdAt)}</Td> <Td>{dateFormat(evidence.createdAt)}</Td>
<Td> <Td noLink>
<div className="flex gap-2"> <div className="flex gap-2">
<Button asChild variant="secondary"> <ActionDropdown>
<a href={evidence.fileUrl ?? ""} target="_blank"> <DropdownItem asChild>
<IconArrowInbox size={16} /> <a href={evidence.fileUrl ?? ""} target="_blank">
{__("Download")} <IconArrowInbox size={16} />
</a> {__("Download")}
</Button> </a>
<Button </DropdownItem>
variant="danger" <DropdownItem
icon={IconTrashCan} variant="danger"
onClick={handleDelete} icon={IconTrashCan}
disabled={isDeleting} onClick={handleDelete}
/> disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div> </div>
</Td> </Td>
</Tr> </Tr>

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<70954edefec64fedbc4f8c1ec1c1efab>> * @generated SignedSource<<f1947ff2accbc325c11c88fb343d82b2>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -15,7 +15,10 @@ export type MeasureEvidencesTabFragment$data = {
readonly __id: string; readonly __id: string;
readonly edges: ReadonlyArray<{ readonly edges: ReadonlyArray<{
readonly node: { readonly node: {
readonly fileUrl: string | null | undefined;
readonly filename: string;
readonly id: string; readonly id: string;
readonly mimeType: string;
readonly " $fragmentSpreads": FragmentRefs<"MeasureEvidencesTabFragment_evidence">; readonly " $fragmentSpreads": FragmentRefs<"MeasureEvidencesTabFragment_evidence">;
}; };
}>; }>;
@@ -135,6 +138,27 @@ return {
"plural": false, "plural": false,
"selections": [ "selections": [
(v1/*: any*/), (v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "filename",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "mimeType",
"storageKey": null
},
{ {
"args": null, "args": null,
"kind": "FragmentSpread", "kind": "FragmentSpread",
@@ -220,6 +244,6 @@ return {
}; };
})(); })();
(node as any).hash = "619655a768981a51a00a71887dbcb227"; (node as any).hash = "abae73e44e966689cd837465f93abcb3";
export default node; export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<65fd5218c9365e00e3127dfca328ad10>> * @generated SignedSource<<e2a2d3c5470f8878acedd5e4ecc05300>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -219,6 +219,20 @@ return {
"name": "filename", "name": "filename",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "mimeType",
"storageKey": null
},
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -240,20 +254,6 @@ return {
"name": "createdAt", "name": "createdAt",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "mimeType",
"storageKey": null
},
(v11/*: any*/) (v11/*: any*/)
], ],
"storageKey": null "storageKey": null
@@ -343,16 +343,16 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "28755a6b97ad50d18c25dea1b383d062", "cacheID": "61b9596f07d5d2516f50e2f646eb21a2",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "MeasureEvidencesTabQuery", "name": "MeasureEvidencesTabQuery",
"operationKind": "query", "operationKind": "query",
"text": "query MeasureEvidencesTabQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 50\n $last: Int = null\n $order: EvidenceOrder = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MeasureEvidencesTabFragment_16fISc\n id\n }\n}\n\nfragment MeasureEvidencesTabFragment_16fISc on Measure {\n id\n evidences(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n edges {\n node {\n id\n ...MeasureEvidencesTabFragment_evidence\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment_evidence on Evidence {\n id\n filename\n size\n type\n createdAt\n fileUrl\n mimeType\n}\n" "text": "query MeasureEvidencesTabQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 50\n $last: Int = null\n $order: EvidenceOrder = null\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MeasureEvidencesTabFragment_16fISc\n id\n }\n}\n\nfragment MeasureEvidencesTabFragment_16fISc on Measure {\n id\n evidences(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n edges {\n node {\n id\n filename\n fileUrl\n mimeType\n ...MeasureEvidencesTabFragment_evidence\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment_evidence on Evidence {\n id\n filename\n size\n type\n createdAt\n fileUrl\n mimeType\n}\n"
} }
}; };
})(); })();
(node as any).hash = "619655a768981a51a00a71887dbcb227"; (node as any).hash = "abae73e44e966689cd837465f93abcb3";
export default node; export default node;

View File

@@ -61,7 +61,7 @@ export const measureRoutes = [
), ),
}, },
{ {
path: "evidences", path: "evidences/:evidenceId?",
fallback: LinkCardSkeleton, fallback: LinkCardSkeleton,
Component: lazy( Component: lazy(
() => () =>

View File

@@ -23,5 +23,8 @@
"schema": "./pkg/server/api/console/v1/schema.graphql" "schema": "./pkg/server/api/console/v1/schema.graphql"
}, },
"license": "MIT", "license": "MIT",
"packageManager": "npm@11.1.0" "packageManager": "npm@11.1.0",
"volta": {
"node": "22.16.0"
}
} }

View File

@@ -49,6 +49,7 @@ type Props = {
defaultOpen?: boolean; defaultOpen?: boolean;
className?: string; className?: string;
ref?: DialogRef; ref?: DialogRef;
onClose?: () => void;
}; };
export const useDialogRef = (): DialogRef => { export const useDialogRef = (): DialogRef => {
@@ -62,6 +63,7 @@ export function Dialog({
className, className,
ref, ref,
defaultOpen, defaultOpen,
onClose,
}: Props) { }: Props) {
const { overlay, content, header, title: titleClassname } = dialog(); const { overlay, content, header, title: titleClassname } = dialog();
const [open, setOpen] = useState(!!defaultOpen); const [open, setOpen] = useState(!!defaultOpen);
@@ -76,8 +78,16 @@ export function Dialog({
}, },
}; };
} }
const onOpenChange = (open: boolean) => {
setOpen(open);
if (!open) {
onClose?.();
}
};
return ( return (
<Root open={open} onOpenChange={setOpen}> <Root open={open} onOpenChange={onOpenChange}>
{trigger && <Trigger asChild>{trigger}</Trigger>} {trigger && <Trigger asChild>{trigger}</Trigger>}
<Portal> <Portal>
<Overlay className={overlay()} /> <Overlay className={overlay()} />