@kahitsan/ksui 0.31.1 → 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.
@@ -0,0 +1,291 @@
1
+ import { Show, type JSX } from "solid-js";
2
+ import ArrowRightLeft from "lucide-solid/icons/arrow-right-left";
3
+ import Plus from "lucide-solid/icons/plus";
4
+ import AccountAvatar from "../base/AccountAvatar";
5
+ import FormField from "../base/FormField";
6
+ import AccountRadioPicker from "./AccountRadioPicker";
7
+ import type { TransactionAccount } from "./TransactionForm";
8
+ import { formatPHP } from "../../utils/formatPHP";
9
+
10
+ export interface TransferAccountsPickerProps {
11
+ accounts: TransactionAccount[];
12
+ sourceAccount: string;
13
+ setSourceAccount: (v: string) => void;
14
+ destAccount: string;
15
+ setDestAccount: (v: string) => void;
16
+ sourceLabel: string;
17
+ destLabel: string;
18
+ amount: string;
19
+ feeAmount: string;
20
+ feeEnabled: boolean;
21
+ }
22
+
23
+ interface AccountTileProps {
24
+ role: "from" | "to";
25
+ account: TransactionAccount | undefined;
26
+ emptyHint: string;
27
+ delta: number;
28
+ onEditRequest: () => void;
29
+ tileTestId: string;
30
+ clipClass: string;
31
+ }
32
+
33
+ function AccountTile(p: AccountTileProps): JSX.Element {
34
+ const isFilled = () => !!p.account;
35
+ const isFrom = () => p.role === "from";
36
+ return (
37
+ <button
38
+ type="button"
39
+ data-testid={p.tileTestId}
40
+ onClick={p.onEditRequest}
41
+ class={`group flex flex-1 min-w-0 items-center gap-3 border px-4 py-3 text-left transition-colors cursor-pointer ${p.clipClass}`}
42
+ classList={{
43
+ "border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10":
44
+ isFilled() && isFrom(),
45
+ "border-amber-500/40 bg-amber-500/5 hover:bg-amber-500/10":
46
+ isFilled() && !isFrom(),
47
+ "border-dashed border-zinc-700 bg-zinc-900/40 hover:border-amber-500/50 hover:bg-zinc-900/60":
48
+ !isFilled(),
49
+ }}
50
+ aria-label={
51
+ isFilled()
52
+ ? `${isFrom() ? "Source" : "Destination"}: ${p.account!.name} — tap to change`
53
+ : `Select ${isFrom() ? "source" : "destination"} account`
54
+ }
55
+ >
56
+ <Show
57
+ when={p.account}
58
+ keyed
59
+ fallback={
60
+ <span class="flex h-8 w-8 items-center justify-center text-zinc-500">
61
+ <Plus size={18} />
62
+ </span>
63
+ }
64
+ >
65
+ {(a) => (
66
+ <AccountAvatar
67
+ account={a}
68
+ size={28}
69
+ iconClass={isFrom() ? "text-blue-300" : "text-amber-300"}
70
+ />
71
+ )}
72
+ </Show>
73
+ <div class="flex min-w-0 flex-col">
74
+ <span
75
+ class="text-[10px] font-semibold uppercase tracking-widest"
76
+ classList={{
77
+ "text-blue-300": isFilled() && isFrom(),
78
+ "text-amber-300": isFilled() && !isFrom(),
79
+ "text-zinc-500": !isFilled(),
80
+ }}
81
+ >
82
+ {isFrom() ? "From" : "To"}
83
+ </span>
84
+ <Show
85
+ when={p.account}
86
+ keyed
87
+ fallback={
88
+ <span class="text-sm text-zinc-500">{p.emptyHint}</span>
89
+ }
90
+ >
91
+ {(a) => (
92
+ <>
93
+ <span class="truncate text-sm font-semibold text-zinc-100">
94
+ {a.name}
95
+ </span>
96
+ <Show when={a.balance != null}>
97
+ <span class="flex items-baseline gap-1 text-[11px] tabular-nums text-zinc-400">
98
+ <span class="truncate">{formatPHP(a.balance!)}</span>
99
+ <Show when={p.delta !== 0}>
100
+ <span
101
+ class="whitespace-nowrap font-semibold"
102
+ classList={{
103
+ "text-red-400": p.delta < 0,
104
+ "text-emerald-400": p.delta > 0,
105
+ }}
106
+ data-testid={`transactions-form-transfer-delta-${p.role}`}
107
+ >
108
+ ({p.delta > 0 ? "+" : ""}
109
+ {formatPHP(p.delta)})
110
+ </span>
111
+ </Show>
112
+ </span>
113
+ </Show>
114
+ </>
115
+ )}
116
+ </Show>
117
+ </div>
118
+ </button>
119
+ );
120
+ }
121
+
122
+ export default function TransferAccountsPicker(
123
+ props: TransferAccountsPickerProps
124
+ ) {
125
+ const sourceMeta = () =>
126
+ props.accounts.find((a) => a.id.toString() === props.sourceAccount);
127
+ const destMeta = () =>
128
+ props.accounts.find((a) => a.id.toString() === props.destAccount);
129
+
130
+ const swap = () => {
131
+ const s = props.sourceAccount;
132
+ const d = props.destAccount;
133
+ props.setSourceAccount(d);
134
+ props.setDestAccount(s);
135
+ };
136
+
137
+ const bothFilled = () => !!props.sourceAccount && !!props.destAccount;
138
+
139
+ const amountNum = () => {
140
+ const n = parseFloat(props.amount);
141
+ return Number.isFinite(n) && n > 0 ? n : 0;
142
+ };
143
+ const feeNum = () => {
144
+ if (!props.feeEnabled) return 0;
145
+ const n = parseFloat(props.feeAmount);
146
+ return Number.isFinite(n) && n > 0 ? n : 0;
147
+ };
148
+ const sourceDelta = () => -(amountNum() + feeNum());
149
+ const destDelta = () => amountNum();
150
+
151
+ return (
152
+ <div class="space-y-3">
153
+ <div class="flex items-stretch gap-2 max-sm:flex-col sm:flex-row">
154
+ <AccountTile
155
+ role="from"
156
+ account={sourceMeta()}
157
+ emptyHint="Select source"
158
+ delta={sourceDelta()}
159
+ onEditRequest={() => props.setSourceAccount("")}
160
+ tileTestId="transactions-form-transfer-tile-from"
161
+ clipClass="ks-hud-clip-top-right-bottom-left"
162
+ />
163
+
164
+ <Show
165
+ when={!bothFilled()}
166
+ fallback={
167
+ <button
168
+ type="button"
169
+ data-testid="transactions-form-transfer-swap"
170
+ onClick={swap}
171
+ class="flex h-9 w-9 shrink-0 items-center justify-center self-center border border-zinc-700 bg-zinc-900 text-zinc-300 transition-colors hover:border-amber-500/60 hover:text-amber-300 cursor-pointer ks-hud-clip-button max-sm:rotate-90"
172
+ aria-label="Swap source and destination"
173
+ title="Swap source and destination"
174
+ >
175
+ <ArrowRightLeft size={14} />
176
+ </button>
177
+ }
178
+ >
179
+ <div
180
+ class="shrink-0 self-center max-sm:py-1 sm:px-1"
181
+ aria-hidden="true"
182
+ >
183
+ <svg
184
+ viewBox="0 0 56 14"
185
+ class="max-sm:hidden sm:block h-3.5 w-14"
186
+ >
187
+ <defs>
188
+ <linearGradient
189
+ id="fin-flow-h"
190
+ gradientUnits="userSpaceOnUse"
191
+ x1="0"
192
+ y1="7"
193
+ x2="56"
194
+ y2="7"
195
+ >
196
+ <stop offset="0" stop-color="#3b82f6" />
197
+ <stop offset="1" stop-color="#f59e0b" />
198
+ </linearGradient>
199
+ </defs>
200
+ <line
201
+ x1="0"
202
+ y1="7"
203
+ x2="44"
204
+ y2="7"
205
+ stroke="url(#fin-flow-h)"
206
+ stroke-width="2"
207
+ class="fin-flow-dash"
208
+ />
209
+ <path d="M44 1 L56 7 L44 13 Z" fill="#f59e0b" />
210
+ </svg>
211
+ <svg
212
+ viewBox="0 0 14 40"
213
+ class="max-sm:block sm:hidden h-10 w-3.5"
214
+ >
215
+ <defs>
216
+ <linearGradient
217
+ id="fin-flow-v"
218
+ gradientUnits="userSpaceOnUse"
219
+ x1="7"
220
+ y1="0"
221
+ x2="7"
222
+ y2="40"
223
+ >
224
+ <stop offset="0" stop-color="#3b82f6" />
225
+ <stop offset="1" stop-color="#f59e0b" />
226
+ </linearGradient>
227
+ </defs>
228
+ <line
229
+ x1="7"
230
+ y1="0"
231
+ x2="7"
232
+ y2="30"
233
+ stroke="url(#fin-flow-v)"
234
+ stroke-width="2"
235
+ class="fin-flow-dash"
236
+ />
237
+ <path d="M1 30 L13 30 L7 40 Z" fill="#f59e0b" />
238
+ </svg>
239
+ </div>
240
+ </Show>
241
+
242
+ <AccountTile
243
+ role="to"
244
+ account={destMeta()}
245
+ emptyHint={
246
+ props.sourceAccount ? "Select destination" : "Destination"
247
+ }
248
+ delta={destDelta()}
249
+ onEditRequest={() => props.setDestAccount("")}
250
+ tileTestId="transactions-form-transfer-tile-to"
251
+ clipClass="ks-hud-clip-top-left-bottom-right"
252
+ />
253
+ </div>
254
+
255
+ <Show when={!props.sourceAccount}>
256
+ <div
257
+ class="animate-[fin-slide-fade-down_0.28s_ease-out]"
258
+ data-testid="transactions-form-transfer-source-picker"
259
+ >
260
+ <FormField label={props.sourceLabel}>
261
+ <AccountRadioPicker
262
+ accounts={props.accounts}
263
+ ariaLabel={props.sourceLabel}
264
+ value={props.sourceAccount}
265
+ onChange={props.setSourceAccount}
266
+ excludeId={props.destAccount}
267
+ autoDefault={false}
268
+ />
269
+ </FormField>
270
+ </div>
271
+ </Show>
272
+ <Show when={props.sourceAccount && !props.destAccount}>
273
+ <div
274
+ class="animate-[fin-slide-fade-down_0.28s_ease-out]"
275
+ data-testid="transactions-form-transfer-dest-picker"
276
+ >
277
+ <FormField label={props.destLabel}>
278
+ <AccountRadioPicker
279
+ accounts={props.accounts}
280
+ ariaLabel={props.destLabel}
281
+ value={props.destAccount}
282
+ onChange={props.setDestAccount}
283
+ excludeId={props.sourceAccount}
284
+ autoDefault={false}
285
+ />
286
+ </FormField>
287
+ </div>
288
+ </Show>
289
+ </div>
290
+ );
291
+ }
@@ -0,0 +1,34 @@
1
+ import X from "lucide-solid/icons/x";
2
+
3
+ export interface TransferFeeChipProps {
4
+ enabled: boolean;
5
+ onToggle: () => void;
6
+ }
7
+
8
+ export default function TransferFeeChip(props: TransferFeeChipProps) {
9
+ return (
10
+ <button
11
+ type="button"
12
+ data-testid="transactions-form-transfer-fee-toggle"
13
+ onClick={props.onToggle}
14
+ class="flex items-center gap-1.5 self-center rounded-md border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider transition-colors cursor-pointer"
15
+ classList={{
16
+ "border-blue-500/50 bg-blue-500/15 text-blue-300 hover:bg-blue-500/25":
17
+ props.enabled,
18
+ "border-zinc-700 bg-zinc-950/60 text-zinc-400 hover:border-blue-500/40 hover:text-blue-300":
19
+ !props.enabled,
20
+ }}
21
+ aria-pressed={props.enabled}
22
+ aria-label={props.enabled ? "Remove transfer fee" : "Add transfer fee"}
23
+ >
24
+ <span>Fees</span>
25
+ <span
26
+ class="inline-flex items-center justify-center"
27
+ classList={{ hidden: !props.enabled }}
28
+ aria-hidden="true"
29
+ >
30
+ <X size={11} />
31
+ </span>
32
+ </button>
33
+ );
34
+ }
@@ -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
+ });