diff --git a/apps/console/src/components/SortableTable.tsx b/apps/console/src/components/SortableTable.tsx index 0c629127c..1d0f6de73 100644 --- a/apps/console/src/components/SortableTable.tsx +++ b/apps/console/src/components/SortableTable.tsx @@ -99,7 +99,7 @@ export function SortableTh({ size={16} className={clsx( isCurrentField && "text-txt-primary", - isCurrentField && !isDesc && "rotate-180" + isCurrentField && !isDesc && "rotate-180", )} /> diff --git a/apps/console/src/pages/organizations/measures/dialog/CreateEvidenceDialog.tsx b/apps/console/src/pages/organizations/measures/dialog/CreateEvidenceDialog.tsx new file mode 100644 index 000000000..c4d2123cd --- /dev/null +++ b/apps/console/src/pages/organizations/measures/dialog/CreateEvidenceDialog.tsx @@ -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 ( + + } + ref={props.ref} + className="max-w-lg" + > + + setTab("upload")}> + {__("Upload")} + + setTab("link")}> + {__("Link")} + + + {tab === "upload" && } + {tab === "link" && } + + ); +} + +function EvidenceUpload({ measureId, connectionId }: Omit) { + 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 ( + <> + + + + + ); +} + +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 ( +
+ + + + + + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/measures/dialog/EvidencePreviewDialog.tsx b/apps/console/src/pages/organizations/measures/dialog/EvidencePreviewDialog.tsx index 2590d2138..efbec71d7 100644 --- a/apps/console/src/pages/organizations/measures/dialog/EvidencePreviewDialog.tsx +++ b/apps/console/src/pages/organizations/measures/dialog/EvidencePreviewDialog.tsx @@ -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 ( - + + } + onClose={onClose} + > -

- {__( - "Preview of the evidence file. You can view or download the file from here." - )} -

- + ref.current?.close()} + />
); } -function EvidencePreviewContent({ evidence }: Pick) { +const fetchUrlFromUriFile = async ( + fileUrl: string, + options?: { signal?: AbortSignal } +): Promise => { + 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 ( +
+ +
+ ); + } + if (evidence.mimeType.startsWith("image/")) { return ( ) { } return ( -
- -

- {__("Preview not availeeable for this file type") + +

+ +

+ {__("Preview not available for this file type") + " " + evidence.mimeType}

diff --git a/apps/console/src/pages/organizations/measures/tabs/__generated__/MeasureEvidencesTabUploadMutation.graphql.ts b/apps/console/src/pages/organizations/measures/dialog/__generated__/CreateEvidenceDialogUploadMutation.graphql.ts similarity index 84% rename from apps/console/src/pages/organizations/measures/tabs/__generated__/MeasureEvidencesTabUploadMutation.graphql.ts rename to apps/console/src/pages/organizations/measures/dialog/__generated__/CreateEvidenceDialogUploadMutation.graphql.ts index 223d82f22..0a6f3a0ab 100644 --- a/apps/console/src/pages/organizations/measures/tabs/__generated__/MeasureEvidencesTabUploadMutation.graphql.ts +++ b/apps/console/src/pages/organizations/measures/dialog/__generated__/CreateEvidenceDialogUploadMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @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; 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; diff --git a/apps/console/src/pages/organizations/measures/tabs/MeasureEvidencesTab.tsx b/apps/console/src/pages/organizations/measures/tabs/MeasureEvidencesTab.tsx index 711876514..bb918200c 100644 --- a/apps/console/src/pages/organizations/measures/tabs/MeasureEvidencesTab.tsx +++ b/apps/console/src/pages/organizations/measures/tabs/MeasureEvidencesTab.tsx @@ -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 (
- - + {__("Evidence name")} @@ -170,6 +120,13 @@ export default function MeasureEvidencesTab() { connectionId={connectionId} /> ))} + dialogRef.current?.open()} + icon={IconPlusLarge} + > + {__("Add evidence")} + {evidence && ( @@ -183,6 +140,11 @@ export default function MeasureEvidencesTab() { evidence={evidence} /> )} +
); } diff --git a/packages/ui/src/Atoms/Tabs/Tabs.tsx b/packages/ui/src/Atoms/Tabs/Tabs.tsx index 5efefe0b2..399a05dc1 100644 --- a/packages/ui/src/Atoms/Tabs/Tabs.tsx +++ b/packages/ui/src/Atoms/Tabs/Tabs.tsx @@ -36,7 +36,7 @@ export function TabItem({ asChild, active, ...props -}: AsChildProps<{ active?: boolean }>) { +}: AsChildProps<{ active?: boolean; onClick?: () => void }>) { const Component = asChild ? Slot : "div"; return ; } diff --git a/packages/ui/src/Molecules/Field/Field.tsx b/packages/ui/src/Molecules/Field/Field.tsx index 961de4535..e891db749 100644 --- a/packages/ui/src/Molecules/Field/Field.tsx +++ b/packages/ui/src/Molecules/Field/Field.tsx @@ -18,6 +18,7 @@ type Props = | BaseProps> | BaseProps<"text", ComponentProps> | BaseProps<"email", ComponentProps> + | BaseProps<"url", ComponentProps> | BaseProps<"password", ComponentProps> | BaseProps<"textarea", ComponentProps> | BaseProps<"number", ComponentProps>