Add link dialog for measure evidences
This commit is contained in:
@@ -99,7 +99,7 @@ export function SortableTh({
|
||||
size={16}
|
||||
className={clsx(
|
||||
isCurrentField && "text-txt-primary",
|
||||
isCurrentField && !isDesc && "rotate-180"
|
||||
isCurrentField && !isDesc && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function TabItem({
|
||||
asChild,
|
||||
active,
|
||||
...props
|
||||
}: AsChildProps<{ active?: boolean }>) {
|
||||
}: AsChildProps<{ active?: boolean; onClick?: () => void }>) {
|
||||
const Component = asChild ? Slot : "div";
|
||||
return <Component {...props} className={cls.item({ active })} />;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type Props =
|
||||
| BaseProps<never, ComponentProps<typeof Input>>
|
||||
| BaseProps<"text", ComponentProps<typeof Input>>
|
||||
| BaseProps<"email", ComponentProps<typeof Input>>
|
||||
| BaseProps<"url", ComponentProps<typeof Input>>
|
||||
| BaseProps<"password", ComponentProps<typeof Input>>
|
||||
| BaseProps<"textarea", ComponentProps<typeof Textarea>>
|
||||
| BaseProps<"number", ComponentProps<typeof Input>>
|
||||
|
||||
Reference in New Issue
Block a user