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

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
"use client"
// Adds a contact point (an e-mail address, a phone number, a web address) to a party. The service
// checks the value against its kind and answers a malformed one in its own words.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import {
CheckField,
FormLayout,
type FormValues,
PageSection,
SelectField,
serviceNotice,
TextField,
usePageNavigation,
} from "@conusai-kit/ui/patterns"
import { FieldGroup, FieldLegend, FieldSet } from "@conusai-kit/ui/primitives"
import { type ReactElement, useState } from "react"
import { CONTACT_KINDS, CONTACT_PURPOSES } from "../contract/constants.js"
export interface ContactFormLabels {
readonly heading: string
readonly kind: string
readonly value: string
readonly purposes: string
readonly primary: string
readonly submit: string
readonly busy: string
readonly kinds: Readonly<Record<(typeof CONTACT_KINDS)[number], string>>
readonly purposeNames: Readonly<Record<(typeof CONTACT_PURPOSES)[number], string>>
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
const INPUT_TYPE = { email: "email", phone: "tel", web: "url" } as const
export function ContactForm({
slugs,
partyId,
labels,
}: {
slugs: ProjectSlugs
partyId: string
labels: ContactFormLabels
}): ReactElement {
const navigation = usePageNavigation()
const add = useWrite<Record<string, unknown>, unknown>("crm.contact.add")
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
const [kind, setKind] = useState<(typeof CONTACT_KINDS)[number]>("email")
const submit = async (values: FormValues): Promise<void> => {
await add.mutate({
...slugs,
partyId,
kind,
value: values.value,
purposes: CONTACT_PURPOSES.filter((purpose) => values[`purpose-${purpose}`] === "on"),
isPrimary: values.isPrimary === "on",
requestId,
})
setRequestId(crypto.randomUUID())
// The page is rendered on the server: a reload lists the new contact point.
await navigation.refresh()
}
return (
<PageSection title={labels.heading} variant="card">
<FormLayout
submitLabel={labels.submit}
busyLabel={labels.busy}
onSubmit={submit}
describeError={(error) => serviceNotice(error, labels.refusals)?.text ?? describeError(error)}
>
<SelectField
label={labels.kind}
value={kind}
onValueChange={(value) => setKind(CONTACT_KINDS.find((option) => option === value) ?? "email")}
options={CONTACT_KINDS.map((option) => ({ value: option, label: labels.kinds[option] }))}
/>
<TextField name="value" label={labels.value} type={INPUT_TYPE[kind]} required maxLength={256} />
<FieldSet>
<FieldLegend variant="label">{labels.purposes}</FieldLegend>
<FieldGroup>
{CONTACT_PURPOSES.map((purpose) => (
<CheckField key={purpose} name={`purpose-${purpose}`} label={labels.purposeNames[purpose]} />
))}
</FieldGroup>
</FieldSet>
<CheckField name="isPrimary" label={labels.primary} />
</FormLayout>
</PageSection>
)
}
+60
View File
@@ -0,0 +1,60 @@
// A party's contact points. Part of a page's view (F10): it holds no state, and every string is passed in.
import { PageSection, type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import type { ContactView } from "../contract/index.js"
export interface ContactsLabels {
readonly title: string
/** How many contact points, already filled with the plural form. */
readonly count: string
readonly caption: string
readonly empty: string
readonly kind: string
readonly value: string
readonly purposes: string
readonly primary: string
readonly erased: string
readonly yes: string
readonly no: string
readonly kinds: Readonly<Record<ContactView["kind"], string>>
readonly purposeNames: Readonly<Record<ContactView["purposes"][number], string>>
}
export function Contacts({
contacts,
labels,
}: {
contacts: readonly ContactView[]
labels: ContactsLabels
}): ReactElement {
const columns: ResourceColumn<ContactView>[] = [
{ id: "kind", header: labels.kind, cell: (contact) => labels.kinds[contact.kind] },
{
id: "value",
header: labels.value,
wrap: true,
cell: (contact) => (contact.erased ? labels.erased : contact.value),
},
{
id: "purposes",
header: labels.purposes,
wrap: true,
cell: (contact) => contact.purposes.map((purpose) => labels.purposeNames[purpose]).join(", "),
},
{ id: "isPrimary", header: labels.primary, cell: (contact) => (contact.isPrimary ? labels.yes : labels.no) },
]
return (
<PageSection title={labels.title}>
<ResourceList
label={labels.caption}
columns={columns}
data={contacts}
rowId={(contact) => contact.contactId}
count={contacts.length === 0 ? undefined : labels.count}
rowLabel={(contact) => labels.kinds[contact.kind]}
empty={{ title: labels.empty }}
/>
</PageSection>
)
}
+15
View File
@@ -0,0 +1,15 @@
// The crm module's pages and components. `crmPage` is what the web shell mounts; the rest is exported
// for an app that composes its own page from the parts.
export { ContactForm, type ContactFormLabels } from "./contact-form.js"
export { Contacts, type ContactsLabels } from "./contacts.js"
export {
contactFormLabels,
contactsLabels,
partyFormLabels,
partyListLabels,
type Translate,
} from "./labels.js"
export { type CrmPage, crmPage } from "./pages.js"
export { PartyForm, type PartyFormLabels } from "./party-form.js"
export { PartyList, type PartyListLabels } from "./party-list.js"
+90
View File
@@ -0,0 +1,90 @@
// 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 (`form.submit`); the page binds it to
// `i18n.message("crm." + key)`. `refusals` is the kit's text for each business refusal in the reader's
// language (`businessRefusalNotices`), which a form shows when the service refuses.
import { CONTACT_KINDS, CONTACT_PURPOSES } from "../contract/constants.js"
import type { ContactFormLabels } from "./contact-form.js"
import type { ContactsLabels } from "./contacts.js"
import type { PartyFormLabels } from "./party-form.js"
import type { PartyListLabels } from "./party-list.js"
export type Translate = (key: string, values?: Record<string, string | number>) => string
export type Refusals = Readonly<Record<string, string>>
const ALL_KINDS = ["postal", "email", "phone", "web", "peppol_endpoint"] as const
const purposeNames = (t: Translate) =>
Object.fromEntries(
CONTACT_PURPOSES.map((purpose) => [purpose, t(`purposes.${purpose}`)]),
) as ContactsLabels["purposeNames"]
export function partyFormLabels(t: Translate, refusals: Refusals): PartyFormLabels {
return {
heading: t("form.heading"),
kind: t("form.kind"),
person: t("kinds.person"),
organization: t("kinds.organization"),
displayName: t("form.displayName"),
legalName: t("form.legalName"),
country: t("form.country"),
countryHint: t("form.countryHint"),
linkSelf: t("form.linkSelf"),
submit: t("form.submit"),
busy: t("form.busy"),
refusals,
}
}
export function partyListLabels(t: Translate): PartyListLabels {
return {
caption: t("parties.caption"),
empty: t("parties.empty"),
name: t("parties.name"),
kind: t("parties.kind"),
country: t("parties.country"),
person: t("kinds.person"),
organization: t("kinds.organization"),
// The placeholder is the component's to fill, so it is passed through untouched.
open: t("parties.open", { name: "{name}" }),
search: t("parties.search"),
}
}
export function contactsLabels(t: Translate, count: number): ContactsLabels {
return {
title: t("contacts.title"),
count: t("contacts.count", { count }),
caption: t("contacts.caption"),
empty: t("contacts.empty"),
kind: t("contacts.kind"),
value: t("contacts.value"),
purposes: t("contacts.purposes"),
primary: t("contacts.primary"),
erased: t("contacts.erased"),
yes: t("contacts.yes"),
no: t("contacts.no"),
kinds: Object.fromEntries(ALL_KINDS.map((kind) => [kind, t(`contactKinds.${kind}`)])) as ContactsLabels["kinds"],
purposeNames: purposeNames(t),
}
}
export function contactFormLabels(t: Translate, refusals: Refusals): ContactFormLabels {
return {
heading: t("contactForm.heading"),
kind: t("contactForm.kind"),
value: t("contactForm.value"),
purposes: t("contactForm.purposes"),
primary: t("contactForm.primary"),
submit: t("contactForm.submit"),
busy: t("contactForm.busy"),
kinds: Object.fromEntries(
CONTACT_KINDS.map((kind) => [kind, t(`contactKinds.${kind}`)]),
) as ContactFormLabels["kinds"],
purposeNames: purposeNames(t),
refusals,
}
}
+158
View File
@@ -0,0 +1,158 @@
// The crm 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>/crm`, and the view of what it returned.
//
// /crm the organization's parties, and the form that adds one
// /crm/<partyId> one party: how to reach it, and the form that adds a contact point
//
// 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 } from "@conusai-kit/i18n"
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
import { Can } from "@conusai-kit/ui/data"
import {
DetailLayout,
Page,
PageHeader,
type ServiceNotice,
ServiceStatePage,
serviceNotice,
} from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import { type ContactView, type PartyView, partyGet, partyList } from "../contract/index.js"
import { ContactForm, type ContactFormLabels } from "./contact-form.js"
import { Contacts, type ContactsLabels } from "./contacts.js"
import { contactFormLabels, contactsLabels, partyFormLabels, partyListLabels, type Translate } from "./labels.js"
import { PartyForm, type PartyFormLabels } from "./party-form.js"
import { PartyList, type PartyListLabels } from "./party-list.js"
/** The parties, and the form that adds one. */
export interface CrmListData {
readonly kind: "list"
readonly title: string
readonly intro: string
readonly form: PartyFormLabels
readonly list: PartyListLabels
readonly parties: { rows: PartyView[]; after?: string | undefined }
}
/** One party, its kind and country already in a sentence. */
export interface CrmPartyData {
readonly kind: "party"
readonly title: string
readonly back: string
readonly partyId: string
readonly displayName: string
readonly summary: string
/** The note that the party is a member's account, when it is. */
readonly linked?: string
readonly contacts: readonly ContactView[]
readonly contactLabels: ContactsLabels
readonly form: ContactFormLabels
}
/** The service cannot answer: the notice in place of the page. */
export interface CrmStateData {
readonly kind: "state"
readonly title: string
readonly notice: ServiceNotice
}
export type CrmPageData = CrmListData | CrmPartyData | CrmStateData
export type CrmPage = ProjectPage<TransportCaller, I18n, Container, CrmPageData>
/** A party id as the service mints it: one path segment. */
const PARTY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
/** The party a path segment names; a segment that does not decode names none. */
function partyIdOf(segment: string): string {
try {
return decodeURIComponent(segment)
} catch {
throw notFound("no such page")
}
}
export const crmPage: CrmPage = projectPage({
async load({ slugs, segments, caller, i18n }): Promise<CrmPageData> {
const t: Translate = (key, values) => i18n.message(`crm.${key}`, values)
const title = t("title")
const refusals = businessRefusalNotices(i18n.locale)
const [segment, ...rest] = segments
const partyId = segment === undefined ? undefined : partyIdOf(segment)
if (rest.length > 0 || (partyId !== undefined && !PARTY_ID.test(partyId))) throw notFound("no such page")
try {
if (partyId === undefined) {
return {
kind: "list",
title,
intro: t("intro"),
form: partyFormLabels(t, refusals),
list: partyListLabels(t),
parties: await caller.call(partyList, slugs),
}
}
const { party, contacts } = await caller.call(partyGet, { ...slugs, partyId })
return {
kind: "party",
title,
back: t("party.back"),
partyId: party.partyId,
displayName: party.displayName,
summary: `${t(`kinds.${party.kind}`)}, ${party.country}${party.legalName ? `, ${party.legalName}` : ""}`,
...(party.linked ? { linked: t("party.linked") } : {}),
contacts,
contactLabels: contactsLabels(t, contacts.length),
form: contactFormLabels(t, refusals),
}
} catch (error) {
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
if (!notice) throw error
return { kind: "state", title, notice }
}
},
View({ slugs, data }: ProjectPageViewProps<CrmPageData>): ReactElement {
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/crm`
if (data.kind === "state") return <ServiceStatePage title={data.title} notice={data.notice} />
if (data.kind === "list") {
return (
<Page>
<PageHeader title={data.title} description={data.intro} />
<DetailLayout
aside={
<Can permission="crm.party.write">
<PartyForm slugs={slugs} listHref={listHref} labels={data.form} />
</Can>
}
>
<PartyList slugs={slugs} seed={data.parties} listHref={listHref} labels={data.list} />
</DetailLayout>
</Page>
)
}
return (
<Page>
<PageHeader title={data.displayName} description={data.summary} breadcrumb={<a href={listHref}>{data.back}</a>}>
{data.linked ? <span>{data.linked}</span> : null}
</PageHeader>
<DetailLayout
aside={
<Can permission="crm.party.write">
<ContactForm slugs={slugs} partyId={data.partyId} labels={data.form} />
</Can>
}
>
<Contacts contacts={data.contacts} labels={data.contactLabels} />
</DetailLayout>
</Page>
)
},
})
+96
View File
@@ -0,0 +1,96 @@
"use client"
// Adds a party (a person or an organization), then opens it. "This is me" links a person to the
// member's own account, so the platform's export or erasure of that member reaches the party.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import {
CheckField,
FormLayout,
type FormValues,
PageSection,
SelectField,
serviceNotice,
TextField,
usePageNavigation,
} from "@conusai-kit/ui/patterns"
import { type ReactElement, useState } from "react"
export interface PartyFormLabels {
readonly heading: string
readonly kind: string
readonly person: string
readonly organization: string
readonly displayName: string
readonly legalName: string
readonly country: string
readonly countryHint: string
readonly linkSelf: string
readonly submit: string
readonly busy: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
export function PartyForm({
slugs,
listHref,
labels,
}: {
slugs: ProjectSlugs
listHref: string
labels: PartyFormLabels
}): ReactElement {
const navigation = usePageNavigation()
const create = useWrite<Record<string, unknown>, { partyId: string }>("crm.party.create")
const [requestId] = useState(() => crypto.randomUUID())
const [kind, setKind] = useState("organization")
const submit = async ({ displayName, legalName, country = "", linkSelf }: FormValues): Promise<void> => {
const created = await create.mutate({
...slugs,
kind,
displayName,
...(legalName ? { legalName } : {}),
country: country.toUpperCase(),
...(kind === "person" && linkSelf === "on" ? { linkSelf: true } : {}),
requestId,
})
navigation.navigate(`${listHref}/${encodeURIComponent(created.partyId)}`)
}
return (
<PageSection title={labels.heading}>
<FormLayout
submitLabel={labels.submit}
busyLabel={labels.busy}
onSubmit={submit}
describeError={(error) => serviceNotice(error, labels.refusals)?.text ?? describeError(error)}
>
<SelectField
label={labels.kind}
value={kind}
onValueChange={setKind}
options={[
{ value: "organization", label: labels.organization },
{ value: "person", label: labels.person },
]}
/>
<TextField name="displayName" label={labels.displayName} required maxLength={300} />
<TextField name="legalName" label={labels.legalName} maxLength={300} />
<TextField
name="country"
label={labels.country}
description={labels.countryHint}
errorMessage={labels.countryHint}
required
minLength={2}
maxLength={2}
pattern="[A-Za-z]{2}"
/>
{kind === "person" ? <CheckField name="linkSelf" label={labels.linkSelf} /> : null}
</FormLayout>
</PageSection>
)
}
+68
View File
@@ -0,0 +1,68 @@
"use client"
// The organization's parties, a page at a time. The server renders the first page (`seed`); a search
// and "Show more" are reads made on a person's action, through the page's data client.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { useDataClient } from "@conusai-kit/ui/data"
import { type ResourceColumn, ResourceList, useResourceList } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import type { PartyView } from "../contract/index.js"
export interface PartyListLabels {
readonly caption: string
readonly empty: string
readonly name: string
readonly kind: string
readonly country: string
readonly person: string
readonly organization: string
/** `Open {name}`: the link's accessible name. */
readonly open: string
readonly search: string
}
type Page = { rows: PartyView[]; after?: string | undefined }
export function PartyList({
slugs,
seed,
listHref,
labels,
}: {
slugs: ProjectSlugs
seed: Page
listHref: string
labels: PartyListLabels
}): ReactElement {
const client = useDataClient()
const { list } = useResourceList<PartyView>({
seed,
rowId: (party) => party.partyId,
searchLabel: labels.search,
read: async ({ q, after }) => {
const input = { ...slugs, ...(q ? { q } : {}), ...(after ? { after } : {}) }
return (await client.call<Page>("crm.party.list", input)).value
},
})
const columns: ResourceColumn<PartyView>[] = [
{ id: "displayName", header: labels.name, wrap: true, minWidth: "10rem" },
{
id: "kind",
header: labels.kind,
cell: (party) => (party.kind === "person" ? labels.person : labels.organization),
},
{ id: "country", header: labels.country },
]
return (
<ResourceList
{...list}
label={labels.caption}
columns={columns}
rowHref={(party) => `${listHref}/${encodeURIComponent(party.partyId)}`}
rowLinkLabel={(party) => labels.open.replace("{name}", party.displayName)}
rowLabel={(party) => party.displayName}
empty={{ title: labels.empty }}
/>
)
}