@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.
- package/package.json +1 -1
- package/src/components/composite/AccountRadioPicker.tsx +120 -0
- package/src/components/composite/FormAdvancedSection.tsx +366 -0
- package/src/components/composite/SalesBodyEditor.tsx +418 -0
- package/src/components/composite/TransactionForm.tsx +1053 -0
- package/src/components/composite/TransferAccountsPicker.tsx +291 -0
- package/src/components/composite/TransferFeeChip.tsx +34 -0
- 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 +37 -0
|
@@ -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
|
+
});
|