ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+178
View File
@@ -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>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
// The imports applied so far. A server component: it holds no state, and every string is passed in.
import type { ReactElement } from "react"
import type { ImportView } from "../contract/index.js"
export interface ImportListLabels {
/** How many imports, already filled with the plural form. */
readonly count: string
readonly caption: string
readonly empty: string
readonly source: string
readonly status: string
readonly created: string
readonly updated: string
readonly unchanged: string
readonly refused: string
}
export function ImportList({
imports,
labels,
}: {
imports: readonly ImportView[]
labels: ImportListLabels
}): ReactElement {
return imports.length === 0 ? (
<p>{labels.empty}</p>
) : (
<>
<p>{labels.count}</p>
<Table>
<TableCaption>{labels.caption}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.source}</TableHead>
<TableHead scope="col">{labels.status}</TableHead>
<TableHead scope="col">{labels.created}</TableHead>
<TableHead scope="col">{labels.updated}</TableHead>
<TableHead scope="col">{labels.unchanged}</TableHead>
<TableHead scope="col">{labels.refused}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{imports.map((row) => (
<TableRow key={row.importId}>
<TableHead scope="row">{row.sourceName}</TableHead>
<TableCell>{row.status}</TableCell>
<TableCell>{row.created}</TableCell>
<TableCell>{row.updated}</TableCell>
<TableCell>{row.unchanged}</TableCell>
<TableCell>{row.refused}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)
}
+79
View File
@@ -0,0 +1,79 @@
// What the import form reads out of a file in the browser: a small CSV reader (RFC 4180: quoted fields,
// doubled quotes, LF or CRLF) and the rows as the service takes them. No component here, so a test reads
// it directly; the service still validates every row and reports the ones it refuses.
import { IMPORT_CHUNK_ROWS, type ImportRow } from "../contract/index.js"
const REQUIRED = ["code", "name", "base_unit"] as const
/** The records of a CSV text, each a list of fields. Blank lines are records of one empty field. */
export function csvRows(text: string): string[][] {
const records: string[][] = []
let record: string[] = []
let field = ""
let quoted = false
for (let at = 0; at < text.length; at += 1) {
const char = text[at]
if (quoted) {
if (char !== '"') field += char
else if (text[at + 1] === '"') {
field += '"'
at += 1
} else quoted = false
} else if (char === '"') quoted = true
else if (char === ",") {
record.push(field)
field = ""
} else if (char === "\n" || char === "\r") {
if (char === "\r" && text[at + 1] === "\n") at += 1
record.push(field)
records.push(record)
record = []
field = ""
} else field += char
}
if (field !== "" || record.length > 0) {
record.push(field)
records.push(record)
}
return records
}
export type ParsedProducts = { kind: "rows"; rows: ImportRow[] } | { kind: "unreadable"; missing: string[] }
/** The product rows of a CSV file, by header name, or the required columns its header lacks. */
export function parseProductCsv(text: string): ParsedProducts {
const records = csvRows(text.replace(/^/, ""))
const header = (records[0] ?? []).map((name) => name.trim().toLowerCase())
const missing = REQUIRED.filter((name) => !header.includes(name))
if (missing.length > 0) return { kind: "unreadable", missing }
const column = (record: readonly string[], name: string): string => {
const at = header.indexOf(name)
return at < 0 ? "" : (record[at] ?? "").trim()
}
const rows: ImportRow[] = []
records.slice(1).forEach((record, at) => {
if (record.every((value) => value.trim() === "")) return
const description = column(record, "description")
const revision = column(record, "revision")
rows.push({
// The line a person sees in the file: the header is line 1.
sourceRow: at + 2,
...(revision === "" ? {} : { revision }),
product: {
code: column(record, "code"),
name: column(record, "name"),
baseUnit: column(record, "base_unit"),
...(description === "" ? {} : { description }),
},
})
})
return { kind: "rows", rows }
}
/** Rows in chunks of the size one request may carry. */
export function chunksOf<T>(rows: readonly T[]): T[][] {
const chunks: T[][] = []
for (let at = 0; at < rows.length; at += IMPORT_CHUNK_ROWS) chunks.push(rows.slice(at, at + IMPORT_CHUNK_ROWS))
return chunks
}
+14
View File
@@ -0,0 +1,14 @@
// The catalog module's pages and components. `catalogPage` is what the web shell mounts; the rest is
// exported for an app that composes its own page from the parts.
export { ImportForm, type ImportFormLabels } from "./import-form.js"
export { ImportList, type ImportListLabels } from "./import-list.js"
export { chunksOf, csvRows, parseProductCsv } from "./import-rows.js"
export {
importFormLabels,
importListLabels,
productListLabels,
type Translate,
} from "./labels.js"
export { type CatalogPage, catalogPage, serviceState } from "./pages.js"
export { ProductList, type ProductListLabels } from "./product-list.js"
+83
View File
@@ -0,0 +1,83 @@
// The labels each component takes, built from a message lookup. Every key this module's pages use is
// named here or in pages.tsx, so `__tests__/i18n.test.ts` can run these builders against the
// catalogues and fail on a key either language lacks.
//
// `t` takes a key relative to the module (`products.name`); the page binds it to
// `i18n.message("catalog." + key)`. The app's `app.failed` is passed in already looked up, and so is
// `refusals`, the kit's text for each business refusal in the reader's language (`businessRefusalNotices`).
import type { ImportFormLabels } from "./import-form.js"
import type { ImportListLabels } from "./import-list.js"
import type { ProductListLabels } from "./product-list.js"
export type Translate = (key: string, values?: Record<string, string | number>) => string
export type Refusals = Readonly<Record<string, string>>
export function productListLabels(t: Translate, failed: string, refusals: Refusals): ProductListLabels {
return {
caption: t("products.caption"),
empty: t("products.empty"),
code: t("products.code"),
name: t("products.name"),
unit: t("products.unit"),
status: t("products.status"),
// The placeholder is the component's to fill, so it is passed through untouched.
open: t("products.open", { name: "{name}" }),
search: t("products.search"),
searchIn: t("products.searchIn"),
fields: {
name: t("products.byName"),
code: t("products.byCode"),
gtin: t("products.byGtin"),
erp: t("products.byErp"),
},
searchSubmit: t("products.searchSubmit"),
more: t("products.more"),
failed,
refusals,
}
}
export function importListLabels(t: Translate, count: number): ImportListLabels {
return {
count: t("imports.count", { count }),
caption: t("imports.caption"),
empty: t("imports.empty"),
source: t("imports.source"),
status: t("imports.status"),
created: t("imports.created"),
updated: t("imports.updated"),
unchanged: t("imports.unchanged"),
refused: t("imports.refused"),
}
}
export function importFormLabels(t: Translate, failed: string, refusals: Refusals): ImportFormLabels {
return {
heading: t("importForm.heading"),
file: t("importForm.file"),
hint: t("importForm.hint"),
submit: t("importForm.submit"),
busy: t("importForm.busy"),
// The counts are the component's to fill, so the placeholders are passed through untouched.
done: t("importForm.done", {
created: "{created}",
updated: "{updated}",
unchanged: "{unchanged}",
refused: "{refused}",
}),
empty: t("importForm.empty"),
tooLarge: t("importForm.tooLarge"),
missing: t("importForm.missing", { columns: "{columns}" }),
refusedCaption: t("importForm.refusedCaption"),
row: t("importForm.row"),
reason: t("importForm.reason"),
reasons: {
invalid_fields: t("importForm.invalidFields"),
conflict: t("importForm.conflict"),
not_found: t("importForm.notFound"),
},
failed,
refusals,
}
}
+253
View File
@@ -0,0 +1,253 @@
// The catalog module's pages, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web
// shell runs on the server under `/<org>/<workspace>/<project>/catalog`, and the view of what it returned.
//
// /catalog the organization's products, and a search
// /catalog/imports the imports so far, and the form that applies one
// /catalog/<productId> one product
//
// Every read is a procedure through the same caller the API uses, and every label is translated in the
// loader. A refusal that is the platform's -- the service is not enabled for the organization, the
// deployment lacks a scope, the service did not answer -- is the page's own state, not an error page, in the
// kit's words for it (`businessServiceNotices` of `@conusai-kit/business/refusals`).
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
import type { I18n, Locale } from "@conusai-kit/i18n"
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
import { Can } from "@conusai-kit/ui/data"
import { PageHeader, ServiceStatePage, serviceNotice } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import {
CATALOG_PERMISSIONS,
type ImportView,
importList,
type ProductView,
productGet,
productList,
} from "../contract/index.js"
import { ImportForm, type ImportFormLabels } from "./import-form.js"
import { ImportList, type ImportListLabels } from "./import-list.js"
import { importFormLabels, importListLabels, productListLabels, type Translate } from "./labels.js"
import { ProductList, type ProductListLabels } from "./product-list.js"
/** A platform refusal as the page shows it: the problem's code and its text in the reader's language. */
export interface ServiceNotice {
readonly code: string
readonly text: string
}
type StatusNames = Readonly<Record<ProductView["status"], string>>
export interface CatalogListData {
readonly kind: "list"
readonly title: string
readonly intro: string
readonly importsLink: string
readonly list: ProductListLabels
readonly statuses: StatusNames
readonly products: { rows: ProductView[]; after?: string | undefined }
}
export interface CatalogProductData {
readonly kind: "product"
readonly title: string
readonly back: string
readonly product: ProductView
readonly status: string
readonly labels: {
readonly code: string
readonly unit: string
readonly status: string
readonly group: string
readonly description: string
readonly revision: string
readonly identifiers: string
readonly none: string
}
}
export interface CatalogImportsData {
readonly kind: "imports"
readonly title: string
readonly intro: string
readonly back: string
readonly form: ImportFormLabels
readonly list: ImportListLabels
readonly imports: readonly ImportView[]
}
/** The service cannot answer: the notice in place of the page. */
export interface CatalogStateData {
readonly kind: "state"
readonly title: string
readonly notice: ServiceNotice
}
export type CatalogPageData = CatalogListData | CatalogProductData | CatalogImportsData | CatalogStateData
export type CatalogPage = ProjectPage<TransportCaller, I18n, Container, CatalogPageData>
/** A product id as the service mints it: one path segment. */
const PRODUCT_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
/** The product a path segment names; a segment that does not decode names none. */
function productIdOf(segment: string): string {
try {
return decodeURIComponent(segment)
} catch {
throw notFound("no such page")
}
}
/** The notice for a platform refusal, or undefined for any other error (which the shell answers). */
export function serviceState(error: unknown, locale: Locale): ServiceNotice | undefined {
return serviceNotice(error, businessServiceNotices(locale))
}
const statusNames = (t: Translate): StatusNames => ({
active: t("statuses.active"),
archived: t("statuses.archived"),
deleted: t("statuses.deleted"),
})
export const catalogPage: CatalogPage = projectPage({
async load({ slugs, segments, caller, i18n }): Promise<CatalogPageData> {
const t: Translate = (key, values) => i18n.message(`catalog.${key}`, values)
const failed = i18n.message("app.failed")
const refusals = businessRefusalNotices(i18n.locale)
const title = t("title")
const [segment, ...rest] = segments
if (rest.length > 0) throw notFound("no such page")
const productId = segment === undefined || segment === "imports" ? undefined : productIdOf(segment)
if (productId !== undefined && !PRODUCT_ID.test(productId)) throw notFound("no such page")
try {
if (segment === "imports") {
const { rows } = await caller.call(importList, slugs)
return {
kind: "imports",
title: t("imports.title"),
intro: t("imports.intro"),
back: t("nav.products"),
form: importFormLabels(t, failed, refusals),
list: importListLabels(t, rows.length),
imports: rows,
}
}
if (productId === undefined) {
return {
kind: "list",
title,
intro: t("intro"),
importsLink: t("nav.imports"),
list: productListLabels(t, failed, refusals),
statuses: statusNames(t),
products: await caller.call(productList, slugs),
}
}
const product = await caller.call(productGet, { ...slugs, productId })
return {
kind: "product",
title,
back: t("product.back"),
product,
status: statusNames(t)[product.status],
labels: {
code: t("product.code"),
unit: t("product.unit"),
status: t("product.status"),
group: t("product.group"),
description: t("product.description"),
revision: t("product.revision"),
identifiers: t("product.identifiers"),
none: t("product.none"),
},
}
} catch (error) {
const notice = serviceState(error, i18n.locale)
if (!notice) throw error
return { kind: "state", title, notice }
}
},
View({ slugs, data }: ProjectPageViewProps<CatalogPageData>): ReactElement {
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/catalog`
return data.kind === "state" ? (
<ServiceStatePage title={data.title} notice={data.notice} />
) : (
<>
<PageHeader title={data.title} />
{data.kind === "list" ? (
<>
<p>{data.intro}</p>
<p>
<a href={`${listHref}/imports`}>{data.importsLink}</a>
</p>
<ProductList
slugs={slugs}
seed={data.products}
statuses={data.statuses}
listHref={listHref}
labels={data.list}
/>
</>
) : data.kind === "imports" ? (
<>
<p>
<a href={listHref}>{data.back}</a>
</p>
<p>{data.intro}</p>
<Can permission={CATALOG_PERMISSIONS.write}>
<ImportForm slugs={slugs} labels={data.form} />
</Can>
<ImportList imports={data.imports} labels={data.list} />
</>
) : (
<>
<p>
<a href={listHref}>{data.back}</a>
</p>
<h2>{data.product.name}</h2>
<dl>
<dt>{data.labels.code}</dt>
<dd>{data.product.code}</dd>
<dt>{data.labels.unit}</dt>
<dd>{data.product.baseUnit}</dd>
<dt>{data.labels.status}</dt>
<dd>{data.status}</dd>
<dt>{data.labels.revision}</dt>
<dd>{data.product.revision}</dd>
{data.product.groupCode === null ? null : (
<>
<dt>{data.labels.group}</dt>
<dd>{data.product.groupCode}</dd>
</>
)}
{data.product.description === "" ? null : (
<>
<dt>{data.labels.description}</dt>
<dd>{data.product.description}</dd>
</>
)}
<dt>{data.labels.identifiers}</dt>
<dd>
{data.product.identifiers.length === 0 ? (
data.labels.none
) : (
<ul>
{data.product.identifiers.map((identifier) => (
<li key={`${identifier.kind}:${identifier.value}`}>
{identifier.kind}: {identifier.value}
</li>
))}
</ul>
)}
</dd>
</dl>
</>
)}
</>
)
},
})
+82
View File
@@ -0,0 +1,82 @@
"use client"
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useDataClient } from "@conusai-kit/ui/data"
import { ResourceList, SelectField, serviceNotice, useResourceList } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import { PRODUCT_SEARCH_FIELDS, type ProductSearchField, type ProductView } from "../contract/index.js"
export interface ProductListLabels {
readonly caption: string
readonly empty: string
readonly code: string
readonly name: string
readonly unit: string
readonly status: string
/** `Open {name}`: the link's accessible name. */
readonly open: string
readonly search: string
readonly searchIn: string
readonly fields: Readonly<Record<ProductSearchField, string>>
readonly searchSubmit: string
readonly more: 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 Page = { rows: ProductView[]; after?: string | undefined }
export function ProductList({
slugs,
seed,
statuses,
listHref,
labels,
}: {
slugs: ProjectSlugs
seed: Page
statuses: Readonly<Record<ProductView["status"], string>>
listHref: string
labels: ProductListLabels
}): ReactElement {
const client = useDataClient()
const { list, filters, setFilter } = useResourceList<ProductView>({
seed,
rowId: (row) => row.productId,
searchLabel: labels.search,
describeError: (error) =>
labels.failed.replace("{detail}", serviceNotice(error, labels.refusals)?.text ?? describeError(error)),
read: async ({ q, after, filters }) =>
(
await client.call<Page>(q ? "catalog.product.search" : "catalog.product.list", {
...slugs,
...(q ? { q, by: filters.by ?? "name" } : {}),
...(after ? { after } : {}),
})
).value,
})
return (
<ResourceList
{...list}
label={labels.caption}
empty={{ title: labels.empty }}
filters={
<SelectField
label={labels.searchIn}
value={filters.by ?? "name"}
options={PRODUCT_SEARCH_FIELDS.map((value) => ({ value, label: labels.fields[value] }))}
onValueChange={(value) => setFilter("by", value)}
/>
}
columns={[
{ id: "name", header: labels.name, wrap: true },
{ id: "code", header: labels.code },
{ id: "baseUnit", header: labels.unit },
{ id: "status", header: labels.status, cell: (row) => statuses[row.status] },
]}
rowHref={(row) => `${listHref}/${encodeURIComponent(row.productId)}`}
rowLinkLabel={(row) => labels.open.replace("{name}", row.name)}
/>
)
}