@@ -0,0 +1,178 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user