84 lines
2.8 KiB
TypeScript
84 lines
2.8 KiB
TypeScript
"use client"
|
|
|
|
import { Button } from "@conusai-kit/ui/primitives"
|
|
|
|
// Download the approved lines as an Excel workbook. The procedure answers with the file as base64
|
|
// (a procedure's answer is JSON on every transport); the browser turns it into a download. With
|
|
// nothing approved the button is disabled and says why, in text a screen reader reads with it.
|
|
|
|
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
|
import { type ReactElement, useEffect, useId, useState } from "react"
|
|
import type { ProjectSlugs } from "./slugs.js"
|
|
|
|
export interface ExportLabels {
|
|
readonly export: string
|
|
readonly hint: string
|
|
readonly nothing: string
|
|
/** Said after the workbook was handed to the browser. */
|
|
readonly exported: string
|
|
readonly failed: string
|
|
}
|
|
|
|
export function ExportButton({
|
|
slugs,
|
|
fileId,
|
|
approved,
|
|
labels,
|
|
}: {
|
|
slugs: ProjectSlugs
|
|
fileId: string
|
|
/** How many lines are approved: with none, there is nothing to export. */
|
|
approved: number
|
|
labels: ExportLabels
|
|
}): ReactElement {
|
|
const createExport = useWrite<
|
|
ProjectSlugs & { fileId: string },
|
|
{ filename: string; contentType: string; body: string }
|
|
>("documents.export.create")
|
|
const hintId = useId()
|
|
const [interactive, setInteractive] = useState(false)
|
|
useEffect(() => setInteractive(true), [])
|
|
const [message, setMessage] = useState<{ ok: boolean; text: string } | undefined>(undefined)
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const run = async (): Promise<void> => {
|
|
setBusy(true)
|
|
setMessage(undefined)
|
|
try {
|
|
const made = await createExport.mutate({ ...slugs, fileId })
|
|
const bytes = Uint8Array.from(atob(made.body), (char) => char.charCodeAt(0))
|
|
const url = URL.createObjectURL(new Blob([bytes], { type: made.contentType }))
|
|
const link = document.createElement("a")
|
|
link.href = url
|
|
link.download = made.filename
|
|
// Attached while it is clicked, and the object URL kept for a while after: a browser starts the
|
|
// download after the click returns, and a URL revoked first is a download that never starts.
|
|
document.body.append(link)
|
|
link.click()
|
|
link.remove()
|
|
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
|
setMessage({ ok: true, text: labels.exported })
|
|
} catch (error) {
|
|
setMessage({ ok: false, text: labels.failed.replace("{detail}", describeError(error)) })
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<Button
|
|
variant="outline"
|
|
type="button"
|
|
disabled={!interactive || busy || approved === 0}
|
|
aria-describedby={hintId}
|
|
onClick={run}
|
|
>
|
|
{labels.export}
|
|
</Button>
|
|
<p id={hintId}>{approved === 0 ? labels.nothing : labels.hint}</p>
|
|
<p role={message?.ok === false ? "alert" : "status"}>{message?.text ?? ""}</p>
|
|
</div>
|
|
)
|
|
}
|