179 lines
6.2 KiB
TypeScript
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>
|
|
)
|
|
}
|