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
* @nogrep
*/
@@ -598,6 +598,20 @@ return {
"name": "filename",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "mimeType",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -619,20 +633,6 @@ return {
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "mimeType",
"storageKey": null
},
(v13/*: any*/)
],
"storageKey": null
@@ -667,12 +667,12 @@ return {
]
},
"params": {
"cacheID": "4a923b265801da22c605af53e744d543",
"cacheID": "c36bf27c7f4d6b3ef4a3e063e4bfccea",
"id": null,
"metadata": {},
"name": "MeasureGraphNodeQuery",
"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 { useTranslate } from "@probo/i18n";
import { usePageTitle } from "@probo/hooks";
import {
Button,
ActionDropdown,
DropdownItem,
Dropzone,
IconArrowInbox,
IconTrashCan,
@@ -18,8 +19,9 @@ import { graphql } from "relay-runtime";
import { useFragment, useMutation, useRefetchableFragment } from "react-relay";
import { SortableTable } from "/components/SortableTable";
import type { MeasureEvidencesTabFragment_evidence$key } from "./__generated__/MeasureEvidencesTabFragment_evidence.graphql";
import { fileSize, fileType } from "@probo/helpers";
import { promisifyMutation, sprintf } from "@probo/helpers";
import { fileSize, fileType, promisifyMutation, sprintf } from "@probo/helpers";
import { EvidencePreviewDialog } from "../dialog/EvidencePreviewDialog";
import { useOrganizationId } from "/hooks/useOrganizationId";
export const evidencesFragment = graphql`
fragment MeasureEvidencesTabFragment on Measure
@@ -43,6 +45,9 @@ export const evidencesFragment = graphql`
edges {
node {
id
filename
fileUrl
mimeType
...MeasureEvidencesTabFragment_evidence
}
}
@@ -50,7 +55,7 @@ export const evidencesFragment = graphql`
}
`;
const evidenceFragment = graphql`
export const evidenceFragment = graphql`
fragment MeasureEvidencesTabFragment_evidence on Evidence {
id
filename
@@ -93,12 +98,15 @@ export default function MeasureEvidencesTab() {
const { measure } = useOutletContext<{
measure: MeasureEvidencesTabFragment$key & { id: string; name: string };
}>();
const { evidenceId } = useParams<{ evidenceId: string }>();
const [data, refetch] = useRefetchableFragment(evidencesFragment, measure);
const connectionId = data.evidences.__id;
const evidences = data.evidences?.edges?.map((edge) => edge.node) ?? [];
const navigate = useNavigate();
const { __ } = useTranslate();
const [mutate, isUpdating] = useMutation(uploadEvidenceMutation);
const evidence = evidences.find((e) => e.id === evidenceId);
const organizationId = useOrganizationId();
usePageTitle(measure.name + " - " + __("Evidences"));
@@ -123,7 +131,7 @@ export default function MeasureEvidencesTab() {
<div className="space-y-6">
<Dropzone
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}
onDrop={handleDrop}
@@ -157,17 +165,32 @@ export default function MeasureEvidencesTab() {
<EvidenceRow
key={evidence.id}
evidenceKey={evidence}
measureId={measure.id}
organizationId={organizationId}
connectionId={connectionId}
/>
))}
</Tbody>
</SortableTable>
{evidence && (
<EvidencePreviewDialog
key={evidence?.id}
onClose={() =>
navigate(
`/organizations/${organizationId}/measures/${measure.id}/evidences`,
)
}
evidence={evidence}
/>
)}
</div>
);
}
function EvidenceRow(props: {
evidenceKey: MeasureEvidencesTabFragment_evidence$key;
measureId: string;
organizationId: string;
connectionId: string;
}) {
const evidence = useFragment(evidenceFragment, props.evidenceKey);
@@ -191,34 +214,40 @@ function EvidenceRow(props: {
{
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 (
<Tr>
<Tr
to={`/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`}
>
<Td>{evidence.filename}</Td>
<Td>{fileType(__, evidence)}</Td>
<Td>{fileSize(__, evidence.size)}</Td>
<Td>{dateFormat(evidence.createdAt)}</Td>
<Td>
<Td noLink>
<div className="flex gap-2">
<Button asChild variant="secondary">
<a href={evidence.fileUrl ?? ""} target="_blank">
<IconArrowInbox size={16} />
{__("Download")}
</a>
</Button>
<Button
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
/>
<ActionDropdown>
<DropdownItem asChild>
<a href={evidence.fileUrl ?? ""} target="_blank">
<IconArrowInbox size={16} />
{__("Download")}
</a>
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
</Td>
</Tr>

View File

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

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<65fd5218c9365e00e3127dfca328ad10>>
* @generated SignedSource<<e2a2d3c5470f8878acedd5e4ecc05300>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -219,6 +219,20 @@ return {
"name": "filename",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "mimeType",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -240,20 +254,6 @@ return {
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fileUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "mimeType",
"storageKey": null
},
(v11/*: any*/)
],
"storageKey": null
@@ -343,16 +343,16 @@ return {
]
},
"params": {
"cacheID": "28755a6b97ad50d18c25dea1b383d062",
"cacheID": "61b9596f07d5d2516f50e2f646eb21a2",
"id": null,
"metadata": {},
"name": "MeasureEvidencesTabQuery",
"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;

View File

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

View File

@@ -23,5 +23,8 @@
"schema": "./pkg/server/api/console/v1/schema.graphql"
},
"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;
className?: string;
ref?: DialogRef;
onClose?: () => void;
};
export const useDialogRef = (): DialogRef => {
@@ -62,6 +63,7 @@ export function Dialog({
className,
ref,
defaultOpen,
onClose,
}: Props) {
const { overlay, content, header, title: titleClassname } = dialog();
const [open, setOpen] = useState(!!defaultOpen);
@@ -76,8 +78,16 @@ export function Dialog({
},
};
}
const onOpenChange = (open: boolean) => {
setOpen(open);
if (!open) {
onClose?.();
}
};
return (
<Root open={open} onOpenChange={setOpen}>
<Root open={open} onOpenChange={onOpenChange}>
{trigger && <Trigger asChild>{trigger}</Trigger>}
<Portal>
<Overlay className={overlay()} />