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
+91
View File
@@ -0,0 +1,91 @@
"use client"
import { Button } from "@conusai-kit/ui/primitives"
// What an administrator does to a document's retention: place or lift a legal hold, and dispose of it.
// The service decides whether disposal is allowed (a hold blocks it, and the retention must have ended):
// a refusal is shown here, and a disposal asks first, because it cannot be undone.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import { ConfirmDialog, serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
import { Alert, AlertDescription } from "@conusai-kit/ui/primitives"
import type { ReactElement } from "react"
import { useState } from "react"
export interface DocumentActionsLabels {
readonly heading: string
readonly hold: string
readonly release: string
readonly dispose: string
readonly disposeConfirm: string
readonly busy: 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>>
}
export function DocumentActions({
slugs,
documentId,
revision,
legalHold,
labels,
}: {
slugs: ProjectSlugs
documentId: string
revision: string
legalHold: boolean
labels: DocumentActionsLabels
}): ReactElement {
const navigation = usePageNavigation()
const hold = useWrite<Record<string, unknown>, unknown>("archive.document.hold")
const dispose = useWrite<Record<string, unknown>, unknown>("archive.document.dispose")
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
const act = async (write: () => Promise<unknown>): Promise<void> => {
setState({ kind: "busy" })
try {
await write()
await navigation.refresh()
setState({ kind: "idle" })
} catch (error) {
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
}
}
const request = { ...slugs, documentId, revision, requestId: crypto.randomUUID() }
return (
<section>
<h2>{labels.heading}</h2>
<Button
type="button"
disabled={state.kind === "busy"}
onClick={() => void act(() => hold.mutate({ ...request, legalHold: !legalHold }))}
>
{state.kind === "busy" ? labels.busy : legalHold ? labels.release : labels.hold}
</Button>
<ConfirmDialog
title={labels.dispose}
description={labels.disposeConfirm}
confirmLabel={labels.dispose}
busyLabel={labels.busy}
trigger={
<Button type="button" variant="destructive" disabled={state.kind === "busy" || legalHold}>
{labels.dispose}
</Button>
}
onConfirm={async () => {
await dispose.mutate(request)
await navigation.refresh()
}}
/>
{state.kind === "failed" ? (
<Alert variant="destructive">
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
</Alert>
) : null}
</section>
)
}
+75
View File
@@ -0,0 +1,75 @@
"use client"
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useDataClient } from "@conusai-kit/ui/data"
import { ResourceList, serviceNotice, useResourceList } from "@conusai-kit/ui/patterns"
import { type ReactElement, useMemo } from "react"
import type { DocumentView } from "../contract/index.js"
export interface DocumentListLabels {
readonly caption: string
readonly empty: string
readonly title: string
readonly retentionClass: string
readonly status: string
readonly created: string
/** `Open {title}`: the link's accessible name. */
readonly open: string
readonly search: 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: DocumentView[]; after?: string | undefined }
export function DocumentList({
slugs,
seed,
statuses,
listHref,
labels,
locale,
}: {
slugs: ProjectSlugs
seed: Page
locale: string
statuses: Readonly<Record<DocumentView["status"], string>>
listHref: string
labels: DocumentListLabels
}): ReactElement {
const date = useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }), [locale])
const client = useDataClient()
const { list } = useResourceList<DocumentView>({
seed,
rowId: (row) => row.documentId,
searchLabel: labels.search,
describeError: (error) =>
labels.failed.replace("{detail}", serviceNotice(error, labels.refusals)?.text ?? describeError(error)),
read: async ({ q, after }) =>
(
await client.call<Page>("archive.document.list", {
...slugs,
...(q ? { q } : {}),
...(after ? { after } : {}),
})
).value,
})
return (
<ResourceList
{...list}
label={labels.caption}
empty={{ title: labels.empty }}
columns={[
{ id: "title", header: labels.title, wrap: true },
{ id: "retentionClass", header: labels.retentionClass },
{ id: "status", header: labels.status, cell: (row) => statuses[row.status] },
{ id: "createdAt", header: labels.created, cell: (row) => date.format(new Date(row.createdAt)) },
]}
rowHref={(row) => `${listHref}/${encodeURIComponent(row.documentId)}`}
rowLinkLabel={(row) => labels.open.replace("{title}", row.title)}
/>
)
}
+21
View File
@@ -0,0 +1,21 @@
// The archive module's pages and components. `archivePage` is what the web shell mounts; the rest is
// exported for an app that composes its own page from the parts.
export { DocumentActions, type DocumentActionsLabels } from "./document-actions.js"
export { DocumentList, type DocumentListLabels } from "./document-list.js"
export {
documentActionsLabels,
documentListLabels,
registerFormLabels,
retentionFormLabels,
retentionLabels,
type Translate,
versionFormLabels,
versionsLabels,
} from "./labels.js"
export { type ArchivePage, archivePage, serviceState } from "./pages.js"
export { RegisterForm, type RegisterFormLabels } from "./register-form.js"
export { RetentionClasses, type RetentionLabels } from "./retention-classes.js"
export { RetentionForm, type RetentionFormLabels } from "./retention-form.js"
export { VersionForm, type VersionFormLabels } from "./version-form.js"
export { Versions, type VersionsLabels } from "./versions.js"
+125
View File
@@ -0,0 +1,125 @@
// 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 (`documents.title`); the page binds it to
// `i18n.message("archive." + key)`. The app's `app.failed` is passed in already looked up, and so is
// `refusals`, the kit's sentence for each business refusal by problem code (`businessRefusalNotices`).
import { RETENTION_BASES, type RetentionClassView } from "../contract/index.js"
import type { DocumentActionsLabels } from "./document-actions.js"
import type { DocumentListLabels } from "./document-list.js"
import type { RegisterFormLabels } from "./register-form.js"
import type { RetentionLabels } from "./retention-classes.js"
import type { RetentionFormLabels } from "./retention-form.js"
import type { VersionFormLabels } from "./version-form.js"
import type { VersionsLabels } from "./versions.js"
export type Translate = (key: string, values?: Record<string, string | number>) => string
/** The kit's sentence for each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
export type Refusals = Readonly<Record<string, string>>
export const baseNames = (t: Translate): Readonly<Record<RetentionClassView["from"], string>> =>
Object.fromEntries(RETENTION_BASES.map((base) => [base, t(`retention.bases.${base}`)])) as Record<
RetentionClassView["from"],
string
>
export function documentListLabels(t: Translate, failed: string, refusals: Refusals): DocumentListLabels {
return {
caption: t("documents.caption"),
empty: t("documents.empty"),
title: t("documents.title"),
retentionClass: t("documents.retentionClass"),
status: t("documents.status"),
created: t("documents.created"),
// The placeholder is the component's to fill, so it is passed through untouched.
open: t("documents.open", { title: "{title}" }),
search: t("documents.search"),
searchSubmit: t("documents.searchSubmit"),
more: t("documents.more"),
failed,
refusals,
}
}
export function registerFormLabels(t: Translate, failed: string, refusals: Refusals): RegisterFormLabels {
return {
heading: t("register.heading"),
title: t("register.title"),
kind: t("register.kind"),
kindHint: t("register.kindHint"),
retentionClass: t("register.retentionClass"),
file: t("register.file"),
submit: t("register.submit"),
busy: t("register.busy"),
noClass: t("register.noClass"),
failed,
refusals,
}
}
export function versionsLabels(t: Translate, count: number): VersionsLabels {
return {
title: t("versions.title"),
count: t("versions.count", { count }),
caption: t("versions.caption"),
sequence: t("versions.sequence"),
mediaType: t("versions.mediaType"),
size: t("versions.size"),
hash: t("versions.hash"),
created: t("versions.created"),
}
}
export function versionFormLabels(t: Translate, failed: string, refusals: Refusals): VersionFormLabels {
return {
heading: t("versionForm.heading"),
file: t("versionForm.file"),
submit: t("versionForm.submit"),
busy: t("versionForm.busy"),
failed,
refusals,
}
}
export function documentActionsLabels(t: Translate, failed: string, refusals: Refusals): DocumentActionsLabels {
return {
heading: t("actions.heading"),
hold: t("actions.hold"),
release: t("actions.release"),
dispose: t("actions.dispose"),
disposeConfirm: t("actions.disposeConfirm"),
busy: t("actions.busy"),
failed,
refusals,
}
}
export function retentionLabels(t: Translate): RetentionLabels {
return {
caption: t("retention.caption"),
empty: t("retention.empty"),
code: t("retention.code"),
keepFor: t("retention.keepFor"),
from: t("retention.from"),
dueCaption: t("retention.dueCaption"),
dueEmpty: t("retention.dueEmpty"),
}
}
export function retentionFormLabels(t: Translate, failed: string, refusals: Refusals): RetentionFormLabels {
return {
heading: t("retentionForm.heading"),
code: t("retentionForm.code"),
keepFor: t("retentionForm.keepFor"),
keepForHint: t("retentionForm.keepForHint"),
from: t("retentionForm.from"),
submit: t("retentionForm.submit"),
busy: t("retentionForm.busy"),
bases: baseNames(t),
failed,
refusals,
}
}
+299
View File
@@ -0,0 +1,299 @@
// The archive 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>/archive`, and the view of what it returned.
//
// /archive the organization's documents, and the form that registers one
// /archive/retention the retention classes, what is due for disposal, and the form that adds a class
// /archive/<documentId> one document: its versions, the form that adds one, its legal hold and disposal
//
// 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.
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 {
ARCHIVE_PERMISSIONS,
type DocumentView,
disposalList,
documentGet,
documentList,
type RetentionClassView,
retentionList,
type VersionView,
} from "../contract/index.js"
import { DocumentActions, type DocumentActionsLabels } from "./document-actions.js"
import { DocumentList, type DocumentListLabels } from "./document-list.js"
import {
baseNames,
documentActionsLabels,
documentListLabels,
registerFormLabels,
retentionFormLabels,
retentionLabels,
type Translate,
versionFormLabels,
versionsLabels,
} from "./labels.js"
import { RegisterForm, type RegisterFormLabels } from "./register-form.js"
import { RetentionClasses, type RetentionLabels } from "./retention-classes.js"
import { RetentionForm, type RetentionFormLabels } from "./retention-form.js"
import { VersionForm, type VersionFormLabels } from "./version-form.js"
import { Versions, type VersionsLabels } from "./versions.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<DocumentView["status"], string>>
export interface ArchiveListData {
readonly kind: "list"
readonly title: string
readonly intro: string
readonly retentionLink: string
readonly form: RegisterFormLabels
readonly list: DocumentListLabels
readonly statuses: StatusNames
readonly classes: readonly RetentionClassView[]
readonly documents: { rows: DocumentView[]; after?: string | undefined }
}
export interface ArchiveDocumentData {
readonly kind: "document"
readonly title: string
readonly back: string
readonly document: DocumentView
readonly status: string
readonly versions: readonly VersionView[]
readonly labels: {
readonly retentionClass: string
readonly status: string
readonly revision: string
readonly created: string
readonly hold: string
readonly held: string
readonly disposedAt: string
readonly versions: VersionsLabels
readonly versionForm: VersionFormLabels
readonly actions: DocumentActionsLabels
}
}
export interface ArchiveRetentionData {
readonly kind: "retention"
readonly title: string
readonly intro: string
readonly back: string
readonly list: RetentionLabels
readonly form: RetentionFormLabels
readonly bases: Readonly<Record<RetentionClassView["from"], string>>
readonly classes: readonly RetentionClassView[]
readonly due: readonly DocumentView[]
}
/** The service cannot answer: the notice in place of the page. */
export interface ArchiveStateData {
readonly kind: "state"
readonly title: string
readonly notice: ServiceNotice
}
export type ArchivePageData = ArchiveListData | ArchiveDocumentData | ArchiveRetentionData | ArchiveStateData
export type ArchivePage = ProjectPage<TransportCaller, I18n, Container, ArchivePageData>
/** A document id as the service mints it: one path segment. */
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
/** The document a path segment names; a segment that does not decode names none. */
function documentIdOf(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"),
disposed: t("statuses.disposed"),
})
export const archivePage: ArchivePage = projectPage({
async load({ slugs, segments, caller, i18n }): Promise<ArchivePageData> {
const t: Translate = (key, values) => i18n.message(`archive.${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 documentId = segment === undefined || segment === "retention" ? undefined : documentIdOf(segment)
if (documentId !== undefined && !DOCUMENT_ID.test(documentId)) throw notFound("no such page")
try {
if (segment === "retention") {
const [classes, due] = await Promise.all([caller.call(retentionList, slugs), caller.call(disposalList, slugs)])
return {
kind: "retention",
title: t("retention.title"),
intro: t("retention.intro"),
back: t("nav.documents"),
list: retentionLabels(t),
form: retentionFormLabels(t, failed, refusals),
bases: baseNames(t),
classes: classes.rows,
due: due.rows,
}
}
if (documentId === undefined) {
const [documents, classes] = await Promise.all([
caller.call(documentList, slugs),
caller.call(retentionList, slugs),
])
return {
kind: "list",
title,
intro: t("intro"),
retentionLink: t("nav.retention"),
form: registerFormLabels(t, failed, refusals),
list: documentListLabels(t, failed, refusals),
statuses: statusNames(t),
classes: classes.rows,
documents,
}
}
const { document, versions } = await caller.call(documentGet, { ...slugs, documentId })
return {
kind: "document",
title,
back: t("document.back"),
document,
status: statusNames(t)[document.status],
versions,
labels: {
retentionClass: t("document.retentionClass"),
status: t("document.status"),
revision: t("document.revision"),
created: t("document.created"),
hold: t("document.hold"),
held: document.legalHold ? t("document.yes") : t("document.no"),
disposedAt: t("document.disposedAt"),
versions: versionsLabels(t, versions.length),
versionForm: versionFormLabels(t, failed, refusals),
actions: documentActionsLabels(t, failed, refusals),
},
}
} catch (error) {
const notice = serviceState(error, i18n.locale)
if (!notice) throw error
return { kind: "state", title, notice }
}
},
View({ slugs, data, locale }: ProjectPageViewProps<ArchivePageData>): ReactElement {
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/archive`
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}/retention`}>{data.retentionLink}</a>
</p>
<Can permission={ARCHIVE_PERMISSIONS.write}>
<RegisterForm slugs={slugs} classes={data.classes} listHref={listHref} labels={data.form} />
</Can>
<DocumentList
locale={locale}
slugs={slugs}
seed={data.documents}
statuses={data.statuses}
listHref={listHref}
labels={data.list}
/>
</>
) : data.kind === "retention" ? (
<>
<p>
<a href={listHref}>{data.back}</a>
</p>
<p>{data.intro}</p>
<Can permission={ARCHIVE_PERMISSIONS.manage}>
<RetentionForm slugs={slugs} labels={data.form} />
</Can>
<RetentionClasses
classes={data.classes}
due={data.due}
bases={data.bases}
listHref={listHref}
labels={data.list}
/>
</>
) : (
<>
<p>
<a href={listHref}>{data.back}</a>
</p>
<h2>{data.document.title}</h2>
<dl>
<dt>{data.labels.status}</dt>
<dd>{data.status}</dd>
<dt>{data.labels.retentionClass}</dt>
<dd>{data.document.retentionClass}</dd>
<dt>{data.labels.created}</dt>
<dd>{data.document.createdAt.slice(0, 10)}</dd>
<dt>{data.labels.revision}</dt>
<dd>{data.document.revision}</dd>
<dt>{data.labels.hold}</dt>
<dd>{data.labels.held}</dd>
{data.document.disposedAt === null ? null : (
<>
<dt>{data.labels.disposedAt}</dt>
<dd>{data.document.disposedAt.slice(0, 10)}</dd>
</>
)}
</dl>
<Versions versions={data.versions} labels={data.labels.versions} />
{data.document.disposed ? null : (
<>
<Can permission={ARCHIVE_PERMISSIONS.write}>
<VersionForm
slugs={slugs}
documentId={data.document.documentId}
revision={data.document.revision}
labels={data.labels.versionForm}
/>
</Can>
<Can permission={ARCHIVE_PERMISSIONS.manage}>
<DocumentActions
slugs={slugs}
documentId={data.document.documentId}
revision={data.document.revision}
legalHold={data.document.legalHold}
labels={data.labels.actions}
/>
</Can>
</>
)}
</>
)}
</>
)
},
})
+106
View File
@@ -0,0 +1,106 @@
"use client"
import { Button } from "@conusai-kit/ui/primitives"
// Registers a document: the browser stores the file through the files module, then asks this module to
// register it, which takes the version's hash, size and type from the stored file. Then it opens the
// document.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useUpload, useWrite } from "@conusai-kit/ui/data"
import { SelectField, serviceNotice, usePageNavigation } 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 { RetentionClassView } from "../contract/index.js"
export interface RegisterFormLabels {
readonly heading: string
readonly title: string
readonly kind: string
readonly kindHint: string
readonly retentionClass: string
readonly file: string
readonly submit: string
readonly busy: string
readonly noClass: 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>>
}
export function RegisterForm({
slugs,
classes,
listHref,
labels,
}: {
slugs: ProjectSlugs
classes: readonly RetentionClassView[]
listHref: string
labels: RegisterFormLabels
}): ReactElement {
const navigation = usePageNavigation()
const upload = useUpload()
const register = useWrite<Record<string, unknown>, { documentId: string }>("archive.document.register")
const [requestId] = useState(() => crypto.randomUUID())
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const file = form.get("file")
if (!(file instanceof File) || file.size === 0) return
const kind = String(form.get("kind") ?? "").trim()
setState({ kind: "busy" })
try {
const stored = await upload(slugs, file)
const created = await register.mutate({
...slugs,
title: String(form.get("title") ?? "").trim(),
...(kind ? { kind } : {}),
retentionClass: String(form.get("retentionClass") ?? ""),
fileId: stored.fileId,
requestId,
})
navigation.navigate(`${listHref}/${encodeURIComponent(created.documentId)}`)
} catch (error) {
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
}
}
return classes.length === 0 ? (
<p>{labels.noClass}</p>
) : (
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
<h2>{labels.heading}</h2>
<Field>
<FieldLabel>{labels.title}</FieldLabel>
<Input name="title" type="text" required maxLength={200} />
</Field>
<Field>
<FieldLabel>{labels.kind}</FieldLabel>
<Input name="kind" type="text" pattern="[0-9]{1,4}" />
<FieldDescription>{labels.kindHint}</FieldDescription>
</Field>
<SelectField
name="retentionClass"
label={labels.retentionClass}
required
defaultValue={classes[0]?.code}
options={classes.map((retention) => ({ value: retention.code, label: retention.code }))}
/>
<Field>
<FieldLabel>{labels.file}</FieldLabel>
<Input name="file" type="file" required />
</Field>
<Button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</Button>
{state.kind === "failed" ? (
<Alert variant="destructive">
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
</Alert>
) : null}
</form>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
// The retention classes, and the documents due for disposal. A server component: it holds no state, and
// every string is passed in.
import type { ReactElement } from "react"
import type { DocumentView, RetentionClassView } from "../contract/index.js"
export interface RetentionLabels {
readonly caption: string
readonly empty: string
readonly code: string
readonly keepFor: string
readonly from: string
readonly dueCaption: string
readonly dueEmpty: string
}
export function RetentionClasses({
classes,
due,
bases,
listHref,
labels,
}: {
classes: readonly RetentionClassView[]
due: readonly DocumentView[]
bases: Readonly<Record<RetentionClassView["from"], string>>
listHref: string
labels: RetentionLabels
}): ReactElement {
return (
<>
{classes.length === 0 ? (
<p>{labels.empty}</p>
) : (
<Table>
<TableCaption>{labels.caption}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.code}</TableHead>
<TableHead scope="col">{labels.keepFor}</TableHead>
<TableHead scope="col">{labels.from}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{classes.map((retention) => (
<TableRow key={retention.code}>
<TableHead scope="row">{retention.code}</TableHead>
<TableCell>{retention.keepFor}</TableCell>
<TableCell>{bases[retention.from]}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{due.length === 0 ? (
<p>{labels.dueEmpty}</p>
) : (
<Table>
<TableCaption>{labels.dueCaption}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.code}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{due.map((document) => (
<TableRow key={document.documentId}>
<TableHead scope="row">
<a href={`${listHref}/${encodeURIComponent(document.documentId)}`}>{document.title}</a>
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)
}
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Button } from "@conusai-kit/ui/primitives"
// Adds a retention class. A class never changes once it exists: a new policy is a new class.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import { SelectField, serviceNotice, usePageNavigation } 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 { RETENTION_BASES, type RetentionClassView } from "../contract/index.js"
export interface RetentionFormLabels {
readonly heading: string
readonly code: string
readonly keepFor: string
readonly keepForHint: string
readonly from: string
readonly submit: string
readonly busy: string
readonly bases: Readonly<Record<RetentionClassView["from"], 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>>
}
export function RetentionForm({ slugs, labels }: { slugs: ProjectSlugs; labels: RetentionFormLabels }): ReactElement {
const navigation = usePageNavigation()
const create = useWrite<Record<string, unknown>, unknown>("archive.retention.create")
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const form = new FormData(event.currentTarget)
setState({ kind: "busy" })
try {
await create.mutate({
...slugs,
code: String(form.get("code") ?? "").trim(),
keepFor: String(form.get("keepFor") ?? "").trim(),
from: String(form.get("from") ?? "created"),
requestId,
})
await navigation.refresh()
setRequestId(crypto.randomUUID())
setState({ kind: "idle" })
} catch (error) {
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
}
}
return (
<form key={requestId} onSubmit={submit} aria-busy={state.kind === "busy"}>
<h2>{labels.heading}</h2>
<Field>
<FieldLabel>{labels.code}</FieldLabel>
<Input name="code" type="text" required maxLength={64} pattern="[A-Za-z0-9_\-]+" />
</Field>
<Field>
<FieldLabel>{labels.keepFor}</FieldLabel>
<Input name="keepFor" type="text" required maxLength={64} />
<FieldDescription>{labels.keepForHint}</FieldDescription>
</Field>
<SelectField
name="from"
label={labels.from}
defaultValue="created"
options={RETENTION_BASES.map((base) => ({ value: base, label: labels.bases[base] }))}
/>
<Button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</Button>
{state.kind === "failed" ? (
<Alert variant="destructive">
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
</Alert>
) : null}
</form>
)
}
+74
View File
@@ -0,0 +1,74 @@
"use client"
import { Button } from "@conusai-kit/ui/primitives"
// Adds a version: the browser stores the file through the files module, then asks this module to append
// it over the revision the page read. A version nobody else has overtaken lands; a stale one is refused.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useUpload, useWrite } from "@conusai-kit/ui/data"
import { serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
import { Alert, AlertDescription, Field, FieldLabel, Input } from "@conusai-kit/ui/primitives"
import { type FormEvent, type ReactElement, useState } from "react"
export interface VersionFormLabels {
readonly heading: string
readonly file: string
readonly submit: string
readonly busy: 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>>
}
export function VersionForm({
slugs,
documentId,
revision,
labels,
}: {
slugs: ProjectSlugs
documentId: string
revision: string
labels: VersionFormLabels
}): ReactElement {
const navigation = usePageNavigation()
const upload = useUpload()
const add = useWrite<Record<string, unknown>, unknown>("archive.version.add")
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ 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
setState({ kind: "busy" })
try {
const stored = await upload(slugs, file)
await add.mutate({ ...slugs, documentId, revision, fileId: stored.fileId, requestId })
await navigation.refresh()
setRequestId(crypto.randomUUID())
setState({ kind: "idle" })
} catch (error) {
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
}
}
return (
<form key={requestId} onSubmit={submit} aria-busy={state.kind === "busy"}>
<h2>{labels.heading}</h2>
<Field>
<FieldLabel>{labels.file}</FieldLabel>
<Input name="file" type="file" required />
</Field>
<Button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</Button>
{state.kind === "failed" ? (
<Alert variant="destructive">
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
</Alert>
) : null}
</form>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
// A document's versions. A server component: it holds no state, and every string is passed in.
import type { ReactElement } from "react"
import type { VersionView } from "../contract/index.js"
export interface VersionsLabels {
readonly title: string
/** How many versions, already filled with the plural form. */
readonly count: string
readonly caption: string
readonly sequence: string
readonly mediaType: string
readonly size: string
readonly hash: string
readonly created: string
}
export function Versions({
versions,
labels,
}: {
versions: readonly VersionView[]
labels: VersionsLabels
}): ReactElement {
return (
<section>
<h2>{labels.title}</h2>
<p>{labels.count}</p>
<Table>
<TableCaption>{labels.caption}</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">{labels.sequence}</TableHead>
<TableHead scope="col">{labels.mediaType}</TableHead>
<TableHead scope="col">{labels.size}</TableHead>
<TableHead scope="col">{labels.hash}</TableHead>
<TableHead scope="col">{labels.created}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{versions.map((version) => (
<TableRow key={version.versionId}>
<TableHead scope="row">{version.sequence}</TableHead>
<TableCell>{version.mediaType}</TableCell>
<TableCell>{version.size}</TableCell>
<TableCell>
<code>{version.sha256.slice(0, 12)}</code>
</TableCell>
<TableCell>{version.createdAt.slice(0, 10)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
)
}