Add link dialog for measure evidences

This commit is contained in:
Jonathan
2025-06-17 21:35:31 +02:00
parent ac65ccd213
commit bd1e7396ae
7 changed files with 305 additions and 88 deletions

View File

@@ -99,7 +99,7 @@ export function SortableTh({
size={16}
className={clsx(
isCurrentField && "text-txt-primary",
isCurrentField && !isDesc && "rotate-180"
isCurrentField && !isDesc && "rotate-180",
)}
/>
</button>

View File

@@ -0,0 +1,190 @@
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Dropzone,
Field,
Spinner,
TabItem,
Tabs,
type DialogRef,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { graphql, useMutation } from "react-relay";
import { useState } from "react";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
const uploadEvidenceMutation = graphql`
mutation CreateEvidenceDialogUploadMutation(
$input: UploadMeasureEvidenceInput!
$connections: [ID!]!
) {
uploadMeasureEvidence(input: $input) {
evidenceEdge @appendEdge(connections: $connections) {
node {
id
...MeasureEvidencesTabFragment_evidence
}
}
}
}
`;
type Props = {
measureId: string;
connectionId: string;
ref: DialogRef;
};
export function CreateEvidenceDialog(props: Props) {
const { __ } = useTranslate();
const [tab, setTab] = useState("link");
return (
<Dialog
title={
<Breadcrumb
items={[
{ label: __("Measures detail") },
{ label: __("Create Evidence") },
]}
/>
}
ref={props.ref}
className="max-w-lg"
>
<Tabs className="px-6">
<TabItem active={tab === "upload"} onClick={() => setTab("upload")}>
{__("Upload")}
</TabItem>
<TabItem active={tab === "link"} onClick={() => setTab("link")}>
{__("Link")}
</TabItem>
</Tabs>
{tab === "upload" && <EvidenceUpload {...props} />}
{tab === "link" && <EvidenceLink {...props} />}
</Dialog>
);
}
function EvidenceUpload({ measureId, connectionId }: Omit<Props, "ref">) {
const { __ } = useTranslate();
const [mutate, isUpdating] = useMutation(uploadEvidenceMutation);
const handleDrop = (files: File[]) => {
for (const file of files) {
mutate({
variables: {
connections: [connectionId],
input: {
measureId: measureId,
file: null,
},
},
uploadables: {
"input.file": file,
},
});
}
};
return (
<>
<DialogContent padded>
<Dropzone
description={__(
"Only PDF, DOCX, XLSX, PPTX, JPG, PNG, WEBP files up to 10MB are allowed"
)}
isUploading={isUpdating}
onDrop={handleDrop}
accept={{
"application/pdf": [".pdf"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[".docx"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
[".xlsx"],
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
[".pptx"],
"image/jpeg": [".jpg", ".jpeg"],
"image/png": [".png"],
"image/webp": [".webp"],
}}
maxSize={10}
/>
</DialogContent>
</>
);
}
const linkSchema = z.object({
name: z.string(),
url: z.string().url(),
});
function EvidenceLink({ measureId, connectionId, ref }: Props) {
const { __ } = useTranslate();
const { handleSubmit, register, formState } = useFormWithSchema(linkSchema, {
defaultValues: {
name: "",
url: "",
},
});
const [mutate] = useMutationWithToasts(uploadEvidenceMutation, {
successMessage: __("Evidence created successfully"),
errorMessage: __("Failed to create evidence"),
});
const onSubmit = handleSubmit(async (data) => {
const fileName = `${data.name.trim()}.uri`;
const file = new File([data.url.trim()], fileName, {
type: "text/uri-list",
});
await mutate({
variables: {
connections: [connectionId],
input: {
measureId: measureId,
file: null,
},
},
uploadables: {
"input.file": file,
},
});
ref.current?.close();
});
return (
<form onSubmit={onSubmit}>
<DialogContent padded className="space-y-4">
<Field
required
type="text"
label={__("Name")}
placeholder={__("Evidence name")}
{...register("name")}
error={formState.errors.name?.message}
/>
<Field
required
type="url"
label={__("URL")}
placeholder={__("Evidence URL")}
{...register("url")}
error={formState.errors.url?.message}
help={__("This will create a .uri file with the URL inside")}
/>
</DialogContent>
<DialogFooter>
<Button
type="submit"
disabled={formState.isSubmitting}
icon={formState.isSubmitting ? Spinner : undefined}
>
{__("Create")}
</Button>
</DialogFooter>
</form>
);
}

View File

@@ -1,11 +1,16 @@
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
IconArrowInbox,
IconFilter,
IconWarning,
Spinner,
useDialogRef,
useToast,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useEffect } from "react";
type Props = {
evidence: {
@@ -18,27 +23,86 @@ type Props = {
export function EvidencePreviewDialog({ evidence, onClose }: Props) {
const { __ } = useTranslate();
const ref = useDialogRef();
return (
<Dialog defaultOpen title={evidence.filename} onClose={onClose}>
<Dialog
ref={ref}
defaultOpen
title={
<Breadcrumb
items={[{ label: __("Evidences") }, { label: 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} />
<EvidencePreviewContent
evidence={evidence}
onClose={() => ref.current?.close()}
/>
</DialogContent>
</Dialog>
);
}
function EvidencePreviewContent({ evidence }: Pick<Props, "evidence">) {
const fetchUrlFromUriFile = async (
fileUrl: string,
options?: { signal?: AbortSignal }
): Promise<string> => {
const response = await fetch(fileUrl, options);
const text = await response.text();
// URI files typically have the URL on the first line
const firstLine = text.trim().split("\n")[0];
if (!firstLine) {
throw new Error("No URL found in URI file");
}
return firstLine;
};
function EvidencePreviewContent({ evidence, onClose }: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const isUriFile =
evidence.mimeType === "text/uri-list" || evidence.mimeType === "text/uri";
useEffect(() => {
if (!isUriFile) {
return;
}
const abortController = new AbortController();
fetchUrlFromUriFile(evidence.fileUrl ?? "", {
signal: abortController.signal,
})
.then((url) => {
window.open(url, "_blank");
})
.catch((e) => {
if (e.name === "AbortError") {
return;
}
toast({
title: __("Error"),
description: e.message ?? __("Failed to extract URL from URI file"),
variant: "error",
});
})
.finally(onClose);
return () => {
abortController.abort();
};
}, [evidence.fileUrl, isUriFile]);
if (!evidence.fileUrl) {
return null;
}
if (isUriFile) {
return (
<div className="flex flex-col items-center gap-2 justify-center">
<Spinner size={20} />
</div>
);
}
if (evidence.mimeType.startsWith("image/")) {
return (
<img
@@ -60,10 +124,10 @@ function EvidencePreviewContent({ evidence }: Pick<Props, "evidence">) {
}
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") +
<div className="flex flex-col items-center gap-2 justify-center">
<IconWarning size={20} />
<p className="text-txt-secondary text-center">
{__("Preview not available for this file type") +
" " +
evidence.mimeType}
</p>

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<dd129ccf57ef541ce24672d4b55d708f>>
* @generated SignedSource<<4e4aeb5c756375a47e6a260c6ff9e259>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -14,11 +14,11 @@ export type UploadMeasureEvidenceInput = {
file: any;
measureId: string;
};
export type MeasureEvidencesTabUploadMutation$variables = {
export type CreateEvidenceDialogUploadMutation$variables = {
connections: ReadonlyArray<string>;
input: UploadMeasureEvidenceInput;
};
export type MeasureEvidencesTabUploadMutation$data = {
export type CreateEvidenceDialogUploadMutation$data = {
readonly uploadMeasureEvidence: {
readonly evidenceEdge: {
readonly node: {
@@ -28,9 +28,9 @@ export type MeasureEvidencesTabUploadMutation$data = {
};
};
};
export type MeasureEvidencesTabUploadMutation = {
response: MeasureEvidencesTabUploadMutation$data;
variables: MeasureEvidencesTabUploadMutation$variables;
export type CreateEvidenceDialogUploadMutation = {
response: CreateEvidenceDialogUploadMutation$data;
variables: CreateEvidenceDialogUploadMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -66,7 +66,7 @@ return {
],
"kind": "Fragment",
"metadata": null,
"name": "MeasureEvidencesTabUploadMutation",
"name": "CreateEvidenceDialogUploadMutation",
"selections": [
{
"alias": null,
@@ -118,7 +118,7 @@ return {
(v0/*: any*/)
],
"kind": "Operation",
"name": "MeasureEvidencesTabUploadMutation",
"name": "CreateEvidenceDialogUploadMutation",
"selections": [
{
"alias": null,
@@ -215,16 +215,16 @@ return {
]
},
"params": {
"cacheID": "facbff238f3ee10c220a5c80883836da",
"cacheID": "7070386f52341969556753a27d42e226",
"id": null,
"metadata": {},
"name": "MeasureEvidencesTabUploadMutation",
"name": "CreateEvidenceDialogUploadMutation",
"operationKind": "mutation",
"text": "mutation MeasureEvidencesTabUploadMutation(\n $input: UploadMeasureEvidenceInput!\n) {\n uploadMeasureEvidence(input: $input) {\n evidenceEdge {\n node {\n id\n ...MeasureEvidencesTabFragment_evidence\n }\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": "mutation CreateEvidenceDialogUploadMutation(\n $input: UploadMeasureEvidenceInput!\n) {\n uploadMeasureEvidence(input: $input) {\n evidenceEdge {\n node {\n id\n ...MeasureEvidencesTabFragment_evidence\n }\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 = "37283c7d6f25ed8c768ba9e0f93ebafb";
(node as any).hash = "719cce21cdff705c55dda3de31997a53";
export default node;

View File

@@ -5,23 +5,26 @@ import { usePageTitle } from "@probo/hooks";
import {
ActionDropdown,
DropdownItem,
Dropzone,
IconArrowInbox,
IconPlusLarge,
IconTrashCan,
Tbody,
Td,
Th,
Thead,
Tr,
TrButton,
useConfirm,
useDialogRef,
} from "@probo/ui";
import { graphql } from "relay-runtime";
import { useFragment, useMutation, useRefetchableFragment } from "react-relay";
import { useFragment, useMutation, usePaginationFragment } from "react-relay";
import { SortableTable } from "/components/SortableTable";
import type { MeasureEvidencesTabFragment_evidence$key } from "./__generated__/MeasureEvidencesTabFragment_evidence.graphql";
import { fileSize, fileType, promisifyMutation, sprintf } from "@probo/helpers";
import { EvidencePreviewDialog } from "../dialog/EvidencePreviewDialog";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { CreateEvidenceDialog } from "../dialog/CreateEvidenceDialog";
export const evidencesFragment = graphql`
fragment MeasureEvidencesTabFragment on Measure
@@ -67,22 +70,6 @@ export const evidenceFragment = graphql`
}
`;
const uploadEvidenceMutation = graphql`
mutation MeasureEvidencesTabUploadMutation(
$input: UploadMeasureEvidenceInput!
$connections: [ID!]!
) {
uploadMeasureEvidence(input: $input) {
evidenceEdge @appendEdge(connections: $connections) {
node {
id
...MeasureEvidencesTabFragment_evidence
}
}
}
}
`;
const deleteEvidenceMutation = graphql`
mutation MeasureEvidencesTabDeleteMutation(
$input: DeleteEvidenceInput!
@@ -99,58 +86,21 @@ export default function MeasureEvidencesTab() {
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 pagination = usePaginationFragment(evidencesFragment, measure);
const connectionId = pagination.data.evidences.__id;
const evidences =
pagination.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();
const dialogRef = useDialogRef();
usePageTitle(measure.name + " - " + __("Evidences"));
const handleDrop = (files: File[]) => {
for (const file of files) {
mutate({
variables: {
connections: [connectionId],
input: {
measureId: measure.id,
file: null,
},
},
uploadables: {
"input.file": file,
},
});
}
};
return (
<div className="space-y-6">
<Dropzone
description={__(
"Only PDF, DOCX, XLSX, PPTX, JPG, PNG, WEBP files up to 10MB are allowed",
)}
isUploading={isUpdating}
onDrop={handleDrop}
accept={{
"application/pdf": [".pdf"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[".docx"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
".xlsx",
],
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
[".pptx"],
"image/jpeg": [".jpg", ".jpeg"],
"image/png": [".png"],
"image/webp": [".webp"],
}}
maxSize={10}
/>
<SortableTable refetch={refetch}>
<SortableTable {...pagination}>
<Thead>
<Tr>
<Th>{__("Evidence name")}</Th>
@@ -170,6 +120,13 @@ export default function MeasureEvidencesTab() {
connectionId={connectionId}
/>
))}
<TrButton
colspan={5}
onClick={() => dialogRef.current?.open()}
icon={IconPlusLarge}
>
{__("Add evidence")}
</TrButton>
</Tbody>
</SortableTable>
{evidence && (
@@ -183,6 +140,11 @@ export default function MeasureEvidencesTab() {
evidence={evidence}
/>
)}
<CreateEvidenceDialog
ref={dialogRef}
measureId={measure.id}
connectionId={connectionId}
/>
</div>
);
}