@@ -0,0 +1,103 @@
|
||||
import { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
|
||||
// Simulated adapter dispatch; these fixtures do not prove gateway or partition isolation.
|
||||
// The module on its own, wired the way apps/web wires a request -- tenancy on the in-memory data
|
||||
// source, the catalog module, and the Catalog port bound to the real generated client over the
|
||||
// business-services fake of @conusai-kit/testing -- so its procedures are tested whether or not this
|
||||
// app turned the module on.
|
||||
|
||||
import { businessClient, type Client } from "@conusai-kit/business"
|
||||
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { type FakeBusinessServices, fakeBusinessServices } from "@conusai-kit/testing"
|
||||
import { createClient } from "@conusai-sdk/catalog/client"
|
||||
import { sdkCatalogService } from "../adapters/service.js"
|
||||
import { CATALOG_SERVICE } from "../core/ports.js"
|
||||
import { catalogModule } from "../module.js"
|
||||
|
||||
export interface Tenant {
|
||||
readonly owner: Actor
|
||||
readonly call: TransportCaller
|
||||
readonly orgSlug: string
|
||||
readonly orgId: string
|
||||
readonly workspaceSlug: string
|
||||
readonly workspaceId: string
|
||||
readonly projectSlug: string
|
||||
readonly projectId: string
|
||||
readonly slugs: { org: string; workspace: string; project: string }
|
||||
}
|
||||
|
||||
export function catalogApp(options: Parameters<typeof fakeBusinessServices>[0] = {}) {
|
||||
const fake: FakeBusinessServices = fakeBusinessServices(options)
|
||||
const client: Client = businessClient({ apiUrl: fake.apiUrl, token: fake.token, orgSlug: fake.orgSlug }, createClient)
|
||||
client.setConfig({ fetch: fake.fetch as typeof fetch })
|
||||
const registry = buildRegistry([tenancyModule, catalogModule])
|
||||
const entities = entitiesOf(registry, "postgres")
|
||||
const data = createMemoryDataSource({ entities })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(CATALOG_SERVICE, sdkCatalogService({ client, orgSlug: fake.orgSlug }))
|
||||
|
||||
const caller = (actor: Actor): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, serviceReadiness: simulatedBusinessAdmission }, { actor })
|
||||
}
|
||||
|
||||
const signUp = async (email: string): Promise<Actor> => {
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: email,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: user.id }
|
||||
}
|
||||
|
||||
/** An organization with one workspace and one project, owned by a new user. */
|
||||
const tenant = async (slug: string): Promise<Tenant> => {
|
||||
const owner = await signUp(`owner@${slug}.test`)
|
||||
const call = caller(owner)
|
||||
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
|
||||
const workspace = (await call.callByName("tenancy.workspace.create", {
|
||||
org: slug,
|
||||
slug: `ws-${slug}`,
|
||||
name: `Workspace ${slug}`,
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string; slug: string }
|
||||
const project = (await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: workspace.slug,
|
||||
slug: `p-${slug}`,
|
||||
name: `Project ${slug}`,
|
||||
moduleKey: "catalog",
|
||||
})) as { projectId: string; slug: string }
|
||||
return {
|
||||
owner,
|
||||
call,
|
||||
orgSlug: org.slug,
|
||||
orgId: org.orgId,
|
||||
workspaceSlug: workspace.slug,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectSlug: project.slug,
|
||||
projectId: project.projectId,
|
||||
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
|
||||
}
|
||||
}
|
||||
|
||||
/** A second user who joins `t`'s organization with the org role `role`. */
|
||||
const member = async (t: Tenant, email: string, role: "viewer" | "member"): Promise<TransportCaller> => {
|
||||
const invite = (await t.call.callByName("tenancy.member.invite", { org: t.orgSlug, email, role })) as {
|
||||
token: string
|
||||
}
|
||||
const actor = await signUp(email)
|
||||
const call = caller(actor)
|
||||
await call.callByName("tenancy.invitation.accept", { org: t.orgSlug, token: invite.token })
|
||||
return call
|
||||
}
|
||||
|
||||
return { fake, client, registry, data, caller, signUp, tenant, member }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// The catalog use cases over a recording port: what each asks the service, with no client and no network.
|
||||
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { ImportOutcomeView } from "../contract/index.js"
|
||||
import { applyImport, listProducts, requestKey, searchProducts } from "../core/catalog.js"
|
||||
import { type CatalogService, catalogReadsOf } from "../core/ports.js"
|
||||
|
||||
const scope: ProjectScope = {
|
||||
level: "project",
|
||||
actor: { userId: "u-1" },
|
||||
orgId: "o1",
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
}
|
||||
|
||||
const outcome: ImportOutcomeView = {
|
||||
import: {
|
||||
importId: "cim_1",
|
||||
sourceName: "products.csv",
|
||||
format: "csv",
|
||||
status: "applied",
|
||||
revision: "1",
|
||||
created: 1,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
refused: 0,
|
||||
},
|
||||
rows: [],
|
||||
}
|
||||
|
||||
function recording() {
|
||||
const asked: Array<[string, unknown]> = []
|
||||
const service: CatalogService = {
|
||||
listProducts: async (query) => {
|
||||
asked.push(["listProducts", query])
|
||||
return { rows: [] }
|
||||
},
|
||||
searchProducts: async (query) => {
|
||||
asked.push(["searchProducts", query])
|
||||
return { rows: [] }
|
||||
},
|
||||
getProduct: async () => {
|
||||
throw new Error("not asked")
|
||||
},
|
||||
listImports: async () => ({ rows: [] }),
|
||||
applyImport: async (input) => {
|
||||
asked.push(["applyImport", input])
|
||||
return outcome
|
||||
},
|
||||
}
|
||||
return { service, asked }
|
||||
}
|
||||
|
||||
const row = { sourceRow: 2, product: { code: "BOLT-M8", name: "Bolt M8", baseUnit: "C62" } }
|
||||
const chunk = {
|
||||
fileHash: "a".repeat(64),
|
||||
sourceName: " products.csv ",
|
||||
format: "csv" as const,
|
||||
chunk: 0,
|
||||
finalChunk: true,
|
||||
rows: [row],
|
||||
requestId: "r1",
|
||||
}
|
||||
|
||||
describe("the catalog use cases", () => {
|
||||
it("list products with the module's page size", async () => {
|
||||
const { service, asked } = recording()
|
||||
await listProducts(service, {})
|
||||
expect(asked).toEqual([["listProducts", { limit: 50 }]])
|
||||
})
|
||||
|
||||
it("search by name unless told otherwise, with the text trimmed", async () => {
|
||||
const { service, asked } = recording()
|
||||
await searchProducts(service, { q: " bolt " })
|
||||
await searchProducts(service, { q: "BOLT", by: "code", after: "x", limit: 10 })
|
||||
await searchProducts(service, { q: "4006381333931", by: "gtin" })
|
||||
expect(asked).toEqual([
|
||||
["searchProducts", { q: "bolt", limit: 50 }],
|
||||
["listProducts", { search: { by: "code", q: "BOLT" }, after: "x", limit: 10 }],
|
||||
["listProducts", { search: { by: "gtin", q: "4006381333931" }, limit: 50 }],
|
||||
])
|
||||
})
|
||||
|
||||
it("apply an import chunk with the project's request key and the source's trimmed name", async () => {
|
||||
const { service, asked } = recording()
|
||||
await applyImport(service, scope, chunk)
|
||||
expect(asked).toEqual([
|
||||
[
|
||||
"applyImport",
|
||||
{
|
||||
fileHash: "a".repeat(64),
|
||||
sourceName: "products.csv",
|
||||
format: "csv",
|
||||
chunk: 0,
|
||||
finalChunk: true,
|
||||
rows: [row],
|
||||
key: requestKey(scope, "r1"),
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("hand a read handler a view without a single write", () => {
|
||||
expect(Object.keys(catalogReadsOf(recording().service)).sort()).toEqual([
|
||||
"getProduct",
|
||||
"listImports",
|
||||
"listProducts",
|
||||
"searchProducts",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
// Every string a page of the module shows is a message in English and in Lithuanian, and no component of
|
||||
// the module carries a string of its own.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n, lintUiStrings } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { catalogModule } from "../module.js"
|
||||
import { importFormLabels, importListLabels, productListLabels } from "../ui/labels.js"
|
||||
|
||||
// The messages as an app that turned the module on has them: from the manifest, flattened by the kit.
|
||||
const catalogues = buildRegistry([tenancyModule, catalogModule]).catalogues
|
||||
const en = catalogues.en?.catalog ?? {}
|
||||
const lt = catalogues.lt?.catalog ?? {}
|
||||
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
const t = (key: string) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}
|
||||
productListLabels(t, "failed", {})
|
||||
importListLabels(t, 2)
|
||||
importFormLabels(t, "failed", {})
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the catalog catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian, none empty", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
|
||||
})
|
||||
|
||||
it("hold every message the labels ask for, and none that nothing asks for", () => {
|
||||
const asked = keysUsed()
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en is missing catalog.${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt is missing catalog.${key}`).toBeTruthy()
|
||||
}
|
||||
// The pages and the navigation ask for these directly.
|
||||
const direct = new Set([
|
||||
"title",
|
||||
"intro",
|
||||
"nav.products",
|
||||
"nav.imports",
|
||||
"product.back",
|
||||
"product.code",
|
||||
"product.unit",
|
||||
"product.status",
|
||||
"product.group",
|
||||
"product.description",
|
||||
"product.revision",
|
||||
"product.identifiers",
|
||||
"product.none",
|
||||
"statuses.active",
|
||||
"statuses.archived",
|
||||
"statuses.deleted",
|
||||
"imports.title",
|
||||
"imports.intro",
|
||||
])
|
||||
expect(Object.keys(en).filter((key) => !asked.includes(key) && !direct.has(key))).toEqual([])
|
||||
})
|
||||
|
||||
it("answer the navigation label the manifest names", () => {
|
||||
expect(catalogModule.ui?.nav?.map((item) => item.label)).toEqual(["catalog.title"])
|
||||
})
|
||||
|
||||
it("count imports with the Lithuanian one, few and other forms", () => {
|
||||
const count = (locale: "en" | "lt", n: number) =>
|
||||
createI18n(locale, { en: { catalog: en }, lt: { catalog: lt } }).message("catalog.imports.count", { count: n })
|
||||
expect(count("lt", 1)).toBe("1 importas")
|
||||
expect(count("lt", 2)).toBe("2 importai")
|
||||
expect(count("lt", 10)).toBe("10 importų")
|
||||
expect(count("lt", 21)).toBe("21 importas")
|
||||
expect(count("en", 2)).toBe("2 imports")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the components", () => {
|
||||
const dir = new URL("../ui/", import.meta.url).pathname
|
||||
const sources = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".tsx"))
|
||||
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
|
||||
|
||||
it("carry no hard-coded user-facing string and no colour literal", () => {
|
||||
expect(sources.length).toBeGreaterThanOrEqual(3)
|
||||
for (const { name, text } of sources) {
|
||||
expect(lintUiStrings(text, name), name).toEqual([])
|
||||
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
// What the import form reads out of a CSV file: rows as the service takes them, each with the line it came
|
||||
// from, chunks the service accepts, and a file it cannot read named as such.
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { chunksOf, csvRows, parseProductCsv } from "../ui/import-rows.js"
|
||||
|
||||
describe("a product CSV", () => {
|
||||
it("reads the required columns by name, in any order, with the line number a person sees", () => {
|
||||
const text = "name,base_unit,code,description\nBolt M8,C62,BOLT-M8,\n\nNut M8,C62,NUT-M8,A nut\n"
|
||||
expect(parseProductCsv(text)).toEqual({
|
||||
kind: "rows",
|
||||
rows: [
|
||||
{ sourceRow: 2, product: { code: "BOLT-M8", name: "Bolt M8", baseUnit: "C62" } },
|
||||
{ sourceRow: 4, product: { code: "NUT-M8", name: "Nut M8", baseUnit: "C62", description: "A nut" } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps a quoted comma, a doubled quote and a CRLF ending", () => {
|
||||
expect(csvRows('a,"b,c","d""e"\r\nf,,g\r\n')).toEqual([
|
||||
["a", "b,c", 'd"e'],
|
||||
["f", "", "g"],
|
||||
])
|
||||
})
|
||||
|
||||
it("carries a row's revision when the file has one", () => {
|
||||
expect(parseProductCsv("code,name,base_unit,revision\nBOLT-M8,Bolt M8,C62,4\n")).toEqual({
|
||||
kind: "rows",
|
||||
rows: [{ sourceRow: 2, revision: "4", product: { code: "BOLT-M8", name: "Bolt M8", baseUnit: "C62" } }],
|
||||
})
|
||||
})
|
||||
|
||||
it("names what is wrong with a file it cannot read, and reads an empty one as nothing", () => {
|
||||
expect(parseProductCsv("code,name\nBOLT-M8,Bolt M8\n")).toEqual({ kind: "unreadable", missing: ["base_unit"] })
|
||||
expect(parseProductCsv("")).toEqual({ kind: "unreadable", missing: ["code", "name", "base_unit"] })
|
||||
expect(parseProductCsv("code,name,base_unit\n")).toEqual({ kind: "rows", rows: [] })
|
||||
})
|
||||
|
||||
it("splits rows into chunks of the service's size", () => {
|
||||
const rows = Array.from({ length: 401 }, (_, at) => at)
|
||||
expect(chunksOf(rows).map((chunk) => chunk.length)).toEqual([200, 200, 1])
|
||||
expect(chunksOf([])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
|
||||
// folder: one fixture per tenant-scoped procedure, each called as a member of one organization with
|
||||
// another's slugs, which the resolver must refuse before the Catalog service is reached.
|
||||
//
|
||||
// The module keeps no row of its own, so it seeds nothing: what a fixture names is the other tenant's
|
||||
// project, and an id is any well-formed one (the service is never asked). The suite reads this file by
|
||||
// its place whether or not the module is on, and checks only the procedures the app registers, so
|
||||
// while the module is off (the template's default) it brings no fixture; `procedures.test.ts` runs the
|
||||
// same fixtures against the module on its own, on or off.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
import { businessModulesOn } from "../../index.js"
|
||||
|
||||
const SOMEWHERE = "00000000-0000-4000-8000-000000000001"
|
||||
const HASH = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
|
||||
export const catalogIsolationFixtures: Record<string, IsolationFixture> = {
|
||||
"catalog.product.list": { input: (t) => inProjectOf(t) },
|
||||
"catalog.product.search": { input: (t) => ({ ...inProjectOf(t), q: "anything" }) },
|
||||
"catalog.product.get": { input: (t) => ({ ...inProjectOf(t), productId: SOMEWHERE }) },
|
||||
"catalog.import.list": { input: (t) => inProjectOf(t) },
|
||||
"catalog.import.apply": {
|
||||
input: (t) => ({
|
||||
...inProjectOf(t),
|
||||
fileHash: HASH,
|
||||
sourceName: "intruder.csv",
|
||||
format: "csv",
|
||||
chunk: 0,
|
||||
finalChunk: true,
|
||||
rows: [{ sourceRow: 2, product: { code: "INTRUDER", name: "Intruder", baseUnit: "C62" } }],
|
||||
requestId: SOMEWHERE,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
/** The shape the suite reads (`modules/notes/__tests__/isolation.ts` names it), so no other module is imported. */
|
||||
export const isolation: {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
} = {
|
||||
seed: async () => ({}),
|
||||
fixtures: businessModulesOn.includes("catalog") ? catalogIsolationFixtures : {},
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// The catalog procedures end to end: the caller, the tenancy resolver, the module and the real generated
|
||||
// client against the business-services fake -- what a member sees, what the service gets, and each
|
||||
// refusal as the kit's app error (`businessAppError`) a page or a form shows.
|
||||
|
||||
import type { AppError } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { TENANCY_ALSO_CHECK, tenancyIsolationExempt, tenancyIsolationFixtures } from "@conusai-kit/tenancy/testing"
|
||||
import { type IsolationTenant, runIsolation, snapshotOf } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { sdkCatalogService } from "../adapters/service.js"
|
||||
import { catalogApp } from "./app.js"
|
||||
import { catalogIsolationFixtures } from "./isolation.js"
|
||||
|
||||
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
return error as AppError
|
||||
}
|
||||
throw new Error("the call was not refused")
|
||||
}
|
||||
|
||||
const HASH = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
const bolt = { code: "BOLT-M8", name: "Bolt M8", baseUnit: "C62" }
|
||||
const nut = { code: "NUT-M8", name: "Nut M8", baseUnit: "C62", description: "A nut" }
|
||||
|
||||
const importOf = (rows: unknown[], over: Record<string, unknown> = {}) => ({
|
||||
fileHash: HASH,
|
||||
sourceName: "products.csv",
|
||||
format: "csv",
|
||||
chunk: 0,
|
||||
finalChunk: true,
|
||||
rows,
|
||||
requestId: crypto.randomUUID(),
|
||||
...over,
|
||||
})
|
||||
|
||||
async function seeded(options: Parameters<typeof catalogApp>[0] = {}) {
|
||||
const app = catalogApp(options)
|
||||
const t = await app.tenant("alpha")
|
||||
await t.call.callByName("catalog.import.apply", {
|
||||
...t.slugs,
|
||||
...importOf([
|
||||
{ sourceRow: 2, product: bolt },
|
||||
{ sourceRow: 3, product: nut },
|
||||
]),
|
||||
})
|
||||
return { app, t }
|
||||
}
|
||||
|
||||
describe("the catalog procedures", () => {
|
||||
it("apply an import, and read its products, with every int64 a plain value", async () => {
|
||||
const app = catalogApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const requestId = crypto.randomUUID()
|
||||
const applied = await t.call.callByName("catalog.import.apply", {
|
||||
...t.slugs,
|
||||
...importOf([{ sourceRow: 2, product: bolt }], { requestId }),
|
||||
})
|
||||
expect(applied).toMatchObject({
|
||||
import: { sourceName: "products.csv", status: "applied", revision: "1", created: 1, refused: 0 },
|
||||
rows: [{ sourceRow: 2, outcome: "created", reason: null }],
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(applied))).toEqual(applied)
|
||||
// A retried request applies once.
|
||||
expect(
|
||||
await t.call.callByName("catalog.import.apply", {
|
||||
...t.slugs,
|
||||
...importOf([{ sourceRow: 2, product: bolt }], { requestId }),
|
||||
}),
|
||||
).toEqual(applied)
|
||||
expect(app.fake.catalog.products.size).toBe(1)
|
||||
|
||||
const listed = (await t.call.callByName("catalog.product.list", t.slugs)) as {
|
||||
rows: Array<{ productId: string }>
|
||||
}
|
||||
expect(listed.rows).toEqual([
|
||||
expect.objectContaining({ code: "BOLT-M8", name: "Bolt M8", baseUnit: "C62", status: "active", revision: "1" }),
|
||||
])
|
||||
const productId = listed.rows[0]?.productId as string
|
||||
expect(await t.call.callByName("catalog.product.get", { ...t.slugs, productId })).toEqual(listed.rows[0])
|
||||
expect(await t.call.callByName("catalog.import.list", t.slugs)).toMatchObject({
|
||||
rows: [{ sourceName: "products.csv", created: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
it("search by name, by code and by identifier", async () => {
|
||||
const { t } = await seeded()
|
||||
const search = async (q: string, by?: string) =>
|
||||
(
|
||||
(await t.call.callByName("catalog.product.search", { ...t.slugs, q, ...(by ? { by } : {}) })) as {
|
||||
rows: Array<{ code: string }>
|
||||
}
|
||||
).rows.map((row) => row.code)
|
||||
expect(await search(" nut ")).toEqual(["NUT-M8"])
|
||||
expect(await search("BOLT", "code")).toEqual(["BOLT-M8"])
|
||||
expect(await search("nothing")).toEqual([])
|
||||
expect(await search("4006381333931", "gtin")).toEqual([])
|
||||
})
|
||||
|
||||
it("carry a chunk's revision forward, and report a refused row without losing the others", async () => {
|
||||
const app = catalogApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const first = (await t.call.callByName("catalog.import.apply", {
|
||||
...t.slugs,
|
||||
...importOf([{ sourceRow: 2, product: bolt }], { finalChunk: false }),
|
||||
})) as { import: { revision: string; status: string } }
|
||||
expect(first.import.status).toBe("applying")
|
||||
const second = await t.call.callByName("catalog.import.apply", {
|
||||
...t.slugs,
|
||||
...importOf(
|
||||
[
|
||||
{ sourceRow: 3, product: nut },
|
||||
{ sourceRow: 4, product: { code: "BAD CODE", name: "Bad", baseUnit: "C62" } },
|
||||
],
|
||||
{ chunk: 1, revision: first.import.revision },
|
||||
),
|
||||
})
|
||||
expect(second).toMatchObject({
|
||||
import: { status: "applying", revision: "2", created: 2, refused: 1 },
|
||||
rows: [
|
||||
{ sourceRow: 3, outcome: "created" },
|
||||
{ sourceRow: 4, outcome: "refused", productId: null, reason: "invalid_fields" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("show the service's refusals in the service's words", async () => {
|
||||
const app = catalogApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const missing = await refusal(t.call.callByName("catalog.product.get", { ...t.slugs, productId: "nobody" }))
|
||||
expect(missing.problem).toMatchObject({ status: 404, code: "business_not_found" })
|
||||
const stale = await refusal(
|
||||
t.call.callByName("catalog.import.apply", {
|
||||
...t.slugs,
|
||||
...importOf([{ sourceRow: 2, product: bolt }], { chunk: 1 }),
|
||||
}),
|
||||
)
|
||||
expect(stale.problem).toMatchObject({ status: 409, code: "business_conflict" })
|
||||
})
|
||||
|
||||
it("answer a disabled service, a missing scope and a silent service with the platform's own codes", async () => {
|
||||
const app = catalogApp({ catalog: false })
|
||||
const t = await app.tenant("alpha")
|
||||
const disabled = await refusal(t.call.callByName("catalog.product.list", t.slugs))
|
||||
expect(disabled.problem).toMatchObject({ status: 404, code: "service_not_enabled" })
|
||||
app.fake.enable("catalog")
|
||||
app.fake.revoke("catalog.imports:write")
|
||||
const scoped = await refusal(
|
||||
t.call.callByName("catalog.import.apply", { ...t.slugs, ...importOf([{ sourceRow: 2, product: bolt }]) }),
|
||||
)
|
||||
expect(scoped.problem).toMatchObject({ status: 403, code: "service_permission_missing" })
|
||||
expect(scoped.problem.detail).not.toContain("catalog.imports:write")
|
||||
app.fake.refuseNext(503)
|
||||
const silent = await refusal(t.call.callByName("catalog.product.list", t.slugs))
|
||||
expect(silent.problem).toMatchObject({ status: 503, code: "business_unavailable" })
|
||||
})
|
||||
|
||||
it("let a viewer read and refuse their import before the service is called", async () => {
|
||||
const { app, t } = await seeded()
|
||||
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
|
||||
const listed = (await viewer.callByName("catalog.product.list", t.slugs)) as { rows: unknown[] }
|
||||
expect(listed.rows).toHaveLength(2)
|
||||
const before = app.fake.calls.length
|
||||
// A member without the permission is answered as a non-member is (kit-core's caller, spec §3.3).
|
||||
const refused = await refusal(
|
||||
viewer.callByName("catalog.import.apply", { ...t.slugs, ...importOf([{ sourceRow: 9, product: bolt }]) }),
|
||||
)
|
||||
expect(refused.problem.status).toBe(404)
|
||||
expect(app.fake.calls.length).toBe(before)
|
||||
})
|
||||
|
||||
it("need no scope beyond the ones `kit module:on catalog` asks for", async () => {
|
||||
const { t } = await seeded({ scopes: ["catalog.products:read", "catalog.imports:read", "catalog.imports:write"] })
|
||||
const { rows } = (await t.call.callByName("catalog.product.list", t.slugs)) as {
|
||||
rows: Array<{ productId: string }>
|
||||
}
|
||||
expect(rows).toHaveLength(2)
|
||||
await t.call.callByName("catalog.product.search", { ...t.slugs, q: "nut" })
|
||||
await t.call.callByName("catalog.product.get", { ...t.slugs, productId: rows[0]?.productId })
|
||||
await t.call.callByName("catalog.import.list", t.slugs)
|
||||
})
|
||||
|
||||
it("refuses a record of another platform organization: the token reaches only its own", async () => {
|
||||
const { app } = await seeded()
|
||||
const [product] = [...app.fake.catalog.products.values()]
|
||||
const elsewhere = sdkCatalogService({ client: app.client, orgSlug: "another-organization" })
|
||||
await expect(elsewhere.getProduct(product?.id as string)).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("the catalog module's tenant isolation", () => {
|
||||
it("refuses every procedure called with another organization's slugs, and never reaches the service", async () => {
|
||||
const app = catalogApp()
|
||||
const tenant = async (slug: string): Promise<IsolationTenant> => {
|
||||
const t = await app.tenant(slug)
|
||||
return {
|
||||
orgSlug: t.orgSlug,
|
||||
workspaceSlug: t.workspaceSlug,
|
||||
projectSlug: t.projectSlug,
|
||||
orgId: t.orgId,
|
||||
workspaceId: t.workspaceId,
|
||||
projectId: t.projectId,
|
||||
userId: t.owner.userId,
|
||||
email: `owner@${slug}.test`,
|
||||
extra: {},
|
||||
}
|
||||
}
|
||||
const a = await tenant("alpha")
|
||||
const b = await tenant("bravo")
|
||||
const result = await runIsolation({
|
||||
registry: app.registry,
|
||||
seed: async () => ({ a, b }),
|
||||
caller: (actor) => app.caller(actor),
|
||||
snapshot: () => snapshotOf(app.data, entitiesOf(app.registry, "postgres"), { outbox: app.data.outbox }),
|
||||
fixtures: { ...tenancyIsolationFixtures(), ...catalogIsolationFixtures },
|
||||
alsoCheck: [...TENANCY_ALSO_CHECK],
|
||||
exempt: tenancyIsolationExempt,
|
||||
})
|
||||
expect(result.failures).toEqual([])
|
||||
for (const name of Object.keys(catalogIsolationFixtures)) expect(result.passed, name).toContain(name)
|
||||
expect(app.fake.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
// The pages and components, rendered the way the shell renders them, from the loader's data: the platform's
|
||||
// refusals are the page's own states, a control is offered to whoever may use it, every table has a
|
||||
// caption and scoped headers.
|
||||
|
||||
import { AppError, buildRegistry, Container, type TransportCaller } from "@conusai-kit/core"
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { createDataClient, DataProvider, type PermissionKey, PermissionsProvider } from "@conusai-kit/ui/data"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactNode } from "react"
|
||||
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { modulePages } from "../../pages.js"
|
||||
import { catalogModule } from "../module.js"
|
||||
import { catalogPage } from "../ui/pages.js"
|
||||
|
||||
const catalogues = buildRegistry([tenancyModule, catalogModule]).catalogues
|
||||
const i18n = createI18n("en", {
|
||||
en: { catalog: catalogues.en?.catalog ?? {}, app: { failed: "That did not work: {detail}" } },
|
||||
})
|
||||
const lithuanian = createI18n("lt", {
|
||||
lt: { catalog: catalogues.lt?.catalog ?? {}, app: { failed: "Nepavyko: {detail}" } },
|
||||
})
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
const EVERYTHING: readonly PermissionKey[] = ["catalog.product.read", "catalog.product.write"]
|
||||
|
||||
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
const renderToStaticMarkup = (node: ReactNode) =>
|
||||
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
||||
|
||||
const inProvider = (node: ReactNode, permissions: readonly PermissionKey[] = EVERYTHING) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
<PermissionsProvider scope={slugs} seed={{ scope: slugs, permissions, revision: "1" }}>
|
||||
{node}
|
||||
</PermissionsProvider>
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
const callerOf = (answers: Record<string, unknown>): TransportCaller =>
|
||||
({
|
||||
call: async (contract: { name: string }) => {
|
||||
const answer = answers[contract.name]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
}) as unknown as TransportCaller
|
||||
|
||||
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
|
||||
const render = async (segments: string[], answers: Record<string, unknown>, permissions?: readonly PermissionKey[]) => {
|
||||
const data = await catalogPage.load({
|
||||
slugs,
|
||||
segments,
|
||||
search: {},
|
||||
caller: callerOf(answers),
|
||||
i18n,
|
||||
container: new Container(),
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
const { View } = catalogPage
|
||||
return inProvider(
|
||||
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />,
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
|
||||
const product = {
|
||||
productId: "prd_1",
|
||||
code: "BOLT-M8",
|
||||
name: "Bolt M8",
|
||||
description: "A bolt",
|
||||
baseUnit: "C62",
|
||||
status: "active",
|
||||
groupCode: null,
|
||||
identifiers: [{ kind: "gtin", value: "4006381333931" }],
|
||||
revision: "3",
|
||||
}
|
||||
|
||||
const escaped = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
/** Every label of a form names a control the page holds, and every hint is in its control's description. */
|
||||
const expectLabelled = (html: string, labels: number): void => {
|
||||
const targets = [...html.matchAll(/<label[^>]* for="([^"]+)"/g)].map((match) => match[1] ?? "")
|
||||
expect(targets).toHaveLength(labels)
|
||||
for (const id of targets) expect(html).toMatch(new RegExp(`<(?:input|select|button)[^>]* id="${escaped(id)}"`))
|
||||
for (const [, id = ""] of html.matchAll(/<p id="([^"]+)" data-slot="field-description"/g))
|
||||
expect(html).toMatch(new RegExp(`aria-describedby="(?:[^"]* )?${escaped(id)}[ "]`))
|
||||
}
|
||||
|
||||
const applied = {
|
||||
importId: "cim_1",
|
||||
sourceName: "products.csv",
|
||||
format: "csv",
|
||||
status: "applied",
|
||||
revision: "1",
|
||||
created: 2,
|
||||
updated: 1,
|
||||
unchanged: 0,
|
||||
refused: 0,
|
||||
}
|
||||
|
||||
describe("the catalog page", () => {
|
||||
it("lists the products, with the way to the imports", async () => {
|
||||
const html = await render([], { "catalog.product.list": { rows: [product] } })
|
||||
expect(html).toMatch(/<h1\b[^>]*>Products<\/h1>/)
|
||||
expect(html).toMatch(
|
||||
/<a\b(?=[^>]*href="\/acme\/ws\/p\/catalog\/prd_1")(?=[^>]*aria-label="Open Bolt M8")[^>]*>Bolt M8<\/a>/,
|
||||
)
|
||||
expect(html).toMatch(/<table\b[^>]*aria-label="Products"/)
|
||||
expect(html).toMatch(/<th\b[^>]*role="columnheader"[^>]*>Code<\/th>/)
|
||||
expect(html).toContain('href="/acme/ws/p/catalog/imports"')
|
||||
})
|
||||
|
||||
it("shows one product with its identifiers and revision", async () => {
|
||||
const html = await render(["prd_1"], { "catalog.product.get": product })
|
||||
expect(html).toContain("<h2>Bolt M8</h2>")
|
||||
expect(html).toContain("<dd>BOLT-M8</dd>")
|
||||
expect(html).toContain("<dd>3</dd>")
|
||||
expect(html).toContain("4006381333931")
|
||||
expect(html).toContain('href="/acme/ws/p/catalog"')
|
||||
})
|
||||
|
||||
it("lists the imports, with the form for whoever may apply one", async () => {
|
||||
const answers = { "catalog.import.list": { rows: [applied] } }
|
||||
const html = await render(["imports"], answers)
|
||||
expect(html).toMatch(/<h1\b[^>]*>Imports<\/h1>/)
|
||||
expect(html).toMatch(/<th\b[^>]*scope="row"[^>]*>products\.csv<\/th>/)
|
||||
expect(html).toMatch(/<caption\b[^>]*>Imports<\/caption>/)
|
||||
expect(html).toContain('type="file"')
|
||||
expectLabelled(html, 1)
|
||||
expect(html.match(/data-slot="field-description"/g)).toHaveLength(1)
|
||||
expect(await render(["imports"], answers, ["catalog.product.read"])).not.toContain('type="file"')
|
||||
})
|
||||
|
||||
it.each([
|
||||
["service_not_enabled", "This service is not enabled for your organization."],
|
||||
["service_permission_missing", "This app is not allowed to use this service. Ask an administrator to grant it."],
|
||||
["business_unavailable", "The service did not answer. Try again later."],
|
||||
])("shows the kit's state %s", async (code, text) => {
|
||||
const refused = new AppError({ type: `/problems/${code}`, title: "Refused", status: 503, code, detail: "why" })
|
||||
const html = await render([], { "catalog.product.list": refused })
|
||||
expect(html).toContain(`data-state="${code}"`)
|
||||
expect(html).toContain('role="status"')
|
||||
expect(html).toContain(text)
|
||||
expect(html).not.toContain("<table")
|
||||
})
|
||||
|
||||
it("shows the same state in Lithuanian", async () => {
|
||||
const code = "service_not_enabled"
|
||||
const refused = new AppError({ type: `/problems/${code}`, title: "Refused", status: 404, code })
|
||||
const data = await catalogPage.load({
|
||||
slugs,
|
||||
segments: [],
|
||||
search: {},
|
||||
caller: callerOf({ "catalog.product.list": refused }),
|
||||
i18n: lithuanian,
|
||||
container: new Container(),
|
||||
})
|
||||
expect(data).toMatchObject({
|
||||
kind: "state",
|
||||
notice: { code, text: "Ši paslauga jūsų organizacijai neįjungta." },
|
||||
})
|
||||
})
|
||||
|
||||
it("leaves a product that is not there, and a path it does not have, to the shell", async () => {
|
||||
const missing = new AppError({
|
||||
type: "/problems/business_not_found",
|
||||
title: "Not found",
|
||||
status: 404,
|
||||
code: "business_not_found",
|
||||
})
|
||||
await expect(render(["prd_2"], { "catalog.product.get": missing })).rejects.toMatchObject({
|
||||
problem: { code: "business_not_found" },
|
||||
})
|
||||
await expect(render(["prd_1", "x"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
await expect(render(["imports", "x"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
await expect(render(["%2Fetc"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
await expect(render(["%E0%A4%A"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
})
|
||||
|
||||
it("is listed for the shell", async () => {
|
||||
await expect(modulePages.catalog?.()).resolves.toBe(catalogPage)
|
||||
})
|
||||
})
|
||||
|
||||
// Explicit synthetic admission: module-local fixtures exercise the selected module.
|
||||
vi.mock("../../inclusion.json", () => ({
|
||||
default: { schemaVersion: 1, selected: ["catalog"], everAdopted: ["catalog"] },
|
||||
}))
|
||||
@@ -0,0 +1,49 @@
|
||||
// The handlers of the module's procedures: each takes the service its kind may use and calls one use
|
||||
// case. No rule lives here.
|
||||
|
||||
import { implement } from "@conusai-kit/core"
|
||||
import { importApply, importList, productGet, productList, productSearch } from "../contract/index.js"
|
||||
import { applyImport, listImports, listProducts, searchProducts } from "../core/catalog.js"
|
||||
import { catalogServiceOf } from "./service.js"
|
||||
|
||||
export const productListImpl = implement(productList, ({ input, ctx }) =>
|
||||
listProducts(catalogServiceOf(ctx.container, ctx.kind), {
|
||||
...(input.after === undefined ? {} : { after: input.after }),
|
||||
...(input.limit === undefined ? {} : { limit: input.limit }),
|
||||
}),
|
||||
)
|
||||
|
||||
export const productSearchImpl = implement(productSearch, ({ input, ctx }) =>
|
||||
searchProducts(catalogServiceOf(ctx.container, ctx.kind), {
|
||||
q: input.q,
|
||||
...(input.by === undefined ? {} : { by: input.by }),
|
||||
...(input.after === undefined ? {} : { after: input.after }),
|
||||
...(input.limit === undefined ? {} : { limit: input.limit }),
|
||||
}),
|
||||
)
|
||||
|
||||
export const productGetImpl = implement(productGet, ({ input, ctx }) =>
|
||||
catalogServiceOf(ctx.container, ctx.kind).getProduct(input.productId),
|
||||
)
|
||||
|
||||
export const importListImpl = implement(importList, ({ input, ctx }) =>
|
||||
listImports(catalogServiceOf(ctx.container, ctx.kind), {
|
||||
...(input.after === undefined ? {} : { after: input.after }),
|
||||
...(input.limit === undefined ? {} : { limit: input.limit }),
|
||||
}),
|
||||
)
|
||||
|
||||
export const importApplyImpl = implement(importApply, ({ input, ctx }) =>
|
||||
applyImport(catalogServiceOf(ctx.container, "effect"), ctx.scope, {
|
||||
fileHash: input.fileHash,
|
||||
sourceName: input.sourceName,
|
||||
format: input.format,
|
||||
chunk: input.chunk,
|
||||
finalChunk: input.finalChunk,
|
||||
...(input.revision === undefined ? {} : { revision: input.revision }),
|
||||
rows: input.rows,
|
||||
requestId: input.requestId,
|
||||
}),
|
||||
)
|
||||
|
||||
export const CATALOG_PROCEDURES = [productListImpl, productSearchImpl, productGetImpl, importListImpl, importApplyImpl]
|
||||
@@ -0,0 +1,172 @@
|
||||
// Generated SDK adapter. Production dispatch requires a server-prepared request binding.
|
||||
|
||||
import { businessAppError, type Client, requestBusinessBinding, toBusinessError } from "@conusai-kit/business"
|
||||
import type { Container, ProcedureKind } from "@conusai-kit/core"
|
||||
import {
|
||||
type CatalogImport,
|
||||
catalogFindImports,
|
||||
catalogFindProducts,
|
||||
catalogGetProduct,
|
||||
catalogImportProducts,
|
||||
catalogSearchProducts,
|
||||
type ImportOutcome,
|
||||
type Product,
|
||||
} from "@conusai-sdk/catalog"
|
||||
import { createClient } from "@conusai-sdk/catalog/client"
|
||||
import type { ImportOutcomeView, ImportView, ProductSearchField, ProductView } from "../contract/index.js"
|
||||
import { CATALOG_SERVICE, type CatalogReads, type CatalogService, catalogReadsOf } from "../core/ports.js"
|
||||
|
||||
const productView = (product: Product): ProductView => ({
|
||||
productId: product.id,
|
||||
code: product.code,
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
baseUnit: product.base_unit,
|
||||
status: product.status,
|
||||
groupCode: product.group_code ?? null,
|
||||
identifiers: (product.identifiers ?? []).map(({ kind, value }) => ({ kind, value })),
|
||||
revision: String(product.revision),
|
||||
})
|
||||
|
||||
const importView = (row: CatalogImport): ImportView => ({
|
||||
importId: row.id,
|
||||
sourceName: row.source_name,
|
||||
format: row.format,
|
||||
status: row.status,
|
||||
revision: String(row.revision),
|
||||
created: Number(row.created),
|
||||
updated: Number(row.updated),
|
||||
unchanged: Number(row.unchanged),
|
||||
refused: Number(row.refused),
|
||||
})
|
||||
|
||||
const outcomeView = (outcome: ImportOutcome): ImportOutcomeView => ({
|
||||
import: importView(outcome.import),
|
||||
rows: outcome.rows.map((row) => ({
|
||||
sourceRow: row.source_row,
|
||||
outcome: row.outcome,
|
||||
productId: row.product_id ?? null,
|
||||
reason: row.reason ?? null,
|
||||
})),
|
||||
})
|
||||
|
||||
/** What a lookup asks the service: a code prefix or an identifier. A name goes to the ranked search. */
|
||||
function searchQuery(search: { by: Exclude<ProductSearchField, "name">; q: string }) {
|
||||
switch (search.by) {
|
||||
case "code":
|
||||
return { code_prefix: search.q }
|
||||
case "gtin":
|
||||
case "erp":
|
||||
return { identifier_kind: search.by, identifier_value: search.q }
|
||||
}
|
||||
}
|
||||
|
||||
/** What a generated operation answered: its data, or the kit's app error for the refusal (`businessAppError`). */
|
||||
async function answer<T>(
|
||||
call: Promise<{ data?: T; error?: unknown; response?: Response }>,
|
||||
): Promise<Exclude<T, undefined>> {
|
||||
let result: { data?: T; error?: unknown; response?: Response }
|
||||
try {
|
||||
result = await call
|
||||
} catch (error) {
|
||||
throw businessAppError(toBusinessError(error, undefined))
|
||||
}
|
||||
if (result.error !== undefined || result.data === undefined) {
|
||||
throw businessAppError(toBusinessError(result.error, result.response))
|
||||
}
|
||||
return result.data as Exclude<T, undefined>
|
||||
}
|
||||
|
||||
/** The port over one client, in one organization. */
|
||||
export function sdkCatalogService(binding: { client: Client; orgSlug: string }): CatalogService {
|
||||
const { client } = binding
|
||||
const org = { org_slug: binding.orgSlug }
|
||||
return {
|
||||
async listProducts({ search, after, limit }) {
|
||||
const page = await answer(
|
||||
catalogFindProducts({
|
||||
client,
|
||||
path: org,
|
||||
query: {
|
||||
...(search ? searchQuery(search) : {}),
|
||||
...(after === undefined ? {} : { after }),
|
||||
limit: BigInt(limit),
|
||||
},
|
||||
}),
|
||||
)
|
||||
return { rows: page.data.map(productView), ...(page.next_after ? { after: page.next_after } : {}) }
|
||||
},
|
||||
async searchProducts({ q, after, limit }) {
|
||||
const page = await answer(
|
||||
catalogSearchProducts({
|
||||
client,
|
||||
path: org,
|
||||
query: { q, ...(after === undefined ? {} : { after }), limit: BigInt(limit) },
|
||||
}),
|
||||
)
|
||||
return {
|
||||
rows: page.data.map((hit) => productView(hit.product)),
|
||||
...(page.next_after ? { after: page.next_after } : {}),
|
||||
}
|
||||
},
|
||||
async getProduct(productId) {
|
||||
return productView(await answer(catalogGetProduct({ client, path: { ...org, product_id: productId } })))
|
||||
},
|
||||
async listImports({ after, limit }) {
|
||||
const page = await answer(
|
||||
catalogFindImports({
|
||||
client,
|
||||
path: org,
|
||||
query: { ...(after === undefined ? {} : { after }), limit: BigInt(limit) },
|
||||
}),
|
||||
)
|
||||
return { rows: page.data.map(importView), ...(page.next_after ? { after: page.next_after } : {}) }
|
||||
},
|
||||
async applyImport(input) {
|
||||
const outcome = await answer(
|
||||
catalogImportProducts({
|
||||
client,
|
||||
path: org,
|
||||
body: {
|
||||
file_hash: input.fileHash,
|
||||
source_name: input.sourceName,
|
||||
format: input.format,
|
||||
chunk: input.chunk,
|
||||
final_chunk: input.finalChunk,
|
||||
...(input.revision === undefined ? {} : { revision: BigInt(input.revision) }),
|
||||
rows: input.rows.map((row) => ({
|
||||
source_row: row.sourceRow,
|
||||
...(row.revision === undefined ? {} : { revision: BigInt(row.revision) }),
|
||||
product: {
|
||||
code: row.product.code,
|
||||
name: row.product.name,
|
||||
base_unit: row.product.baseUnit,
|
||||
...(row.product.description === undefined ? {} : { description: row.product.description }),
|
||||
},
|
||||
})),
|
||||
idempotency_key: input.key,
|
||||
},
|
||||
}),
|
||||
)
|
||||
return outcomeView(outcome)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The service a handler of `kind` may use: the bound one (the token's view for the kind applies), or
|
||||
* the current verified request's, narrowed to its reads for a `read` or `write` handler as the token would.
|
||||
*/
|
||||
export function catalogServiceOf(container: Container, kind: "effect"): CatalogService
|
||||
export function catalogServiceOf(container: Container, kind: ProcedureKind): CatalogReads
|
||||
export function catalogServiceOf(container: Container, kind: ProcedureKind): CatalogReads {
|
||||
if (container.has(CATALOG_SERVICE)) return container.resolve(CATALOG_SERVICE) as CatalogReads
|
||||
let service: CatalogService
|
||||
try {
|
||||
const binding = requestBusinessBinding(container)
|
||||
service = sdkCatalogService({ client: binding.client(createClient), orgSlug: binding.orgSlug })
|
||||
} catch (error) {
|
||||
throw businessAppError(error)
|
||||
}
|
||||
return kind === "effect" ? service : catalogReadsOf(service)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// The module's names, in a file with no import: a browser bundle can take them without the schemas.
|
||||
|
||||
/** The platform service this module is a client of (B-wave contract §3.14). */
|
||||
export const CATALOG_SERVICE_NAME = "catalog"
|
||||
|
||||
/** The app permissions of the module. */
|
||||
export const CATALOG_PERMISSIONS = {
|
||||
read: "catalog.product.read",
|
||||
write: "catalog.product.write",
|
||||
} as const
|
||||
|
||||
/** What a search looks in: the name, the start of the code, or one identifier. */
|
||||
export const PRODUCT_SEARCH_FIELDS = ["name", "code", "gtin", "erp"] as const
|
||||
|
||||
/** The most rows one page of a list asks the service for. */
|
||||
export const PAGE_LIMIT = 50
|
||||
|
||||
/** The most rows one import chunk carries: the service's own limit. */
|
||||
export const IMPORT_CHUNK_ROWS = 200
|
||||
|
||||
/** The largest file the import form reads in the browser. */
|
||||
export const MAX_IMPORT_BYTES = 5_000_000
|
||||
@@ -0,0 +1,26 @@
|
||||
// The public API of the catalog module: what a page, another module and the OpenAPI document may import.
|
||||
// Nothing here reaches core/, adapters/ or a service client.
|
||||
|
||||
export {
|
||||
CATALOG_PERMISSIONS,
|
||||
CATALOG_SERVICE_NAME,
|
||||
IMPORT_CHUNK_ROWS,
|
||||
MAX_IMPORT_BYTES,
|
||||
PAGE_LIMIT,
|
||||
PRODUCT_SEARCH_FIELDS,
|
||||
} from "./constants.js"
|
||||
export {
|
||||
type ImportOutcomeView,
|
||||
type ImportRow,
|
||||
type ImportView,
|
||||
importApply,
|
||||
importList,
|
||||
importOutcomeSchema,
|
||||
importViewSchema,
|
||||
type ProductSearchField,
|
||||
type ProductView,
|
||||
productGet,
|
||||
productList,
|
||||
productSearch,
|
||||
productViewSchema,
|
||||
} from "./procedures.js"
|
||||
@@ -0,0 +1,132 @@
|
||||
// The procedure contracts of the catalog module: schemas and names, no handler and no port.
|
||||
//
|
||||
// The module is a client of the platform's Catalog service (B-wave contract §3.14): products and their
|
||||
// imports live there, in the deployment's organization, and every procedure is one service call. A read
|
||||
// reads; an import is an `effect` (it calls an external service and writes nothing of this app's own).
|
||||
// The service's int64 fields are plain values here, so what a page loads is JSON: a revision is its digits,
|
||||
// a count a number.
|
||||
|
||||
import { procedure } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
import { CATALOG_PERMISSIONS, IMPORT_CHUNK_ROWS, PRODUCT_SEARCH_FIELDS } from "./constants.js"
|
||||
|
||||
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
|
||||
const page = { after: z.string().max(64).optional(), limit: z.number().int().min(1).max(200).optional() }
|
||||
/** What a form generates once per submission, so a retried request applies once. */
|
||||
const requestId = z.string().uuid()
|
||||
const productId = z.string().min(1).max(64)
|
||||
/** A revision as its digits: the service's are 64-bit. */
|
||||
const revision = z.string().regex(/^\d{1,19}$/)
|
||||
|
||||
export const productViewSchema = z.object({
|
||||
productId: z.string(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
/** A UN/CEFACT Recommendation 20 unit code. */
|
||||
baseUnit: z.string(),
|
||||
status: z.enum(["active", "archived", "deleted"]),
|
||||
groupCode: z.string().nullable(),
|
||||
identifiers: z.array(z.object({ kind: z.string(), value: z.string() })),
|
||||
revision,
|
||||
})
|
||||
export type ProductView = z.output<typeof productViewSchema>
|
||||
|
||||
export const importViewSchema = z.object({
|
||||
importId: z.string(),
|
||||
sourceName: z.string(),
|
||||
format: z.string(),
|
||||
status: z.string(),
|
||||
revision,
|
||||
created: z.number().int(),
|
||||
updated: z.number().int(),
|
||||
unchanged: z.number().int(),
|
||||
refused: z.number().int(),
|
||||
})
|
||||
export type ImportView = z.output<typeof importViewSchema>
|
||||
|
||||
export const importOutcomeSchema = z.object({
|
||||
import: importViewSchema,
|
||||
rows: z.array(
|
||||
z.object({
|
||||
sourceRow: z.number().int(),
|
||||
outcome: z.string(),
|
||||
productId: z.string().nullable(),
|
||||
reason: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type ImportOutcomeView = z.output<typeof importOutcomeSchema>
|
||||
|
||||
const importRowSchema = z.object({
|
||||
/** The line of the source file, which the service keeps as the row's provenance. */
|
||||
sourceRow: z.number().int().min(1),
|
||||
/** The revision the row replaces, for a product that exists. */
|
||||
revision: revision.optional(),
|
||||
product: z.object({
|
||||
code: z.string().min(1).max(64),
|
||||
name: z.string().min(1).max(200),
|
||||
baseUnit: z.string().min(1).max(3),
|
||||
description: z.string().max(4000).optional(),
|
||||
}),
|
||||
})
|
||||
export type ImportRow = z.output<typeof importRowSchema>
|
||||
|
||||
const productPage = z.object({ rows: z.array(productViewSchema), after: z.string().optional() })
|
||||
|
||||
export const productList = procedure("catalog.product.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(CATALOG_PERMISSIONS.read)
|
||||
.input(z.object({ ...inProject, ...page }))
|
||||
.output(productPage)
|
||||
.contract()
|
||||
|
||||
const searchField = z.enum(PRODUCT_SEARCH_FIELDS)
|
||||
export type ProductSearchField = z.output<typeof searchField>
|
||||
|
||||
export const productSearch = procedure("catalog.product.search")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(CATALOG_PERMISSIONS.read)
|
||||
.input(z.object({ ...inProject, q: z.string().trim().min(1).max(200), by: searchField.optional(), ...page }))
|
||||
.output(productPage)
|
||||
.contract()
|
||||
|
||||
export const productGet = procedure("catalog.product.get")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(CATALOG_PERMISSIONS.read)
|
||||
.input(z.object({ ...inProject, productId }))
|
||||
.output(productViewSchema)
|
||||
.contract()
|
||||
|
||||
export const importList = procedure("catalog.import.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(CATALOG_PERMISSIONS.read)
|
||||
.input(z.object({ ...inProject, ...page }))
|
||||
.output(z.object({ rows: z.array(importViewSchema), after: z.string().optional() }))
|
||||
.contract()
|
||||
|
||||
export const importApply = procedure("catalog.import.apply")
|
||||
.kind("effect")
|
||||
.scope("project")
|
||||
.permission(CATALOG_PERMISSIONS.write)
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
/** The source file's lower-case SHA-256: one file is one import, however many chunks it takes. */
|
||||
fileHash: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
sourceName: z.string().trim().min(1).max(200),
|
||||
format: z.enum(["csv", "xlsx", "json"]),
|
||||
chunk: z.number().int().min(0),
|
||||
finalChunk: z.boolean(),
|
||||
/** The revision of the import, from the chunk before; absent on the first. */
|
||||
revision: revision.optional(),
|
||||
rows: z.array(importRowSchema).min(1).max(IMPORT_CHUNK_ROWS),
|
||||
requestId,
|
||||
}),
|
||||
)
|
||||
.output(importOutcomeSchema)
|
||||
.contract()
|
||||
@@ -0,0 +1,70 @@
|
||||
// What the catalog module does, as pure TypeScript over the port in ports.ts.
|
||||
//
|
||||
// Products are the organization's: every project of the app sees the same ones (a multi-tenant app keeps
|
||||
// all its tenants' products in its deployment's one organization; partitioning by app tenant is an open
|
||||
// owner decision, B-wave contract §11.4).
|
||||
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import {
|
||||
type ImportOutcomeView,
|
||||
type ImportRow,
|
||||
type ImportView,
|
||||
PAGE_LIMIT,
|
||||
type ProductSearchField,
|
||||
type ProductView,
|
||||
} from "../contract/index.js"
|
||||
import type { CatalogReads, CatalogService, Page } from "./ports.js"
|
||||
|
||||
/** The service's idempotency key of a request: one project's request id never answers another's. */
|
||||
export const requestKey = (scope: Pick<ProjectScope, "projectId">, requestId: string): string =>
|
||||
`app-project:${scope.projectId}:${requestId}`
|
||||
|
||||
const cursor = (after: string | undefined) => (after === undefined ? {} : { after })
|
||||
|
||||
export function listProducts(
|
||||
reads: CatalogReads,
|
||||
query: { after?: string; limit?: number },
|
||||
): Promise<Page<ProductView>> {
|
||||
return reads.listProducts({ ...cursor(query.after), limit: query.limit ?? PAGE_LIMIT })
|
||||
}
|
||||
|
||||
export function searchProducts(
|
||||
reads: CatalogReads,
|
||||
query: { q: string; by?: ProductSearchField; after?: string; limit?: number },
|
||||
): Promise<Page<ProductView>> {
|
||||
const q = query.q.trim()
|
||||
const by = query.by ?? "name"
|
||||
const limit = query.limit ?? PAGE_LIMIT
|
||||
if (by === "name") return reads.searchProducts({ q, ...cursor(query.after), limit })
|
||||
return reads.listProducts({ search: { by, q }, ...cursor(query.after), limit })
|
||||
}
|
||||
|
||||
export function listImports(reads: CatalogReads, query: { after?: string; limit?: number }): Promise<Page<ImportView>> {
|
||||
return reads.listImports({ ...cursor(query.after), limit: query.limit ?? PAGE_LIMIT })
|
||||
}
|
||||
|
||||
export function applyImport(
|
||||
service: CatalogService,
|
||||
scope: ProjectScope,
|
||||
input: {
|
||||
fileHash: string
|
||||
sourceName: string
|
||||
format: "csv" | "xlsx" | "json"
|
||||
chunk: number
|
||||
finalChunk: boolean
|
||||
revision?: string
|
||||
rows: readonly ImportRow[]
|
||||
requestId: string
|
||||
},
|
||||
): Promise<ImportOutcomeView> {
|
||||
return service.applyImport({
|
||||
fileHash: input.fileHash,
|
||||
sourceName: input.sourceName.trim(),
|
||||
format: input.format,
|
||||
chunk: input.chunk,
|
||||
finalChunk: input.finalChunk,
|
||||
...(input.revision === undefined ? {} : { revision: input.revision }),
|
||||
rows: input.rows,
|
||||
key: requestKey(scope, input.requestId),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// The port the catalog module's use cases are written against: the platform's Catalog service, in this
|
||||
// module's own words. adapters/service.ts is the one place that knows `@conusai-sdk/catalog` exists, and
|
||||
// it turns every refusal into the kit's app errors (`businessAppError` of
|
||||
// `@conusai-kit/business`) before core sees it.
|
||||
|
||||
import { token } from "@conusai-kit/core"
|
||||
import type { ImportOutcomeView, ImportRow, ImportView, ProductSearchField, ProductView } from "../contract/index.js"
|
||||
|
||||
export interface Page<T> {
|
||||
readonly rows: T[]
|
||||
readonly after?: string
|
||||
}
|
||||
|
||||
/** What a `read` procedure may ask the service: nothing that changes a row. */
|
||||
export interface CatalogReads {
|
||||
listProducts(query: {
|
||||
search?: { by: Exclude<ProductSearchField, "name">; q: string }
|
||||
after?: string
|
||||
limit: number
|
||||
}): Promise<Page<ProductView>>
|
||||
/** The service's ranked search: the exact code first, then most shared words. */
|
||||
searchProducts(query: { q: string; after?: string; limit: number }): Promise<Page<ProductView>>
|
||||
getProduct(productId: string): Promise<ProductView>
|
||||
listImports(query: { after?: string; limit: number }): Promise<Page<ImportView>>
|
||||
}
|
||||
|
||||
export interface CatalogService extends CatalogReads {
|
||||
applyImport(input: {
|
||||
fileHash: string
|
||||
sourceName: string
|
||||
format: "csv" | "xlsx" | "json"
|
||||
chunk: number
|
||||
finalChunk: boolean
|
||||
revision?: string
|
||||
rows: readonly ImportRow[]
|
||||
key: string
|
||||
}): Promise<ImportOutcomeView>
|
||||
}
|
||||
|
||||
/** The read view a `read` or `write` handler receives: the kind is a capability, not a label. */
|
||||
export const catalogReadsOf = (service: CatalogReads): CatalogReads => ({
|
||||
listProducts: (query) => service.listProducts(query),
|
||||
searchProducts: (query) => service.searchProducts(query),
|
||||
getProduct: (productId) => service.getProduct(productId),
|
||||
listImports: (query) => service.listImports(query),
|
||||
})
|
||||
|
||||
/**
|
||||
* Bound by a composition root or a test that brings its own client (another organization, the
|
||||
* business-services fake). With nothing bound, the module calls this deployment's service.
|
||||
*/
|
||||
export const CATALOG_SERVICE = token<CatalogService>("catalog.service", {
|
||||
views: { read: catalogReadsOf, write: catalogReadsOf },
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
// English messages of the catalog module. Every string a page of this module shows is a key here and in
|
||||
// lt.ts; `__tests__/i18n.test.ts` fails when the two differ.
|
||||
|
||||
export const en = {
|
||||
title: "Products",
|
||||
intro:
|
||||
"The products your organization sells or buys, from the platform's Catalog service. They are your organization's, shared by every project.",
|
||||
nav: {
|
||||
products: "All products",
|
||||
imports: "Imports",
|
||||
},
|
||||
statuses: {
|
||||
active: "Active",
|
||||
archived: "Archived",
|
||||
deleted: "Deleted",
|
||||
},
|
||||
products: {
|
||||
caption: "Products",
|
||||
empty: "No products yet.",
|
||||
code: "Code",
|
||||
name: "Name",
|
||||
unit: "Unit",
|
||||
status: "Status",
|
||||
open: "Open {name}",
|
||||
search: "Find a product",
|
||||
searchIn: "Look in",
|
||||
byName: "Name",
|
||||
byCode: "Code",
|
||||
byGtin: "GTIN",
|
||||
byErp: "ERP code",
|
||||
searchSubmit: "Find",
|
||||
more: "Show more",
|
||||
},
|
||||
product: {
|
||||
back: "All products",
|
||||
code: "Code",
|
||||
unit: "Unit",
|
||||
status: "Status",
|
||||
group: "Group",
|
||||
description: "Description",
|
||||
revision: "Revision",
|
||||
identifiers: "Identifiers",
|
||||
none: "None",
|
||||
},
|
||||
imports: {
|
||||
title: "Imports",
|
||||
intro: "Each file is applied once, row by row. A row the service refuses is reported and the others stay applied.",
|
||||
count: "{count, plural, one {# import} other {# imports}}",
|
||||
caption: "Imports",
|
||||
empty: "Nothing has been imported yet.",
|
||||
source: "File",
|
||||
status: "Status",
|
||||
created: "Created",
|
||||
updated: "Updated",
|
||||
unchanged: "Unchanged",
|
||||
refused: "Refused",
|
||||
},
|
||||
importForm: {
|
||||
heading: "Import products",
|
||||
file: "CSV file",
|
||||
hint: "A header row with code, name and base_unit; description and revision are optional. Up to 5 MB.",
|
||||
submit: "Import",
|
||||
busy: "Importing",
|
||||
done: "{created} created, {updated} updated, {unchanged} unchanged, {refused} refused.",
|
||||
empty: "The file has no rows.",
|
||||
tooLarge: "The file is larger than 5 MB.",
|
||||
missing: "The file's header lacks: {columns}",
|
||||
refusedCaption: "Refused rows",
|
||||
row: "Row",
|
||||
reason: "Reason",
|
||||
invalidFields: "A field is not valid",
|
||||
conflict: "It conflicts with a product that exists: give the product's revision",
|
||||
notFound: "Something the row names does not exist",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// The catalog module's messages, as the TypeScript objects its manifest declares (`i18n: { en, lt }`).
|
||||
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,80 @@
|
||||
// Lietuviški catalog modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile;
|
||||
// `__tests__/i18n.test.ts` žlunga, kai jie skiriasi.
|
||||
|
||||
import type { en } from "./en.js"
|
||||
|
||||
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
|
||||
|
||||
export const lt: Messages<typeof en> = {
|
||||
title: "Produktai",
|
||||
intro:
|
||||
"Produktai, kuriuos jūsų organizacija parduoda ar perka, iš platformos katalogo paslaugos. Jie priklauso jūsų organizacijai ir yra bendri visiems projektams.",
|
||||
nav: {
|
||||
products: "Visi produktai",
|
||||
imports: "Importai",
|
||||
},
|
||||
statuses: {
|
||||
active: "Aktyvus",
|
||||
archived: "Archyvuotas",
|
||||
deleted: "Ištrintas",
|
||||
},
|
||||
products: {
|
||||
caption: "Produktai",
|
||||
empty: "Produktų dar nėra.",
|
||||
code: "Kodas",
|
||||
name: "Pavadinimas",
|
||||
unit: "Vienetas",
|
||||
status: "Būsena",
|
||||
open: "Atidaryti: {name}",
|
||||
search: "Rasti produktą",
|
||||
searchIn: "Ieškoti pagal",
|
||||
byName: "Pavadinimą",
|
||||
byCode: "Kodą",
|
||||
byGtin: "GTIN",
|
||||
byErp: "ERP kodą",
|
||||
searchSubmit: "Ieškoti",
|
||||
more: "Rodyti daugiau",
|
||||
},
|
||||
product: {
|
||||
back: "Visi produktai",
|
||||
code: "Kodas",
|
||||
unit: "Vienetas",
|
||||
status: "Būsena",
|
||||
group: "Grupė",
|
||||
description: "Aprašymas",
|
||||
revision: "Versija",
|
||||
identifiers: "Identifikatoriai",
|
||||
none: "Nėra",
|
||||
},
|
||||
imports: {
|
||||
title: "Importai",
|
||||
intro:
|
||||
"Kiekvienas failas pritaikomas vieną kartą, eilutė po eilutės. Paslaugos atmesta eilutė parodoma, o kitos lieka pritaikytos.",
|
||||
count: "{count, plural, one {# importas} few {# importai} other {# importų}}",
|
||||
caption: "Importai",
|
||||
empty: "Dar nieko neimportuota.",
|
||||
source: "Failas",
|
||||
status: "Būsena",
|
||||
created: "Sukurta",
|
||||
updated: "Atnaujinta",
|
||||
unchanged: "Nepakeista",
|
||||
refused: "Atmesta",
|
||||
},
|
||||
importForm: {
|
||||
heading: "Importuoti produktus",
|
||||
file: "CSV failas",
|
||||
hint: "Antraštės eilutė su code, name ir base_unit; description ir revision neprivalomi. Iki 5 MB.",
|
||||
submit: "Importuoti",
|
||||
busy: "Importuojama",
|
||||
done: "Sukurta {created}, atnaujinta {updated}, nepakeista {unchanged}, atmesta {refused}.",
|
||||
empty: "Faile nėra eilučių.",
|
||||
tooLarge: "Failas didesnis nei 5 MB.",
|
||||
missing: "Failo antraštėje trūksta: {columns}",
|
||||
refusedCaption: "Atmestos eilutės",
|
||||
row: "Eilutė",
|
||||
reason: "Priežastis",
|
||||
invalidFields: "Netinkamas laukas",
|
||||
conflict: "Prieštarauja esamam produktui: nurodykite produkto versiją",
|
||||
notFound: "Tai, ką eilutė nurodo, neegzistuoja",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// The catalog module's public entry: its manifest, its contract, and the port an app may bind (a test's
|
||||
// fake, or a client for another organization). A page imports `./ui`.
|
||||
|
||||
export { sdkCatalogService } from "./adapters/service.js"
|
||||
export * from "./contract/index.js"
|
||||
export { CATALOG_SERVICE, type CatalogReads, type CatalogService } from "./core/ports.js"
|
||||
export { catalogModule } from "./module.js"
|
||||
@@ -0,0 +1,49 @@
|
||||
// The catalog module's manifest: a client of the platform's Catalog business service (B-wave contract
|
||||
// §3.14, docs/business-services.md). It keeps no table of its own -- products and their imports are the
|
||||
// service's, in the deployment's organization -- so it has procedures, permissions, pages and messages,
|
||||
// and no repository, migration, job or event.
|
||||
//
|
||||
// The template ships it off: `kit module:on catalog` adds it to the list in modules/index.ts and asks the
|
||||
// deployment token for the service scopes it needs (`catalog.products:read`,
|
||||
// `catalog.imports:read|write`) in conusai.json.
|
||||
|
||||
import { defineModule } from "@conusai-kit/core"
|
||||
import { CATALOG_PROCEDURES } from "./adapters/procedures.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
|
||||
export const catalogModule = defineModule({
|
||||
id: "catalog",
|
||||
// A business module is its service, which this app reaches on these profiles (test/module-profiles.test.ts).
|
||||
profiles: ["postgres", "mariadb", "mongodb"],
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
permissions: {
|
||||
"catalog.product.read": "viewer",
|
||||
"catalog.product.write": "editor",
|
||||
},
|
||||
procedures: CATALOG_PROCEDURES,
|
||||
serviceRequirements: [
|
||||
{
|
||||
transport: "business",
|
||||
service: "catalog",
|
||||
apiRange: "^1.1.0",
|
||||
profiles: ["postgres", "mariadb", "mongodb"],
|
||||
operations: {
|
||||
"catalog.product.list": ["catalog.products:read"],
|
||||
"catalog.product.search": ["catalog.products:read"],
|
||||
"catalog.product.get": ["catalog.products:read"],
|
||||
"catalog.import.list": ["catalog.imports:read"],
|
||||
"catalog.import.apply": ["catalog.imports:write"],
|
||||
},
|
||||
configuration: [],
|
||||
dependencies: [],
|
||||
dataClasses: ["operational"],
|
||||
partitionRequired: true,
|
||||
remediation: "/docs/business-services.md",
|
||||
},
|
||||
],
|
||||
// `catalog` is the segment the web shell's module mount routes to `catalogPage` (ui/pages.tsx).
|
||||
ui: { nav: [{ scope: "project", label: "catalog.title", href: "catalog", permission: "catalog.product.read" }] },
|
||||
i18n: { en, lt },
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user