Files
2026-11-14 09:56:45 +00:00

179 lines
6.2 KiB
TypeScript

"use client"
import {
Button,
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@conusai-kit/ui/primitives"
// Imports a CSV file: the browser reads it, hashes it and sends it to the service in chunks of the size one
// request may carry, each carrying the import's revision from the chunk before. A row the service refuses
// is listed with its reason; the others stay applied. Sending the same file again applies nothing twice.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import { serviceNotice } from "@conusai-kit/ui/patterns"
import { Alert, AlertDescription, Field, FieldDescription, FieldLabel, Input } from "@conusai-kit/ui/primitives"
import { type FormEvent, type ReactElement, useState } from "react"
import { type ImportOutcomeView, type ImportRow, MAX_IMPORT_BYTES } from "../contract/index.js"
import { chunksOf, parseProductCsv } from "./import-rows.js"
export interface ImportFormLabels {
readonly heading: string
readonly file: string
readonly hint: string
readonly submit: string
readonly busy: string
/** `{created} created, ...`: the component fills the counts. */
readonly done: string
readonly empty: string
readonly tooLarge: string
/** `... lacks: {columns}` */
readonly missing: string
readonly refusedCaption: string
readonly row: string
readonly reason: string
readonly reasons: Readonly<Record<"invalid_fields" | "conflict" | "not_found", string>>
readonly failed: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
type Refused = { sourceRow: number; reason: string | null }
type State =
| { kind: "idle" }
| { kind: "busy" }
| { kind: "message"; text: string }
| { kind: "done"; summary: string; refused: Refused[] }
async function sha256(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes)
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
}
export function ImportForm({ slugs, labels }: { slugs: ProjectSlugs; labels: ImportFormLabels }): ReactElement {
const apply = useWrite<
ProjectSlugs & {
fileHash: string
sourceName: string
format: "csv"
chunk: number
finalChunk: boolean
revision?: string
rows: ImportRow[]
requestId: string
},
ImportOutcomeView
>("catalog.import.apply")
const [state, setState] = useState<State>({ kind: "idle" })
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const file = new FormData(event.currentTarget).get("file")
if (!(file instanceof File) || file.size === 0) return
if (file.size > MAX_IMPORT_BYTES) {
setState({ kind: "message", text: labels.tooLarge })
return
}
setState({ kind: "busy" })
try {
const parsed = parseProductCsv(await file.text())
if (parsed.kind === "unreadable") {
setState({ kind: "message", text: labels.missing.replace("{columns}", parsed.missing.join(", ")) })
return
}
const chunks = chunksOf(parsed.rows)
if (chunks.length === 0) {
setState({ kind: "message", text: labels.empty })
return
}
const fileHash = await sha256(await file.arrayBuffer())
const refused: Refused[] = []
let last: ImportOutcomeView | undefined
for (const [chunk, rows] of chunks.entries()) {
last = await apply.mutate({
...slugs,
fileHash,
sourceName: file.name,
format: "csv",
chunk,
finalChunk: chunk === chunks.length - 1,
...(last === undefined ? {} : { revision: last.import.revision }),
rows,
requestId: crypto.randomUUID(),
})
for (const row of last.rows) {
if (row.outcome === "refused") refused.push({ sourceRow: row.sourceRow, reason: row.reason })
}
}
const totals = last?.import
setState({
kind: "done",
summary: labels.done
.replace("{created}", String(totals?.created ?? 0))
.replace("{updated}", String(totals?.updated ?? 0))
.replace("{unchanged}", String(totals?.unchanged ?? 0))
.replace("{refused}", String(totals?.refused ?? 0)),
refused,
})
} catch (error) {
setState({
kind: "message",
text: labels.failed.replace("{detail}", serviceNotice(error, labels.refusals)?.text ?? describeError(error)),
})
}
}
const reasonOf = (reason: string | null): string =>
(reason !== null && labels.reasons[reason as keyof typeof labels.reasons]) || (reason ?? "")
return (
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
<h2>{labels.heading}</h2>
<Field>
<FieldLabel>{labels.file}</FieldLabel>
<Input name="file" type="file" required accept=".csv,text/csv" />
<FieldDescription>{labels.hint}</FieldDescription>
</Field>
<Button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</Button>
{state.kind === "message" ? (
<Alert variant="destructive">
<AlertDescription>{state.text}</AlertDescription>
</Alert>
) : null}
{state.kind === "done" ? (
<>
<p role="status">{state.summary}</p>
{state.refused.length > 0 ? (
<Table>
<TableCaption>{labels.refusedCaption}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.row}</TableHead>
<TableHead scope="col">{labels.reason}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{state.refused.map((row) => (
<TableRow key={row.sourceRow}>
<TableHead scope="row">{row.sourceRow}</TableHead>
<TableCell>{reasonOf(row.reason)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</>
) : null}
</form>
)
}