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} size={16}
className={clsx( className={clsx(
isCurrentField && "text-txt-primary", isCurrentField && "text-txt-primary",
isCurrentField && !isDesc && "rotate-180" isCurrentField && !isDesc && "rotate-180",
)} )}
/> />
</button> </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 { import {
Breadcrumb,
Button, Button,
Dialog, Dialog,
DialogContent, DialogContent,
IconArrowInbox, IconArrowInbox,
IconFilter, IconWarning,
Spinner,
useDialogRef,
useToast,
} from "@probo/ui"; } from "@probo/ui";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { useEffect } from "react";
type Props = { type Props = {
evidence: { evidence: {
@@ -18,27 +23,86 @@ type Props = {
export function EvidencePreviewDialog({ evidence, onClose }: Props) { export function EvidencePreviewDialog({ evidence, onClose }: Props) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const ref = useDialogRef();
return ( return (
<Dialog defaultOpen title={evidence.filename} onClose={onClose}> <Dialog
ref={ref}
defaultOpen
title={
<Breadcrumb
items={[{ label: __("Evidences") }, { label: evidence.filename }]}
/>
}
onClose={onClose}
>
<DialogContent padded> <DialogContent padded>
<p> <EvidencePreviewContent
{__( evidence={evidence}
"Preview of the evidence file. You can view or download the file from here." onClose={() => ref.current?.close()}
)} />
</p>
<EvidencePreviewContent evidence={evidence} />
</DialogContent> </DialogContent>
</Dialog> </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 { __ } = 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) { if (!evidence.fileUrl) {
return null; 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/")) { if (evidence.mimeType.startsWith("image/")) {
return ( return (
<img <img
@@ -60,10 +124,10 @@ function EvidencePreviewContent({ evidence }: Pick<Props, "evidence">) {
} }
return ( return (
<div className="flex flex-col items-center gap-4 justify-center"> <div className="flex flex-col items-center gap-2 justify-center">
<IconFilter size={20} /> <IconWarning size={20} />
<p className="text-secondary text-center"> <p className="text-txt-secondary text-center">
{__("Preview not availeeable for this file type") + {__("Preview not available for this file type") +
" " + " " +
evidence.mimeType} evidence.mimeType}
</p> </p>

View File

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

View File

@@ -5,23 +5,26 @@ import { usePageTitle } from "@probo/hooks";
import { import {
ActionDropdown, ActionDropdown,
DropdownItem, DropdownItem,
Dropzone,
IconArrowInbox, IconArrowInbox,
IconPlusLarge,
IconTrashCan, IconTrashCan,
Tbody, Tbody,
Td, Td,
Th, Th,
Thead, Thead,
Tr, Tr,
TrButton,
useConfirm, useConfirm,
useDialogRef,
} from "@probo/ui"; } from "@probo/ui";
import { graphql } from "relay-runtime"; 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 { 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, promisifyMutation, sprintf } from "@probo/helpers"; import { fileSize, fileType, promisifyMutation, sprintf } from "@probo/helpers";
import { EvidencePreviewDialog } from "../dialog/EvidencePreviewDialog"; import { EvidencePreviewDialog } from "../dialog/EvidencePreviewDialog";
import { useOrganizationId } from "/hooks/useOrganizationId"; import { useOrganizationId } from "/hooks/useOrganizationId";
import { CreateEvidenceDialog } from "../dialog/CreateEvidenceDialog";
export const evidencesFragment = graphql` export const evidencesFragment = graphql`
fragment MeasureEvidencesTabFragment on Measure 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` const deleteEvidenceMutation = graphql`
mutation MeasureEvidencesTabDeleteMutation( mutation MeasureEvidencesTabDeleteMutation(
$input: DeleteEvidenceInput! $input: DeleteEvidenceInput!
@@ -99,58 +86,21 @@ export default function MeasureEvidencesTab() {
measure: MeasureEvidencesTabFragment$key & { id: string; name: string }; measure: MeasureEvidencesTabFragment$key & { id: string; name: string };
}>(); }>();
const { evidenceId } = useParams<{ evidenceId: string }>(); const { evidenceId } = useParams<{ evidenceId: string }>();
const [data, refetch] = useRefetchableFragment(evidencesFragment, measure); const pagination = usePaginationFragment(evidencesFragment, measure);
const connectionId = data.evidences.__id; const connectionId = pagination.data.evidences.__id;
const evidences = data.evidences?.edges?.map((edge) => edge.node) ?? []; const evidences =
pagination.data.evidences?.edges?.map((edge) => edge.node) ?? [];
const navigate = useNavigate(); const navigate = useNavigate();
const { __ } = useTranslate(); const { __ } = useTranslate();
const [mutate, isUpdating] = useMutation(uploadEvidenceMutation);
const evidence = evidences.find((e) => e.id === evidenceId); const evidence = evidences.find((e) => e.id === evidenceId);
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const dialogRef = useDialogRef();
usePageTitle(measure.name + " - " + __("Evidences")); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<Dropzone <SortableTable {...pagination}>
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}>
<Thead> <Thead>
<Tr> <Tr>
<Th>{__("Evidence name")}</Th> <Th>{__("Evidence name")}</Th>
@@ -170,6 +120,13 @@ export default function MeasureEvidencesTab() {
connectionId={connectionId} connectionId={connectionId}
/> />
))} ))}
<TrButton
colspan={5}
onClick={() => dialogRef.current?.open()}
icon={IconPlusLarge}
>
{__("Add evidence")}
</TrButton>
</Tbody> </Tbody>
</SortableTable> </SortableTable>
{evidence && ( {evidence && (
@@ -183,6 +140,11 @@ export default function MeasureEvidencesTab() {
evidence={evidence} evidence={evidence}
/> />
)} )}
<CreateEvidenceDialog
ref={dialogRef}
measureId={measure.id}
connectionId={connectionId}
/>
</div> </div>
); );
} }

View File

@@ -36,7 +36,7 @@ export function TabItem({
asChild, asChild,
active, active,
...props ...props
}: AsChildProps<{ active?: boolean }>) { }: AsChildProps<{ active?: boolean; onClick?: () => void }>) {
const Component = asChild ? Slot : "div"; const Component = asChild ? Slot : "div";
return <Component {...props} className={cls.item({ active })} />; return <Component {...props} className={cls.item({ active })} />;
} }

View File

@@ -18,6 +18,7 @@ type Props =
| BaseProps<never, ComponentProps<typeof Input>> | BaseProps<never, ComponentProps<typeof Input>>
| BaseProps<"text", ComponentProps<typeof Input>> | BaseProps<"text", ComponentProps<typeof Input>>
| BaseProps<"email", ComponentProps<typeof Input>> | BaseProps<"email", ComponentProps<typeof Input>>
| BaseProps<"url", ComponentProps<typeof Input>>
| BaseProps<"password", ComponentProps<typeof Input>> | BaseProps<"password", ComponentProps<typeof Input>>
| BaseProps<"textarea", ComponentProps<typeof Textarea>> | BaseProps<"textarea", ComponentProps<typeof Textarea>>
| BaseProps<"number", ComponentProps<typeof Input>> | BaseProps<"number", ComponentProps<typeof Input>>