@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user