@kahitsan/ksui 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/composite/resource/form-spec.ts +141 -0
- package/src/components/composite/resource/route-adapter.ts +63 -0
- package/src/components/composite/resource/route-spec.test.ts +125 -0
- package/src/components/composite/resource/route-spec.ts +158 -0
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Pure builder for a declarative form descriptor. field.* helpers compile DOWN
|
|
2
|
+
// to the EXISTING UiField union, so ResourceForm renders them with zero new
|
|
3
|
+
// path. defineForm(...).fields plugs straight into the route adapter as
|
|
4
|
+
// spec.fields. No solid-js — data only.
|
|
5
|
+
import type { FieldTransform, UiField, UiFieldSelectOption } from "./spec.js";
|
|
6
|
+
|
|
7
|
+
// ---- field defs ------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
// FieldDef carries the lowered UiField body minus the key (supplied at the
|
|
10
|
+
// fields-record key) and label (supplied per builder). `kind` is the discriminant.
|
|
11
|
+
export interface FieldDefText {
|
|
12
|
+
readonly kind: "text";
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly required?: boolean;
|
|
15
|
+
readonly transform: FieldTransform;
|
|
16
|
+
readonly placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface FieldDefTextarea {
|
|
19
|
+
readonly kind: "textarea";
|
|
20
|
+
readonly label: string;
|
|
21
|
+
readonly required?: boolean;
|
|
22
|
+
readonly transform: FieldTransform;
|
|
23
|
+
readonly placeholder?: string;
|
|
24
|
+
readonly rows?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface FieldDefSelect {
|
|
27
|
+
readonly kind: "select";
|
|
28
|
+
readonly label: string;
|
|
29
|
+
readonly required?: boolean;
|
|
30
|
+
readonly default?: string;
|
|
31
|
+
readonly options: readonly UiFieldSelectOption[];
|
|
32
|
+
}
|
|
33
|
+
// Declared-but-unwired kinds: present in the union so the contract is whole,
|
|
34
|
+
// but no field.* builder produces them — an unwired kind is a tsc error at the
|
|
35
|
+
// call site, never a silent runtime hole (the adapter also throws on it).
|
|
36
|
+
export interface FieldDefUnwired {
|
|
37
|
+
readonly kind: "currency" | "date" | "relation" | "file";
|
|
38
|
+
readonly label: string;
|
|
39
|
+
}
|
|
40
|
+
export type FieldDef = FieldDefText | FieldDefTextarea | FieldDefSelect | FieldDefUnwired;
|
|
41
|
+
|
|
42
|
+
export const field = {
|
|
43
|
+
text: (opts: {
|
|
44
|
+
label: string;
|
|
45
|
+
required?: boolean;
|
|
46
|
+
transform: FieldTransform;
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
}): FieldDefText =>
|
|
49
|
+
Object.freeze({
|
|
50
|
+
kind: "text",
|
|
51
|
+
label: opts.label,
|
|
52
|
+
transform: opts.transform,
|
|
53
|
+
...(opts.required ? { required: opts.required } : {}),
|
|
54
|
+
...(opts.placeholder !== undefined ? { placeholder: opts.placeholder } : {}),
|
|
55
|
+
}),
|
|
56
|
+
textarea: (opts: {
|
|
57
|
+
label: string;
|
|
58
|
+
required?: boolean;
|
|
59
|
+
transform: FieldTransform;
|
|
60
|
+
placeholder?: string;
|
|
61
|
+
rows?: number;
|
|
62
|
+
}): FieldDefTextarea =>
|
|
63
|
+
Object.freeze({
|
|
64
|
+
kind: "textarea",
|
|
65
|
+
label: opts.label,
|
|
66
|
+
transform: opts.transform,
|
|
67
|
+
...(opts.required ? { required: opts.required } : {}),
|
|
68
|
+
...(opts.rows !== undefined ? { rows: opts.rows } : {}),
|
|
69
|
+
...(opts.placeholder !== undefined ? { placeholder: opts.placeholder } : {}),
|
|
70
|
+
}),
|
|
71
|
+
select: (opts: {
|
|
72
|
+
label: string;
|
|
73
|
+
required?: boolean;
|
|
74
|
+
default?: string;
|
|
75
|
+
options: readonly UiFieldSelectOption[];
|
|
76
|
+
}): FieldDefSelect =>
|
|
77
|
+
Object.freeze({
|
|
78
|
+
kind: "select",
|
|
79
|
+
label: opts.label,
|
|
80
|
+
options: opts.options,
|
|
81
|
+
...(opts.default !== undefined ? { default: opts.default } : {}),
|
|
82
|
+
...(opts.required ? { required: opts.required } : {}),
|
|
83
|
+
}),
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
// ---- the form spec ---------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export interface FormSpec {
|
|
89
|
+
readonly name?: string;
|
|
90
|
+
readonly title?: string;
|
|
91
|
+
readonly fields: Readonly<Record<string, FieldDef>>;
|
|
92
|
+
// Binding seam: STRING command ids, not hardcoded verbs, so a flow runtime
|
|
93
|
+
// can slot in without recontracting the form.
|
|
94
|
+
readonly submit: { readonly create?: string; readonly update?: string; readonly label?: string };
|
|
95
|
+
readonly layout?: unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function defineForm(cfg: FormSpec): FormSpec {
|
|
99
|
+
return Object.freeze(cfg);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- lowering (consumed by ./route-adapter) ---------------------------------
|
|
103
|
+
|
|
104
|
+
/** Fold one FieldDef + its key into the existing UiField. THROWS on an
|
|
105
|
+
* unwired kind so the wired subset fails loudly at build/test. */
|
|
106
|
+
export function fieldToUiField(key: string, def: FieldDef): UiField {
|
|
107
|
+
switch (def.kind) {
|
|
108
|
+
case "text":
|
|
109
|
+
return {
|
|
110
|
+
key,
|
|
111
|
+
label: def.label,
|
|
112
|
+
type: "text",
|
|
113
|
+
...(def.required ? { required: def.required } : {}),
|
|
114
|
+
transform: def.transform,
|
|
115
|
+
...(def.placeholder !== undefined ? { placeholder: def.placeholder } : {}),
|
|
116
|
+
};
|
|
117
|
+
case "textarea":
|
|
118
|
+
return {
|
|
119
|
+
key,
|
|
120
|
+
label: def.label,
|
|
121
|
+
type: "textarea",
|
|
122
|
+
...(def.required ? { required: def.required } : {}),
|
|
123
|
+
transform: def.transform,
|
|
124
|
+
...(def.rows !== undefined ? { rows: def.rows } : {}),
|
|
125
|
+
...(def.placeholder !== undefined ? { placeholder: def.placeholder } : {}),
|
|
126
|
+
};
|
|
127
|
+
case "select":
|
|
128
|
+
return {
|
|
129
|
+
key,
|
|
130
|
+
label: def.label,
|
|
131
|
+
type: "select",
|
|
132
|
+
...(def.default !== undefined ? { default: def.default } : {}),
|
|
133
|
+
options: def.options,
|
|
134
|
+
...(def.required ? { required: def.required } : {}),
|
|
135
|
+
};
|
|
136
|
+
default:
|
|
137
|
+
throw new Error(
|
|
138
|
+
`form-spec: field "${key}" uses unwired kind "${(def as { kind: string }).kind}" (only text/textarea/select are wired)`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The compile-down core: routeToResourceSpec folds a RouteSpec DOWN to the
|
|
2
|
+
// EXISTING ResourceUiSpec byte-identically, so ResourcePage (untouched) is
|
|
3
|
+
// the single render engine. THROWS on unrepresentable config (a 2nd view mode,
|
|
4
|
+
// a cards body, an unwired field kind) so the table-only subset is enforced at
|
|
5
|
+
// author/build time, never silently dropped. Pure — no solid-js.
|
|
6
|
+
import type { ResourceUiSpec, UiColumn, UiField } from "./spec.js";
|
|
7
|
+
import type { BodyView, RouteSpec } from "./route-spec.js";
|
|
8
|
+
import { fieldToUiField } from "./form-spec.js";
|
|
9
|
+
|
|
10
|
+
/** Resolve the single body view, rejecting the multi-view / cards cases the
|
|
11
|
+
* table-only subset does not render yet. */
|
|
12
|
+
function resolveTableView(view: RouteSpec["view"]): BodyView {
|
|
13
|
+
if ("views" in view) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"route-adapter: multi-view routes are not supported (a second view mode is deferred)",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (view.kind !== "table") {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`route-adapter: body view "${view.kind}" is not wired (only "table" lowers to ResourcePage)`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return view;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function routeColumns(view: BodyView): UiColumn[] {
|
|
27
|
+
if (view.kind !== "table") {
|
|
28
|
+
throw new Error(`route-adapter: cannot lower a "${view.kind}" body to columns`);
|
|
29
|
+
}
|
|
30
|
+
return view.columns.map((c) =>
|
|
31
|
+
c.orderable === undefined
|
|
32
|
+
? { key: c.key, title: c.title, render: c.render }
|
|
33
|
+
: { key: c.key, title: c.title, orderable: c.orderable, render: c.render },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function routeFields(route: RouteSpec): UiField[] {
|
|
38
|
+
if (!route.form) return [];
|
|
39
|
+
return Object.entries(route.form.fields).map(([key, def]) => fieldToUiField(key, def));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function routeToResourceSpec(route: RouteSpec): ResourceUiSpec {
|
|
43
|
+
const view = resolveTableView(route.view);
|
|
44
|
+
|
|
45
|
+
const spec: ResourceUiSpec = {
|
|
46
|
+
basePath: route.basePath,
|
|
47
|
+
title: route.title,
|
|
48
|
+
...(route.subtitle !== undefined ? { subtitle: route.subtitle } : {}),
|
|
49
|
+
permissions: {
|
|
50
|
+
view: route.permissions.view,
|
|
51
|
+
edit: route.permissions.edit,
|
|
52
|
+
delete: route.permissions.delete,
|
|
53
|
+
},
|
|
54
|
+
softDeleteField: route.softDeleteField,
|
|
55
|
+
testIdPrefix: route.testIdPrefix,
|
|
56
|
+
columns: routeColumns(view),
|
|
57
|
+
fields: routeFields(route),
|
|
58
|
+
...(route.toolbar?.filters !== undefined ? { filters: route.toolbar.filters } : {}),
|
|
59
|
+
detail: route.detail,
|
|
60
|
+
labels: route.labels,
|
|
61
|
+
};
|
|
62
|
+
return spec;
|
|
63
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Unit tests for the RouteSpec builder layer + its lowering. They pin the
|
|
2
|
+
// contract that a built RouteSpec lowers to the EXACT ResourceUiSpec a
|
|
3
|
+
// hand-authored spec would produce, and that unrepresentable config fails
|
|
4
|
+
// loudly instead of being silently dropped.
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { Cell, action, col, defineRoute, setting, table } from "./route-spec";
|
|
7
|
+
import { defineForm, field, fieldToUiField } from "./form-spec";
|
|
8
|
+
import { routeToResourceSpec } from "./route-adapter";
|
|
9
|
+
import type { ResourceUiSpec } from "./spec";
|
|
10
|
+
|
|
11
|
+
const LABELS = {
|
|
12
|
+
add: "Add Vendor",
|
|
13
|
+
createTitle: "New Vendor",
|
|
14
|
+
createSubmit: "Create",
|
|
15
|
+
editTitle: "Edit Vendor",
|
|
16
|
+
editSubmit: "Save",
|
|
17
|
+
titleField: "name",
|
|
18
|
+
searchPlaceholder: "Search vendors",
|
|
19
|
+
empty: "No vendors yet.",
|
|
20
|
+
noResults: "No vendors match.",
|
|
21
|
+
createErrorFallback: "Could not create the vendor.",
|
|
22
|
+
updateErrorFallback: "Could not update the vendor.",
|
|
23
|
+
networkError: "Network error — try again.",
|
|
24
|
+
archiveTitle: "Archive vendor?",
|
|
25
|
+
archiveMessage: "It can be restored later.",
|
|
26
|
+
archiveConfirm: "Archive",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
function vendorRoute() {
|
|
30
|
+
return defineRoute({
|
|
31
|
+
title: "Vendors",
|
|
32
|
+
basePath: "/api/vendors",
|
|
33
|
+
softDeleteField: "is_active",
|
|
34
|
+
testIdPrefix: "vendor",
|
|
35
|
+
permissions: { view: "vendors.view", edit: ["vendors.edit"], delete: "vendors.delete" },
|
|
36
|
+
header: {
|
|
37
|
+
actions: [action("add", { label: "Add Vendor", flow: "vendors.create" })],
|
|
38
|
+
},
|
|
39
|
+
view: table({
|
|
40
|
+
columns: [
|
|
41
|
+
col("name", { title: "Name", orderable: true, render: Cell.Title }),
|
|
42
|
+
col("kind", { title: "Kind", render: Cell.Enum({ a: "A", b: "B" }) }),
|
|
43
|
+
col("notes", { title: "Notes", render: Cell.Text({ muted: true }) }),
|
|
44
|
+
],
|
|
45
|
+
}),
|
|
46
|
+
form: defineForm({
|
|
47
|
+
fields: {
|
|
48
|
+
name: field.text({ label: "Name", required: true, transform: "trim" }),
|
|
49
|
+
notes: field.textarea({ label: "Notes", transform: "trimOrNull", rows: 3 }),
|
|
50
|
+
kind: field.select({
|
|
51
|
+
label: "Kind",
|
|
52
|
+
default: "a",
|
|
53
|
+
options: [
|
|
54
|
+
{ value: "a", label: "A" },
|
|
55
|
+
{ value: "b", label: "B" },
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
submit: { create: "vendors.create", update: "vendors.update" },
|
|
60
|
+
}),
|
|
61
|
+
detail: [{ label: "Name", value: { type: "field", key: "name" } }],
|
|
62
|
+
settings: { pageSize: setting.number({ default: 25 }) },
|
|
63
|
+
labels: LABELS,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("routeToResourceSpec lowering", () => {
|
|
68
|
+
it("lowers a built route to the hand-authored ResourceUiSpec shape", () => {
|
|
69
|
+
const lowered = routeToResourceSpec(vendorRoute());
|
|
70
|
+
const hand: ResourceUiSpec = {
|
|
71
|
+
basePath: "/api/vendors",
|
|
72
|
+
title: "Vendors",
|
|
73
|
+
permissions: { view: "vendors.view", edit: ["vendors.edit"], delete: "vendors.delete" },
|
|
74
|
+
softDeleteField: "is_active",
|
|
75
|
+
testIdPrefix: "vendor",
|
|
76
|
+
columns: [
|
|
77
|
+
{ key: "name", title: "Name", orderable: true, render: { type: "title" } },
|
|
78
|
+
{ key: "kind", title: "Kind", render: { type: "enum", labels: { a: "A", b: "B" } } },
|
|
79
|
+
{ key: "notes", title: "Notes", render: { type: "text", muted: true } },
|
|
80
|
+
],
|
|
81
|
+
fields: [
|
|
82
|
+
{ key: "name", label: "Name", type: "text", required: true, transform: "trim" },
|
|
83
|
+
{ key: "notes", label: "Notes", type: "textarea", transform: "trimOrNull", rows: 3 },
|
|
84
|
+
{
|
|
85
|
+
key: "kind",
|
|
86
|
+
label: "Kind",
|
|
87
|
+
type: "select",
|
|
88
|
+
default: "a",
|
|
89
|
+
options: [
|
|
90
|
+
{ value: "a", label: "A" },
|
|
91
|
+
{ value: "b", label: "B" },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
detail: [{ label: "Name", value: { type: "field", key: "name" } }],
|
|
96
|
+
labels: LABELS,
|
|
97
|
+
};
|
|
98
|
+
expect(lowered).toEqual(hand);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("omits `orderable` (does not emit false) when col() left it out", () => {
|
|
102
|
+
const lowered = routeToResourceSpec(vendorRoute());
|
|
103
|
+
expect("orderable" in lowered.columns[1]).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("throws on a cards body (unwired view)", () => {
|
|
107
|
+
const route = defineRoute({
|
|
108
|
+
...vendorRoute(),
|
|
109
|
+
view: { kind: "cards", item: {} },
|
|
110
|
+
});
|
|
111
|
+
expect(() => routeToResourceSpec(route)).toThrow(/not wired/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("throws on a multi-view route", () => {
|
|
115
|
+
const route = defineRoute({
|
|
116
|
+
...vendorRoute(),
|
|
117
|
+
view: { views: { t: table({ columns: [] }) }, default: "t" },
|
|
118
|
+
});
|
|
119
|
+
expect(() => routeToResourceSpec(route)).toThrow(/multi-view/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("throws on an unwired field kind", () => {
|
|
123
|
+
expect(() => fieldToUiField("due", { kind: "date", label: "Due" })).toThrow(/unwired kind/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Pure builder layer for declarative resource routes. defineRoute() composes a
|
|
2
|
+
// frozen RouteSpec out of small helpers; the adapter (./route-adapter) folds it
|
|
3
|
+
// DOWN to the existing ResourceUiSpec, so ResourcePage stays the one render
|
|
4
|
+
// engine. No solid-js — this module is data only, like ./spec.ts.
|
|
5
|
+
import type { ColumnTone, UiColumnRender, UiFilter, UiDetailRow } from "./spec.js";
|
|
6
|
+
import type { FormSpec } from "./form-spec.js";
|
|
7
|
+
|
|
8
|
+
// ---- columns ---------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/** A column descriptor; `render` is the EXISTING UiColumnRender union, so
|
|
11
|
+
* no new cell-rendering path is introduced. */
|
|
12
|
+
export interface RouteColumn {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly title: string;
|
|
15
|
+
readonly orderable?: boolean;
|
|
16
|
+
readonly render: UiColumnRender;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Cell.* helpers emit the existing UiColumnRender union — the title field is the
|
|
20
|
+
// column key, supplied at col() time, so these only carry the render shape.
|
|
21
|
+
export const Cell = {
|
|
22
|
+
Title: { type: "title" } as const satisfies UiColumnRender,
|
|
23
|
+
Enum: (labels: Readonly<Record<string, string>>): UiColumnRender => ({ type: "enum", labels }),
|
|
24
|
+
Status: (opts: {
|
|
25
|
+
active: { label: string; tone: ColumnTone };
|
|
26
|
+
inactive: { label: string; tone: ColumnTone };
|
|
27
|
+
}): UiColumnRender => ({ type: "status", active: opts.active, inactive: opts.inactive }),
|
|
28
|
+
Text: (opts?: { muted?: boolean }): UiColumnRender =>
|
|
29
|
+
opts?.muted === undefined ? { type: "text" } : { type: "text", muted: opts.muted },
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** Build a column. `orderable` is omitted (not set false) when not requested so
|
|
33
|
+
* the lowered spec matches a hand-authored one that left it out. */
|
|
34
|
+
export function col(
|
|
35
|
+
key: string,
|
|
36
|
+
opts: { title: string; orderable?: boolean; render: UiColumnRender },
|
|
37
|
+
): RouteColumn {
|
|
38
|
+
const c: RouteColumn =
|
|
39
|
+
opts.orderable === undefined
|
|
40
|
+
? { key, title: opts.title, render: opts.render }
|
|
41
|
+
: { key, title: opts.title, orderable: opts.orderable, render: opts.render };
|
|
42
|
+
return Object.freeze(c);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- body views ------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** A `table` body. `kind` keys a future body-renderer registry; today only
|
|
48
|
+
* `table` is wired (the adapter THROWS on anything else). `cards` is reserved
|
|
49
|
+
* in the union but the builder for it is intentionally absent so an unwired
|
|
50
|
+
* view is a tsc error at the call site, not a runtime hole. */
|
|
51
|
+
export interface TableView {
|
|
52
|
+
readonly kind: "table";
|
|
53
|
+
readonly columns: readonly RouteColumn[];
|
|
54
|
+
}
|
|
55
|
+
export interface CardsView {
|
|
56
|
+
readonly kind: "cards";
|
|
57
|
+
readonly item: unknown;
|
|
58
|
+
}
|
|
59
|
+
export type BodyView = TableView | CardsView;
|
|
60
|
+
|
|
61
|
+
/** The only wired body builder. */
|
|
62
|
+
export function table(opts: { columns: readonly RouteColumn[] }): TableView {
|
|
63
|
+
return Object.freeze({ kind: "table", columns: opts.columns });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- header actions ---------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/** A header action. `flow` is a STRING id (never an inline fn) so a flow
|
|
69
|
+
* runtime can slot in behind a host-provided runner without recontracting. */
|
|
70
|
+
export interface RouteAction {
|
|
71
|
+
readonly id: string;
|
|
72
|
+
readonly label: string;
|
|
73
|
+
readonly icon?: string;
|
|
74
|
+
readonly variant?: string;
|
|
75
|
+
readonly flow: string;
|
|
76
|
+
}
|
|
77
|
+
export function action(
|
|
78
|
+
id: string,
|
|
79
|
+
opts: { label: string; icon?: string; variant?: string; flow: string },
|
|
80
|
+
): RouteAction {
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
id,
|
|
83
|
+
label: opts.label,
|
|
84
|
+
flow: opts.flow,
|
|
85
|
+
...(opts.icon ? { icon: opts.icon } : {}),
|
|
86
|
+
...(opts.variant ? { variant: opts.variant } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- the route spec ---------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export interface RoutePermissions {
|
|
93
|
+
readonly view: string;
|
|
94
|
+
readonly edit: readonly string[];
|
|
95
|
+
readonly delete: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Labels carried straight onto the lowered ResourceUiSpec.labels. */
|
|
99
|
+
export type RouteLabels = {
|
|
100
|
+
readonly add: string;
|
|
101
|
+
readonly createTitle: string;
|
|
102
|
+
readonly createSubmit: string;
|
|
103
|
+
readonly editTitle: string;
|
|
104
|
+
readonly editSubmit: string;
|
|
105
|
+
readonly titleField: string;
|
|
106
|
+
readonly searchPlaceholder: string;
|
|
107
|
+
readonly empty: string;
|
|
108
|
+
readonly noResults: string;
|
|
109
|
+
readonly createErrorFallback: string;
|
|
110
|
+
readonly updateErrorFallback: string;
|
|
111
|
+
readonly networkError: string;
|
|
112
|
+
readonly archiveTitle: string;
|
|
113
|
+
readonly archiveMessage: string;
|
|
114
|
+
readonly archiveConfirm: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** A route-load setting. Reserved for a host settings store; the adapter does
|
|
118
|
+
* not lower it (it is host-resolved later), so it is presentation metadata
|
|
119
|
+
* only for now. */
|
|
120
|
+
export interface SettingDecl {
|
|
121
|
+
readonly kind: "number";
|
|
122
|
+
readonly default: number;
|
|
123
|
+
}
|
|
124
|
+
export const setting = {
|
|
125
|
+
number: (opts: { default: number }): SettingDecl =>
|
|
126
|
+
Object.freeze({ kind: "number", default: opts.default }),
|
|
127
|
+
} as const;
|
|
128
|
+
|
|
129
|
+
export interface RouteSpec {
|
|
130
|
+
readonly path?: string;
|
|
131
|
+
readonly title: string;
|
|
132
|
+
readonly subtitle?: string;
|
|
133
|
+
readonly icon?: string;
|
|
134
|
+
readonly basePath: string;
|
|
135
|
+
readonly softDeleteField: string;
|
|
136
|
+
readonly testIdPrefix: string;
|
|
137
|
+
readonly permissions: RoutePermissions;
|
|
138
|
+
readonly header?: { readonly actions: readonly RouteAction[] };
|
|
139
|
+
readonly toolbar?: {
|
|
140
|
+
readonly search?: { readonly placeholder: string; readonly fields: readonly string[] };
|
|
141
|
+
readonly filters?: readonly UiFilter[];
|
|
142
|
+
};
|
|
143
|
+
readonly view:
|
|
144
|
+
| BodyView
|
|
145
|
+
| { readonly views: Readonly<Record<string, BodyView>>; readonly default: string };
|
|
146
|
+
readonly form?: FormSpec;
|
|
147
|
+
readonly detail: readonly UiDetailRow[];
|
|
148
|
+
readonly settings?: Readonly<Record<string, SettingDecl>>;
|
|
149
|
+
// Sticky-footer slot — reserved in the contract, not rendered yet (needs an
|
|
150
|
+
// additive page-shell `footer?` prop on the consumer side).
|
|
151
|
+
readonly footer?: unknown;
|
|
152
|
+
readonly labels: RouteLabels;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** defineRoute composes a frozen RouteSpec. Pure — no rendering, no side effects. */
|
|
156
|
+
export function defineRoute(cfg: RouteSpec): RouteSpec {
|
|
157
|
+
return Object.freeze(cfg);
|
|
158
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -191,6 +191,14 @@ export type {
|
|
|
191
191
|
} from "./components/composite/resource/ResourcePage";
|
|
192
192
|
export * from "./components/composite/resource/spec";
|
|
193
193
|
|
|
194
|
+
// Builder layer over ResourceUiSpec: defineRoute/defineForm compose a frozen
|
|
195
|
+
// RouteSpec/FormSpec out of small helpers (col, Cell.*, table, action, field.*,
|
|
196
|
+
// setting.*), and routeToResourceSpec lowers it to the exact ResourceUiSpec a
|
|
197
|
+
// hand-authored spec would produce — ResourcePage stays the one render engine.
|
|
198
|
+
export * from "./components/composite/resource/route-spec";
|
|
199
|
+
export * from "./components/composite/resource/form-spec";
|
|
200
|
+
export { routeToResourceSpec } from "./components/composite/resource/route-adapter";
|
|
201
|
+
|
|
194
202
|
// U6 — declarative file/media field for the spec-driven form runtime. Value is an
|
|
195
203
|
// opaque asset handle; host injects onUpload + presignUrl (storage-agnostic).
|
|
196
204
|
export { default as FileField, type FileFieldProps, type AssetHandle, type FileFieldStatus } from "./components/composite/FileField";
|