Files
template-app/modules/account/ui/account-export.tsx
T

82 lines
3.0 KiB
TypeScript

"use client"
// "Export my data": one call, and the answer saved as a JSON file in the browser. A section that
// could not be exported is named on the page as well as in the file, so a person never takes a
// partial export for a whole one.
import { useTransport } from "@conusai-kit/ui"
import { type ReactElement, useId, useState } from "react"
import type { AccountExport } from "../contract/procedures.js"
export interface AccountExportLabels {
readonly heading: string
readonly body: string
readonly submit: string
readonly busy: string
readonly done: string
/** With `{sections}` still in it. */
readonly partial: string
readonly filename: string
/** The app's `app.failed` message, with `{detail}` still in it. */
readonly failed: string
/** A readable name per section key; a key without one is shown as it is. */
readonly sectionNames: Readonly<Record<string, string>>
}
type State =
| { readonly kind: "idle" | "busy" | "done" }
| { readonly kind: "partial"; readonly sections: string }
| { readonly kind: "failed"; readonly detail: string }
function save(exported: AccountExport, filename: string): void {
const href = URL.createObjectURL(new Blob([JSON.stringify(exported, null, 2)], { type: "application/json" }))
const link = document.createElement("a")
link.href = href
link.download = `${filename}-${exported.exportedAt.slice(0, 10)}.json`
document.body.append(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(href), 0)
}
export function AccountExportPanel({ labels }: { labels: AccountExportLabels }): ReactElement {
const { call, describe } = useTransport()
const headingId = useId()
const [state, setState] = useState<State>({ kind: "idle" })
const run = async (): Promise<void> => {
setState({ kind: "busy" })
try {
const exported = (await call("account.data.export", {})) as AccountExport
save(exported, labels.filename)
const missing = Object.entries(exported.sections)
.filter(([, section]) => section.status !== "ok")
.map(([key]) => labels.sectionNames[key] ?? key)
setState(exported.complete ? { kind: "done" } : { kind: "partial", sections: missing.join(", ") })
} catch (error) {
setState({ kind: "failed", detail: describe(error) })
}
}
return (
<section aria-labelledby={headingId}>
<h2 id={headingId}>{labels.heading}</h2>
<p>{labels.body}</p>
<button type="button" onClick={run} disabled={state.kind === "busy"} aria-busy={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</button>
{state.kind === "done" ? <p role="status">{labels.done}</p> : null}
{state.kind === "partial" ? (
<p role="alert" className="app-error">
{labels.partial.replace("{sections}", state.sections)}
</p>
) : null}
{state.kind === "failed" ? (
<p role="alert" className="app-error">
{labels.failed.replace("{detail}", state.detail)}
</p>
) : null}
</section>
)
}