@zerotal/admin 1.0.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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/package.json +78 -0
- package/src/Cluster.ts +50 -0
- package/src/Panel.ts +288 -0
- package/src/PanelInstance.ts +644 -0
- package/src/Resource.ts +918 -0
- package/src/actions/Action.ts +607 -0
- package/src/actions/ImportRecordsJob.ts +108 -0
- package/src/actions/csv.ts +123 -0
- package/src/actions/index.ts +39 -0
- package/src/actions/render.tsx +181 -0
- package/src/actions/transfer.ts +307 -0
- package/src/actions/xlsx.ts +304 -0
- package/src/auth/AuthLayout.tsx +34 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
- package/src/auth/pages/LoginPage.tsx +121 -0
- package/src/auth/pages/ProfilePage.tsx +216 -0
- package/src/auth/pages/ResetPasswordPage.tsx +103 -0
- package/src/auth/pages/VerifyEmailPage.tsx +68 -0
- package/src/auth/register.ts +44 -0
- package/src/authRoles.ts +141 -0
- package/src/commands/MakeAdminResourceCommand.ts +181 -0
- package/src/config.ts +128 -0
- package/src/dashboardLayout.ts +101 -0
- package/src/databaseMedia.ts +148 -0
- package/src/databaseNotifications.ts +169 -0
- package/src/form/Field.ts +928 -0
- package/src/form/ResourceForm.ts +48 -0
- package/src/form/Section.ts +364 -0
- package/src/form/editors.ts +43 -0
- package/src/form/index.ts +59 -0
- package/src/history.ts +151 -0
- package/src/impersonation.ts +126 -0
- package/src/index.ts +380 -0
- package/src/infolist/Entry.ts +537 -0
- package/src/infolist/Section.ts +99 -0
- package/src/infolist/index.ts +38 -0
- package/src/media.ts +297 -0
- package/src/notifications.ts +65 -0
- package/src/pages/AdminPage.ts +100 -0
- package/src/pages/ConsolePage.tsx +324 -0
- package/src/pages/DashboardPage.tsx +264 -0
- package/src/pages/MediaPage.tsx +346 -0
- package/src/pages/NotificationsPage.tsx +155 -0
- package/src/pages/RecordViewPage.tsx +951 -0
- package/src/pages/ResourceFormPage.tsx +1856 -0
- package/src/pages/ResourceListPage.tsx +2552 -0
- package/src/pages/RolesPage.tsx +325 -0
- package/src/pages/SearchPage.tsx +169 -0
- package/src/plugin.ts +283 -0
- package/src/provider/AdminAbilityMiddleware.ts +25 -0
- package/src/provider/AdminGuardMiddleware.ts +29 -0
- package/src/provider/AdminProvider.ts +334 -0
- package/src/relations/RelationManager.ts +114 -0
- package/src/renderHooks.ts +86 -0
- package/src/roles.ts +175 -0
- package/src/savedViews.ts +79 -0
- package/src/support/ability.ts +73 -0
- package/src/support/authorize.ts +105 -0
- package/src/support/countCache.ts +37 -0
- package/src/support/hostPage.ts +30 -0
- package/src/table/Column.ts +353 -0
- package/src/table/Constraint.ts +238 -0
- package/src/table/Filter.ts +275 -0
- package/src/table/Group.ts +73 -0
- package/src/table/Tab.ts +77 -0
- package/src/testing.ts +121 -0
- package/src/theme.ts +70 -0
- package/src/ui/AdminLayout.tsx +355 -0
- package/src/ui/Breadcrumbs.tsx +84 -0
- package/src/ui/environmentIndicator.tsx +63 -0
- package/src/ui/icons.tsx +124 -0
- package/src/widgets/Widget.ts +251 -0
- package/src/widgets/render.tsx +154 -0
|
@@ -0,0 +1,1856 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// Create / Edit page — a reactive form. Fields are declared on
|
|
3
|
+
// the resource (`form()`); a generated Flow Form (see form/ResourceForm)
|
|
4
|
+
// backs the binding + validation. Inputs author `flow:model="form.<key>"`
|
|
5
|
+
// directly (the same markup the compiler emits for `value={this.form.x}`), so
|
|
6
|
+
// edits round-trip into the exposed `form` object with zero per-field wiring.
|
|
7
|
+
//
|
|
8
|
+
// One page subclass is generated per resource (not per mode): the create/edit
|
|
9
|
+
// distinction is resolved from the route at runtime. Generating a *separate*
|
|
10
|
+
// subclass per mode would break Flow's field-decorator registration, which
|
|
11
|
+
// binds @expose/@locked fields to the first-constructed subclass's prototype.
|
|
12
|
+
|
|
13
|
+
import { Component, locked, expose } from "@zerotal/flow";
|
|
14
|
+
import type { HtmlNode, Form } from "@zerotal/flow";
|
|
15
|
+
import type { HttpContext } from "@zerotal/core";
|
|
16
|
+
import { makeAdminLayout } from "../ui/AdminLayout.tsx";
|
|
17
|
+
import { Breadcrumbs, resourceTrail } from "../ui/Breadcrumbs.tsx";
|
|
18
|
+
import type { MediaItem } from "../media.ts";
|
|
19
|
+
import { isImage, isUpload, mediaUrl, resolveMediaSrc, storeMedia } from "../media.ts";
|
|
20
|
+
import { Icon } from "../ui/icons.tsx";
|
|
21
|
+
import { resolveRenderHooks } from "../renderHooks.ts";
|
|
22
|
+
import type { ResourceClass } from "../Panel.ts";
|
|
23
|
+
import { Panel, DEFAULT_PANEL_ID } from "../Panel.ts";
|
|
24
|
+
import type { PanelInstance } from "../PanelInstance.ts";
|
|
25
|
+
import { adminHead } from "../theme.ts";
|
|
26
|
+
import type { Field, FieldMode, ResourceFormClass, SelectOption } from "../form/index.ts";
|
|
27
|
+
import {
|
|
28
|
+
toFormLayout,
|
|
29
|
+
flattenFields,
|
|
30
|
+
type FormSection,
|
|
31
|
+
type FormTabs,
|
|
32
|
+
type Wizard,
|
|
33
|
+
type FormSplit,
|
|
34
|
+
type Callout,
|
|
35
|
+
type CalloutTone,
|
|
36
|
+
type Prime,
|
|
37
|
+
} from "../form/index.ts";
|
|
38
|
+
import { EDITOR_SCRIPT } from "../form/editors.ts";
|
|
39
|
+
import { RuleBuilder, runValidation } from "@zerotal/validator";
|
|
40
|
+
import type { Schema } from "@zerotal/validator";
|
|
41
|
+
import { Tabs } from "@zerotal/flow-ui";
|
|
42
|
+
|
|
43
|
+
/** A generated Form class + its visible fields, for one mode. */
|
|
44
|
+
export interface FormModeConfig {
|
|
45
|
+
FormClass: ResourceFormClass;
|
|
46
|
+
fields: Field[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface FormMeta {
|
|
50
|
+
resource: ResourceClass;
|
|
51
|
+
create: FormModeConfig;
|
|
52
|
+
edit: FormModeConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Registry of per-resource form config, keyed by panel and slug. The page is a
|
|
57
|
+
* single class (not a per-resource subclass) registered on every Create/Edit
|
|
58
|
+
* route, so its @expose/@locked fields register on one prototype; it resolves
|
|
59
|
+
* which resource it's serving from the route at runtime. Two panels may each
|
|
60
|
+
* register a `users` resource, hence the panel id in the key.
|
|
61
|
+
*/
|
|
62
|
+
const _formRegistry = new Map<string, FormMeta>();
|
|
63
|
+
|
|
64
|
+
const formKey = (panelId: string, slug: string): string => `${panelId}:${slug}`;
|
|
65
|
+
|
|
66
|
+
/** Register a resource's form config so the shared page can resolve it. */
|
|
67
|
+
export function registerResourceForm(panelId: string, slug: string, meta: FormMeta): void {
|
|
68
|
+
_formRegistry.set(formKey(panelId, slug), meta);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SPAN_CLASS = ["", "", "sm:col-span-2", "sm:col-span-3", "sm:col-span-4"];
|
|
72
|
+
const INPUT_CLASS =
|
|
73
|
+
"mt-1.5 block w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted-foreground focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-60";
|
|
74
|
+
|
|
75
|
+
export class ResourceFormPage extends Component {
|
|
76
|
+
@locked slug = "";
|
|
77
|
+
@locked mode: FieldMode = "create";
|
|
78
|
+
@locked recordId = "";
|
|
79
|
+
/**
|
|
80
|
+
* Which panel is being served. Locked rather than derived, so the WebSocket
|
|
81
|
+
* round-trips that drive this page — which carry no URL — keep resolving the
|
|
82
|
+
* same resource, base path and shell as the initial render.
|
|
83
|
+
*/
|
|
84
|
+
@locked panelId = DEFAULT_PANEL_ID;
|
|
85
|
+
/** The parent record's id, for a resource nested under another. */
|
|
86
|
+
@locked parentId = "";
|
|
87
|
+
/**
|
|
88
|
+
* The record's version as it was when this form loaded, for a resource using
|
|
89
|
+
* `optimisticLock`. Locked, so a WebSocket save compares against what was
|
|
90
|
+
* actually rendered rather than whatever the client claims.
|
|
91
|
+
*/
|
|
92
|
+
@locked loadedVersion = "";
|
|
93
|
+
/** The locale being edited, for a resource with translatable fields. */
|
|
94
|
+
@expose formLocale = "";
|
|
95
|
+
/**
|
|
96
|
+
* Every locale's value for each translatable field, as the record held them.
|
|
97
|
+
*
|
|
98
|
+
* Kept because the form only ever shows one locale: without the rest, saving
|
|
99
|
+
* an English edit would write `{ en: "…" }` over a record that also had French
|
|
100
|
+
* and German. Locked so the client cannot rewrite the locales it isn't editing.
|
|
101
|
+
*/
|
|
102
|
+
@locked translations: Record<string, Record<string, unknown>> = {};
|
|
103
|
+
/** Resolved select options (from `.optionsUsing()`), cached per render. */
|
|
104
|
+
private _resolvedOptions: Record<string, SelectOption[]> = {};
|
|
105
|
+
// Initialized here (not in onBoot) so the @expose field registration runs — the
|
|
106
|
+
// decorator hooks the field initializer, and an uninitialized field would leave
|
|
107
|
+
// `form` out of the exposed set, so client `form.*` syncs would be dropped. On
|
|
108
|
+
// WebSocket round-trips the Form synth replaces this with the restored instance.
|
|
109
|
+
@expose form: Form & Record<string, unknown> = {} as Form & Record<string, unknown>;
|
|
110
|
+
|
|
111
|
+
/** The locale this form is editing. */
|
|
112
|
+
private _locale(): string {
|
|
113
|
+
const R = this._meta?.resource;
|
|
114
|
+
return this.formLocale || R?.locales[0] || "en";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Switch the locale being edited.
|
|
119
|
+
*
|
|
120
|
+
* The current locale's text is banked into the translation map first, so
|
|
121
|
+
* switching tabs mid-edit does not throw the work away, then the new locale's
|
|
122
|
+
* text is loaded into the same fields.
|
|
123
|
+
*/
|
|
124
|
+
@expose switchLocale(code: unknown): void {
|
|
125
|
+
const R = this._meta?.resource;
|
|
126
|
+
if (!R) return;
|
|
127
|
+
const next = String(code ?? "");
|
|
128
|
+
if (!R.locales.includes(next) || next === this._locale()) return;
|
|
129
|
+
|
|
130
|
+
const current = this._locale();
|
|
131
|
+
for (const key of R.translatable) {
|
|
132
|
+
const map = { ...(this.translations[key] ?? {}) };
|
|
133
|
+
map[current] = (this.form as Record<string, unknown>)[key];
|
|
134
|
+
this.translations[key] = map;
|
|
135
|
+
(this.form as Record<string, unknown>)[key] = map[next] ?? "";
|
|
136
|
+
}
|
|
137
|
+
this.formLocale = next;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private get _meta(): FormMeta | undefined {
|
|
141
|
+
return _formRegistry.get(formKey(this.panelId, this.slug));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The panel this page is serving. */
|
|
145
|
+
private get _panel(): PanelInstance {
|
|
146
|
+
return Panel.get(this.panelId) ?? Panel.default();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The shell. Rendered through the instance hook rather than `static layout`,
|
|
151
|
+
* because one class serves every panel and the shell differs per panel — the
|
|
152
|
+
* layout marks its own identity so the client still swaps only the content
|
|
153
|
+
* slot when navigating within a panel.
|
|
154
|
+
*/
|
|
155
|
+
override layout(page: HtmlNode): Promise<HtmlNode> {
|
|
156
|
+
return new (makeAdminLayout(this._panel))().render(page);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Stylesheet + theme tokens for the panel owning this request. */
|
|
160
|
+
static get head(): string {
|
|
161
|
+
const cfg = Panel.current().config();
|
|
162
|
+
return adminHead(cfg.brand ?? "Admin", cfg.theme);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private get _cfg(): FormModeConfig | undefined {
|
|
166
|
+
const meta = this._meta;
|
|
167
|
+
return meta ? (this.mode === "edit" ? meta.edit : meta.create) : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Work out which resource, mode and record the URL is asking for.
|
|
172
|
+
*
|
|
173
|
+
* A resource's index can sit at any depth — `products`, `shop/products`,
|
|
174
|
+
* `posts/7/comments` — so rather than assuming the first segment names it, each
|
|
175
|
+
* of the panel's resources is matched by its own route pattern. The longest
|
|
176
|
+
* match wins, so `posts/7/comments/create` resolves to the nested comments
|
|
177
|
+
* resource rather than to posts.
|
|
178
|
+
*/
|
|
179
|
+
private async _resolveRoute(
|
|
180
|
+
panel: PanelInstance,
|
|
181
|
+
parts: string[],
|
|
182
|
+
ctx?: HttpContext,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
const candidates = panel
|
|
185
|
+
.resources()
|
|
186
|
+
.filter((r) => r.isEditable())
|
|
187
|
+
.sort((a, b) => b.routePath().split("/").length - a.routePath().split("/").length);
|
|
188
|
+
|
|
189
|
+
for (const R of candidates) {
|
|
190
|
+
const pattern = R.routePath().split("/");
|
|
191
|
+
if (parts.length < pattern.length) continue;
|
|
192
|
+
|
|
193
|
+
let parentId = "";
|
|
194
|
+
let matched = true;
|
|
195
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
196
|
+
const segment = pattern[i]!;
|
|
197
|
+
if (segment.startsWith(":")) {
|
|
198
|
+
parentId = parts[i]!;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (segment !== parts[i]) {
|
|
202
|
+
matched = false;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!matched) continue;
|
|
207
|
+
|
|
208
|
+
const rest = parts.slice(pattern.length);
|
|
209
|
+
// A singular resource has no create page and no id — its one row is the route.
|
|
210
|
+
if (R.singular && rest.length === 0) {
|
|
211
|
+
this.slug = R.getSlug();
|
|
212
|
+
this.parentId = parentId;
|
|
213
|
+
this.mode = "edit";
|
|
214
|
+
const record = await R.singularRecord();
|
|
215
|
+
this.recordId = String(record?.[R.primaryKey] ?? "");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (rest.length === 1 && rest[0] === "create") {
|
|
219
|
+
this.slug = R.getSlug();
|
|
220
|
+
this.parentId = parentId;
|
|
221
|
+
this.mode = "create";
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (rest.length === 2 && rest[1] === "edit") {
|
|
225
|
+
this.slug = R.getSlug();
|
|
226
|
+
this.parentId = parentId;
|
|
227
|
+
this.mode = "edit";
|
|
228
|
+
const pk = R.primaryKey;
|
|
229
|
+
const raw = ctx?.params?.[pk] ?? ctx?.params?.["id"] ?? rest[0];
|
|
230
|
+
if (raw != null) {
|
|
231
|
+
this.recordId = String(
|
|
232
|
+
raw && typeof raw === "object" ? (raw as Record<string, unknown>)[pk] : raw,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
override async onMount(ctx?: HttpContext): Promise<void> {
|
|
241
|
+
// Resolve which resource + mode this page serves from the route on the
|
|
242
|
+
// initial GET; on round-trips these come from the snapshot, and tests seed
|
|
243
|
+
// them via mount props.
|
|
244
|
+
const path = ctx && typeof ctx.path === "function" ? ctx.path() : "";
|
|
245
|
+
if (path) {
|
|
246
|
+
const panel = Panel.forPath(path);
|
|
247
|
+
this.panelId = panel.id;
|
|
248
|
+
const base = panel.base();
|
|
249
|
+
const rel = path.startsWith(base) ? path.slice(base.length) : path;
|
|
250
|
+
await this._resolveRoute(panel, rel.split("/").filter(Boolean), ctx);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const cfg = this._cfg;
|
|
254
|
+
if (!cfg) return;
|
|
255
|
+
this.form = new cfg.FormClass();
|
|
256
|
+
// Create: seed any field whose key matches a query param (e.g. a relation
|
|
257
|
+
// manager's "New" link passing the parent foreign key — `?user_id=5`).
|
|
258
|
+
if (this.mode === "create" && ctx) {
|
|
259
|
+
const q = ctx as unknown as { query?: (key: string) => string | undefined };
|
|
260
|
+
if (typeof q.query === "function") {
|
|
261
|
+
for (const f of cfg.fields) {
|
|
262
|
+
const qv = q.query(f._key);
|
|
263
|
+
if (qv != null && qv !== "") (this.form as Record<string, unknown>)[f._key] = qv;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (this.mode === "edit" && this.recordId) {
|
|
268
|
+
const R = this._meta!.resource;
|
|
269
|
+
const record = await R.find(this.recordId);
|
|
270
|
+
if (record) {
|
|
271
|
+
// Remember the version this form is editing from, for the concurrency
|
|
272
|
+
// check on save.
|
|
273
|
+
if (R.optimisticLock) {
|
|
274
|
+
this.loadedVersion = String((record as Record<string, unknown>)[R.optimisticLock] ?? "");
|
|
275
|
+
}
|
|
276
|
+
const hydrated = R.mutateFormDataBeforeFill(record as Record<string, unknown>);
|
|
277
|
+
// Split each translatable field into "the locale on screen" and "the rest".
|
|
278
|
+
if (R.translatable.length > 0) {
|
|
279
|
+
const locale = this._locale();
|
|
280
|
+
for (const key of R.translatable) {
|
|
281
|
+
const raw = hydrated[key];
|
|
282
|
+
const map =
|
|
283
|
+
raw != null && typeof raw === "object" && !Array.isArray(raw)
|
|
284
|
+
? { ...(raw as Record<string, unknown>) }
|
|
285
|
+
: // A column that was never translated keeps its value under the
|
|
286
|
+
// default locale rather than being lost on the first save.
|
|
287
|
+
{ [R.locales[0] ?? "en"]: raw };
|
|
288
|
+
this.translations[key] = map;
|
|
289
|
+
hydrated[key] = map[locale] ?? "";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Per-field hydration (e.g. key/value object → editable lines, csv → tags).
|
|
293
|
+
for (const f of this._cfg!.fields) {
|
|
294
|
+
if (f._key in hydrated) hydrated[f._key] = f.hydrate(hydrated[f._key]);
|
|
295
|
+
}
|
|
296
|
+
this.form.fill(hydrated);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Set a single field's value from the server (radio / native-control fallbacks). */
|
|
302
|
+
@expose setField(key: unknown, value: unknown): void {
|
|
303
|
+
(this.form as Record<string, unknown>)[String(key)] = value;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Toggle a value in an array-valued field (checkbox list / multiple select). */
|
|
307
|
+
@expose toggleArrayValue(key: unknown, value: unknown): void {
|
|
308
|
+
const k = String(key);
|
|
309
|
+
const form = this.form as Record<string, unknown>;
|
|
310
|
+
const current = Array.isArray(form[k]) ? (form[k] as unknown[]) : [];
|
|
311
|
+
form[k] = current.includes(value) ? current.filter((x) => x !== value) : [...current, value];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Draft text for each tags input, keyed by field. */
|
|
315
|
+
@expose tagDraft: Record<string, string> = {};
|
|
316
|
+
|
|
317
|
+
/** Commit the current tag draft into a tags field's array. */
|
|
318
|
+
@expose addTag(key: unknown): void {
|
|
319
|
+
const k = String(key);
|
|
320
|
+
const raw = (this.tagDraft[k] ?? "").trim();
|
|
321
|
+
if (!raw) return;
|
|
322
|
+
const form = this.form as Record<string, unknown>;
|
|
323
|
+
const current = Array.isArray(form[k]) ? (form[k] as unknown[]) : [];
|
|
324
|
+
if (!current.includes(raw)) form[k] = [...current, raw];
|
|
325
|
+
this.tagDraft = { ...this.tagDraft, [k]: "" };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Remove a tag by index. */
|
|
329
|
+
@expose removeTag(key: unknown, index: unknown): void {
|
|
330
|
+
const k = String(key);
|
|
331
|
+
const form = this.form as Record<string, unknown>;
|
|
332
|
+
const current = Array.isArray(form[k]) ? (form[k] as unknown[]) : [];
|
|
333
|
+
form[k] = current.filter((_, i) => i !== Number(index));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Clear a chosen file before it's stored. */
|
|
337
|
+
/** The field a media picker is open for, or empty when it is closed. */
|
|
338
|
+
@expose pickingFor = "";
|
|
339
|
+
@expose pickerSearch = "";
|
|
340
|
+
/**
|
|
341
|
+
* A file chosen from inside the picker.
|
|
342
|
+
*
|
|
343
|
+
* Held on the page rather than on the form, because the form's exposed
|
|
344
|
+
* properties are generated from the resource's declared fields — an extra key
|
|
345
|
+
* on it is not part of the snapshot, so the upload's reference would be
|
|
346
|
+
* dropped on the way back and the file would silently never arrive.
|
|
347
|
+
*/
|
|
348
|
+
@expose mediaUpload: unknown = null;
|
|
349
|
+
/** The library page, loaded per render while the picker is open. */
|
|
350
|
+
private _mediaItems: MediaItem[] = [];
|
|
351
|
+
private _mediaUrls: Record<string, string> = {};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Whether a chosen file should preview as an image.
|
|
355
|
+
*
|
|
356
|
+
* The catalogue's MIME type is the reliable answer; the extension is the
|
|
357
|
+
* fallback for a value that predates the library, which should still show a
|
|
358
|
+
* thumbnail rather than a generic document icon.
|
|
359
|
+
*/
|
|
360
|
+
/** The library, as a modal grid to choose from. */
|
|
361
|
+
private _mediaPicker(): HtmlNode {
|
|
362
|
+
return (
|
|
363
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
364
|
+
<div class="flex max-h-[80vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-card shadow-xl">
|
|
365
|
+
<div class="flex items-center gap-3 border-b border-border p-4">
|
|
366
|
+
<h2 class="text-sm font-semibold">Media library</h2>
|
|
367
|
+
<input
|
|
368
|
+
{...{ "flow:model.live": "pickerSearch" }}
|
|
369
|
+
placeholder="Search…"
|
|
370
|
+
class="ml-auto h-8 w-48 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
|
|
371
|
+
/>
|
|
372
|
+
<label class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-input px-3 text-sm font-medium transition hover:bg-accent">
|
|
373
|
+
<Icon name="upload" class="h-4 w-4" />
|
|
374
|
+
Upload
|
|
375
|
+
<input type="file" class="hidden" flow:model="mediaUpload" />
|
|
376
|
+
</label>
|
|
377
|
+
<button
|
|
378
|
+
type="button"
|
|
379
|
+
onClick={this.closeMediaPicker}
|
|
380
|
+
aria-label="Close"
|
|
381
|
+
class="rounded-md p-1 text-muted-foreground transition hover:bg-accent hover:text-foreground"
|
|
382
|
+
>
|
|
383
|
+
<Icon name="x" class="h-4 w-4" />
|
|
384
|
+
</button>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
<div class="flex-1 overflow-y-auto p-4">
|
|
388
|
+
{this._mediaItems.length === 0 ? (
|
|
389
|
+
<p class="py-10 text-center text-sm text-muted-foreground">
|
|
390
|
+
{this.pickerSearch ? "Nothing matches that." : "The library is empty."}
|
|
391
|
+
</p>
|
|
392
|
+
) : (
|
|
393
|
+
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6">
|
|
394
|
+
{this._mediaItems.map((item) => (
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
onClick={this.chooseMedia}
|
|
398
|
+
data-args={JSON.stringify([item.path])}
|
|
399
|
+
title={item.name}
|
|
400
|
+
class="overflow-hidden rounded-lg border border-border transition hover:border-primary hover:shadow-sm"
|
|
401
|
+
>
|
|
402
|
+
<div class="flex aspect-square items-center justify-center bg-muted/40">
|
|
403
|
+
{isImage(item) && this._mediaUrls[item.path] ? (
|
|
404
|
+
<img
|
|
405
|
+
src={this._mediaUrls[item.path]}
|
|
406
|
+
alt={item.alt ?? item.name}
|
|
407
|
+
loading="lazy"
|
|
408
|
+
class="h-full w-full object-cover"
|
|
409
|
+
/>
|
|
410
|
+
) : (
|
|
411
|
+
<Icon name="document" class="h-6 w-6 text-muted-foreground" />
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
<p class="truncate p-1.5 text-[11px]">{item.name}</p>
|
|
415
|
+
</button>
|
|
416
|
+
))}
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private _looksLikeImage(item: MediaItem | undefined, path: string): boolean {
|
|
426
|
+
if (item) return isImage(item);
|
|
427
|
+
return /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(path);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
@expose openMediaPicker(key: unknown): void {
|
|
431
|
+
this.pickingFor = String(key);
|
|
432
|
+
this.pickerSearch = "";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
@expose closeMediaPicker(): void {
|
|
436
|
+
this.pickingFor = "";
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Choose a library file for the field the picker was opened from. */
|
|
440
|
+
@expose chooseMedia(path: unknown): void {
|
|
441
|
+
if (!this.pickingFor) return;
|
|
442
|
+
(this.form as Record<string, unknown>)[this.pickingFor] = String(path);
|
|
443
|
+
this.pickingFor = "";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Store the file once its bytes have actually arrived.
|
|
448
|
+
*
|
|
449
|
+
* Driven by the property update rather than the input's `change` event, and
|
|
450
|
+
* the difference is the whole bug: a bound file input starts an HTTP upload on
|
|
451
|
+
* change and only sets the signed reference when that finishes. An action
|
|
452
|
+
* fired from `change` therefore ran while the property was still empty, found
|
|
453
|
+
* nothing to store, and returned silently — the file picker appeared to do
|
|
454
|
+
* nothing at all.
|
|
455
|
+
*/
|
|
456
|
+
override async onUpdated(prop: string, _value: unknown): Promise<void> {
|
|
457
|
+
if (prop === "mediaUpload") await this.uploadToLibrary();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Upload straight into the library from the picker, and select the result. */
|
|
461
|
+
@expose async uploadToLibrary(): Promise<void> {
|
|
462
|
+
const provider = this._panel.mediaProvider();
|
|
463
|
+
if (!provider || !isUpload(this.mediaUpload)) return;
|
|
464
|
+
|
|
465
|
+
const [ok, result] = await storeMedia(this.mediaUpload, {
|
|
466
|
+
provider,
|
|
467
|
+
...(this._panel.mediaDisk() ? { disk: this._panel.mediaDisk()! } : {}),
|
|
468
|
+
});
|
|
469
|
+
this.mediaUpload = null;
|
|
470
|
+
if (!ok) {
|
|
471
|
+
this.flash(result as string, "warning");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// Selecting it immediately is the point of uploading from inside a picker.
|
|
475
|
+
if (this.pickingFor) {
|
|
476
|
+
(this.form as Record<string, unknown>)[this.pickingFor] = (result as MediaItem).path;
|
|
477
|
+
this.pickingFor = "";
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
@expose removeFile(key: unknown): void {
|
|
482
|
+
(this.form as Record<string, unknown>)[String(key)] = null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Repeater / Builder (nested object-arrays) ───────────────────────────────
|
|
486
|
+
//
|
|
487
|
+
// Each row carries a stable `__id`; sub-inputs bind to a *flat* draft object
|
|
488
|
+
// (`repeaterDraft["<field>__<rowId>__<sub>"]`), the same pattern the list page
|
|
489
|
+
// uses for inline cells. The draft round-trips via Flow's model binding; the
|
|
490
|
+
// canonical array in `form[<field>]` is rebuilt from the drafts on save.
|
|
491
|
+
|
|
492
|
+
/** Flat per-sub-input draft for repeater/builder rows. */
|
|
493
|
+
@expose repeaterDraft: Record<string, unknown> = {};
|
|
494
|
+
|
|
495
|
+
/** Composite draft key for a repeater sub-input. */
|
|
496
|
+
private _repKey(field: string, rowId: unknown, sub: string): string {
|
|
497
|
+
return `${field}__${rowId}__${sub}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Look up a (possibly nested) form field by key. */
|
|
501
|
+
private _findField(key: string): Field | undefined {
|
|
502
|
+
const R = this._meta?.resource;
|
|
503
|
+
return R ? flattenFields(R.form()).find((f) => f._key === key) : undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private _rows(field: string): Array<Record<string, unknown>> {
|
|
507
|
+
const v = (this.form as Record<string, unknown>)[field];
|
|
508
|
+
return Array.isArray(v) ? (v as Array<Record<string, unknown>>) : [];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Next stable row id — `max(existing) + 1` (survives WS round-trips). */
|
|
512
|
+
private _nextRowId(rows: Array<Record<string, unknown>>): number {
|
|
513
|
+
return rows.reduce((m, r) => Math.max(m, Number(r.__id) || 0), 0) + 1;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Append an empty repeater row (sub-fields seeded to their defaults). */
|
|
517
|
+
@expose addRepeaterItem(key: unknown): void {
|
|
518
|
+
const k = String(key);
|
|
519
|
+
const field = this._findField(k);
|
|
520
|
+
if (!field) return;
|
|
521
|
+
const rows = [...this._rows(k)];
|
|
522
|
+
if (field._maxItems != null && rows.length >= field._maxItems) return;
|
|
523
|
+
const id = this._nextRowId(rows);
|
|
524
|
+
const row: Record<string, unknown> = { __id: id };
|
|
525
|
+
for (const sf of field._subfields) {
|
|
526
|
+
row[sf._key] = sf.defaultValue();
|
|
527
|
+
this.repeaterDraft[this._repKey(k, id, sf._key)] = row[sf._key];
|
|
528
|
+
}
|
|
529
|
+
(this.form as Record<string, unknown>)[k] = [...rows, row];
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Append a builder block of the given type (its fields seeded to defaults). */
|
|
533
|
+
@expose addBuilderBlock(key: unknown, blockName: unknown): void {
|
|
534
|
+
const k = String(key);
|
|
535
|
+
const name = String(blockName);
|
|
536
|
+
const field = this._findField(k);
|
|
537
|
+
const block = field?._blocks.find((b) => b.name === name);
|
|
538
|
+
if (!field || !block) return;
|
|
539
|
+
const rows = [...this._rows(k)];
|
|
540
|
+
if (field._maxItems != null && rows.length >= field._maxItems) return;
|
|
541
|
+
const id = this._nextRowId(rows);
|
|
542
|
+
const row: Record<string, unknown> = { __id: id, __type: name };
|
|
543
|
+
for (const sf of block._fields) {
|
|
544
|
+
row[sf._key] = sf.defaultValue();
|
|
545
|
+
this.repeaterDraft[this._repKey(k, id, sf._key)] = row[sf._key];
|
|
546
|
+
}
|
|
547
|
+
(this.form as Record<string, unknown>)[k] = [...rows, row];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Remove a repeater/builder row by id (and drop its drafts). */
|
|
551
|
+
@expose removeRepeaterItem(key: unknown, rowId: unknown): void {
|
|
552
|
+
const k = String(key);
|
|
553
|
+
const id = Number(rowId);
|
|
554
|
+
(this.form as Record<string, unknown>)[k] = this._rows(k).filter((r) => Number(r.__id) !== id);
|
|
555
|
+
const prefix = `${k}__${id}__`;
|
|
556
|
+
const next: Record<string, unknown> = {};
|
|
557
|
+
for (const [dk, dv] of Object.entries(this.repeaterDraft)) {
|
|
558
|
+
if (!dk.startsWith(prefix)) next[dk] = dv;
|
|
559
|
+
}
|
|
560
|
+
this.repeaterDraft = next;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Move a repeater/builder row up (-1) or down (+1). */
|
|
564
|
+
@expose moveRepeaterItem(key: unknown, rowId: unknown, dir: unknown): void {
|
|
565
|
+
const k = String(key);
|
|
566
|
+
const id = Number(rowId);
|
|
567
|
+
const d = Number(dir);
|
|
568
|
+
const rows = [...this._rows(k)];
|
|
569
|
+
const i = rows.findIndex((r) => Number(r.__id) === id);
|
|
570
|
+
const j = i + d;
|
|
571
|
+
if (i < 0 || j < 0 || j >= rows.length) return;
|
|
572
|
+
[rows[i], rows[j]] = [rows[j]!, rows[i]!];
|
|
573
|
+
(this.form as Record<string, unknown>)[k] = rows;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Sub-fields applicable to a repeater/builder row (block-specific for builder). */
|
|
577
|
+
private _rowFields(field: Field, row: Record<string, unknown>): Field[] {
|
|
578
|
+
if (field._type === "builder") {
|
|
579
|
+
return field._blocks.find((b) => b.name === row.__type)?._fields ?? [];
|
|
580
|
+
}
|
|
581
|
+
return field._subfields;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Rebuild each repeater/builder array in `form` from its flat drafts (pre-save). */
|
|
585
|
+
private _collectRepeaters(): void {
|
|
586
|
+
const cfg = this._cfg;
|
|
587
|
+
if (!cfg) return;
|
|
588
|
+
const form = this.form as Record<string, unknown>;
|
|
589
|
+
for (const f of cfg.fields) {
|
|
590
|
+
if (f._type !== "repeater" && f._type !== "builder") continue;
|
|
591
|
+
const rebuilt = this._rows(f._key).map((row) => {
|
|
592
|
+
const out: Record<string, unknown> = { __id: row.__id };
|
|
593
|
+
if (f._type === "builder") out.__type = row.__type;
|
|
594
|
+
for (const sf of this._rowFields(f, row)) {
|
|
595
|
+
const dk = this._repKey(f._key, row.__id, sf._key);
|
|
596
|
+
out[sf._key] = dk in this.repeaterDraft ? this.repeaterDraft[dk] : row[sf._key];
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
});
|
|
600
|
+
form[f._key] = rebuilt;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ── Reactive fields (afterStateUpdated) ─────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
/** Run a live field's `afterStateUpdated` hook and merge its patch into the form. */
|
|
607
|
+
@expose fieldChanged(key: unknown): void {
|
|
608
|
+
const R = this._meta?.resource;
|
|
609
|
+
if (!R) return;
|
|
610
|
+
const field = flattenFields(R.form()).find((f) => f._key === String(key));
|
|
611
|
+
if (!field?._afterUpdate) return;
|
|
612
|
+
const form = this.form as Record<string, unknown>;
|
|
613
|
+
const patch = field._afterUpdate(form[String(key)], this.form.data());
|
|
614
|
+
if (patch && typeof patch === "object") {
|
|
615
|
+
for (const [k, v] of Object.entries(patch)) form[k] = v;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ── Wizard ──────────────────────────────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
/** Current wizard step index. */
|
|
622
|
+
@expose wizardStepIndex = 0;
|
|
623
|
+
|
|
624
|
+
/** Resolve the form's wizard (if the schema is a single wizard). */
|
|
625
|
+
private _wizard(): Wizard | null {
|
|
626
|
+
const R = this._meta?.resource;
|
|
627
|
+
if (!R) return null;
|
|
628
|
+
const block = toFormLayout(R.form()).find((b) => b.kind === "wizard");
|
|
629
|
+
return block && block.kind === "wizard" ? block.wizard : null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Validate a set of fields against the current form data → field → messages. */
|
|
633
|
+
private _validateFields(
|
|
634
|
+
fields: Field[],
|
|
635
|
+
data: Record<string, unknown>,
|
|
636
|
+
): Record<string, string[]> {
|
|
637
|
+
const v = new RuleBuilder();
|
|
638
|
+
const schema: Schema = {};
|
|
639
|
+
for (const f of fields)
|
|
640
|
+
schema[f._key] = (f.buildRule(v) as unknown as { _def: Schema[string] })._def;
|
|
641
|
+
const result = runValidation(schema, data);
|
|
642
|
+
if (result.success) return {};
|
|
643
|
+
const out: Record<string, string[]> = {};
|
|
644
|
+
for (const [k, msg] of Object.entries(result.errors as Record<string, string>)) out[k] = [msg];
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Validate the current step's fields, then advance. */
|
|
649
|
+
@expose nextStep(): void {
|
|
650
|
+
const wiz = this._wizard();
|
|
651
|
+
const step = wiz?._steps[this.wizardStepIndex];
|
|
652
|
+
if (!wiz || !step) return;
|
|
653
|
+
const data = this.form.data();
|
|
654
|
+
const fields = step
|
|
655
|
+
.getFields()
|
|
656
|
+
.filter((f) => f.visibleIn(this.mode) && (f._type === "hidden" || f.visibleForData(data)));
|
|
657
|
+
const errors = this._validateFields(fields, data);
|
|
658
|
+
if (Object.keys(errors).length > 0) {
|
|
659
|
+
(this as unknown as { _errors: Record<string, string[]> })._errors = errors;
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
(this as unknown as { _errors: Record<string, string[]> })._errors = {};
|
|
663
|
+
this.wizardStepIndex = Math.min(this.wizardStepIndex + 1, wiz._steps.length - 1);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
@expose prevStep(): void {
|
|
667
|
+
this.wizardStepIndex = Math.max(0, this.wizardStepIndex - 1);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Form submit inside a wizard: advance a step, or save on the last one. */
|
|
671
|
+
@expose async wizardSubmit(): Promise<void> {
|
|
672
|
+
const wiz = this._wizard();
|
|
673
|
+
if (!wiz) return;
|
|
674
|
+
if (this.wizardStepIndex >= wiz._steps.length - 1) await this.save();
|
|
675
|
+
else this.nextStep();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
@expose async save(): Promise<void> {
|
|
679
|
+
const meta = this._meta;
|
|
680
|
+
if (!meta) return;
|
|
681
|
+
const R = meta.resource;
|
|
682
|
+
// Fold nested repeater/builder drafts back into their canonical arrays first.
|
|
683
|
+
this._collectRepeaters();
|
|
684
|
+
// Throws ValidationError on failure → framework re-renders with field errors.
|
|
685
|
+
await this.validate(this.form);
|
|
686
|
+
|
|
687
|
+
// Apply save-time field mutators (e.g. hashing a password) to the form data.
|
|
688
|
+
let data = this.form.data();
|
|
689
|
+
for (const f of this._cfg!.fields) {
|
|
690
|
+
if (f._key in data) data[f._key] = await f.dehydrate(data[f._key]);
|
|
691
|
+
}
|
|
692
|
+
// Somebody else may have saved this record since the form was opened.
|
|
693
|
+
// Refusing is the only safe answer: silently overwriting loses their work,
|
|
694
|
+
// and merging blind is worse.
|
|
695
|
+
if (this.mode === "edit" && R.optimisticLock) {
|
|
696
|
+
const current = (await R.find(this.recordId)) as Record<string, unknown> | null;
|
|
697
|
+
const version = String(current?.[R.optimisticLock] ?? "");
|
|
698
|
+
if (this.loadedVersion && version && version !== this.loadedVersion) {
|
|
699
|
+
this.addError(
|
|
700
|
+
R.optimisticLock,
|
|
701
|
+
"Somebody else changed this record while you were editing. Reload to see their version.",
|
|
702
|
+
);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Put the edited locale back alongside the ones the form never showed.
|
|
708
|
+
if (R.translatable.length > 0) {
|
|
709
|
+
const locale = this._locale();
|
|
710
|
+
for (const key of R.translatable) {
|
|
711
|
+
if (!(key in data)) continue;
|
|
712
|
+
data[key] = { ...(this.translations[key] ?? {}), [locale]: data[key] };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Resource-level lifecycle hook, applied to the validated data.
|
|
717
|
+
data = R.mutateBeforeSave(data, this.mode);
|
|
718
|
+
const base = this._panel.base();
|
|
719
|
+
const parentId = this.parentId || undefined;
|
|
720
|
+
|
|
721
|
+
if (this.mode === "create") {
|
|
722
|
+
// A nested resource's records always belong to the parent in the URL — set
|
|
723
|
+
// here rather than trusting a form field, which the client could rewrite.
|
|
724
|
+
if (R.parent && parentId) data[R.parent.foreignKey] = parentId;
|
|
725
|
+
const created = await R.create(data);
|
|
726
|
+
await R.afterSave((created ?? data) as Record<string, unknown>, "create");
|
|
727
|
+
const id = created ? (created as Record<string, unknown>)[R.primaryKey] : "";
|
|
728
|
+
this.redirect(R.recordUrl(base, id ?? "", parentId)).withSuccess(`${R.getLabel()} created.`);
|
|
729
|
+
} else {
|
|
730
|
+
await R.update(this.recordId, data);
|
|
731
|
+
await R.afterSave(data, "edit");
|
|
732
|
+
// A singular resource has no separate view page — stay on the form.
|
|
733
|
+
const target = R.singular
|
|
734
|
+
? R.indexUrl(base, parentId)
|
|
735
|
+
: R.recordUrl(base, this.recordId, parentId);
|
|
736
|
+
this.redirect(target).withSuccess(`${R.getLabel()} updated.`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ── Field rendering ────────────────────────────────────────────────────────
|
|
741
|
+
|
|
742
|
+
private _error(key: string): string | null {
|
|
743
|
+
// Read the raw error bag (field → messages[]), not the public `errors`
|
|
744
|
+
// accessor (which returns directive descriptors for `error={}`).
|
|
745
|
+
const bag = (this as unknown as { _errors?: Record<string, string[]> })._errors ?? {};
|
|
746
|
+
const e = bag[key];
|
|
747
|
+
return Array.isArray(e) ? (e[0] ?? null) : null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Map a field type to its native `<input type>`. */
|
|
751
|
+
private _inputType(f: Field): string {
|
|
752
|
+
switch (f._type) {
|
|
753
|
+
case "datetime":
|
|
754
|
+
return "datetime-local";
|
|
755
|
+
case "date":
|
|
756
|
+
case "time":
|
|
757
|
+
case "color":
|
|
758
|
+
case "number":
|
|
759
|
+
case "email":
|
|
760
|
+
case "password":
|
|
761
|
+
case "url":
|
|
762
|
+
case "tel":
|
|
763
|
+
return f._type === "number" ? "number" : f._type;
|
|
764
|
+
default:
|
|
765
|
+
return "text";
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private _control(f: Field, disabled: boolean): HtmlNode {
|
|
770
|
+
// Bind by reading `this.form[key]` directly in the value/checked prop: the
|
|
771
|
+
// reactive proxy captures the access path ("form.<key>") and Flow emits the
|
|
772
|
+
// `flow:model` two-way binding. Array/choice fields (radio, checkbox list,
|
|
773
|
+
// multiple select) round-trip through dedicated @expose server methods.
|
|
774
|
+
const form = this.form as Record<string, unknown>;
|
|
775
|
+
|
|
776
|
+
// A custom control takes the field outright. Checked first so it can replace
|
|
777
|
+
// any built-in type, not merely sit beside them.
|
|
778
|
+
if (f._render) return f._render(form[f._key], form) as HtmlNode;
|
|
779
|
+
|
|
780
|
+
const invalid = this._error(f._key) ? "border-destructive focus:ring-destructive/40" : "";
|
|
781
|
+
const opts = this._resolvedOptions[f._key] ?? f._options ?? [];
|
|
782
|
+
// Live fields fire a server callback (afterStateUpdated) after the model syncs.
|
|
783
|
+
const live: Record<string, unknown> =
|
|
784
|
+
f._live || f._afterUpdate
|
|
785
|
+
? { onChange: this.fieldChanged, "data-args": JSON.stringify([f._key]) }
|
|
786
|
+
: {};
|
|
787
|
+
|
|
788
|
+
if (f._type === "textarea") {
|
|
789
|
+
return (
|
|
790
|
+
<textarea
|
|
791
|
+
value={form[f._key]}
|
|
792
|
+
rows={f._rows}
|
|
793
|
+
placeholder={f._placeholder}
|
|
794
|
+
disabled={disabled}
|
|
795
|
+
class={`${INPUT_CLASS} ${invalid}`}
|
|
796
|
+
{...live}
|
|
797
|
+
/>
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (f._type === "toggle") {
|
|
802
|
+
return (
|
|
803
|
+
<label class="mt-1.5 inline-flex cursor-pointer items-center">
|
|
804
|
+
<input type="checkbox" checked={form[f._key]} disabled={disabled} class="peer sr-only" />
|
|
805
|
+
<span class="relative h-6 w-11 rounded-full bg-input transition peer-checked:bg-primary after:absolute after:left-0.5 after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-background after:shadow after:transition after:content-[''] peer-checked:after:translate-x-5" />
|
|
806
|
+
<span class="ml-2 text-sm text-muted-foreground">{f._placeholder ?? f.getLabel()}</span>
|
|
807
|
+
</label>
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (f._type === "radio") {
|
|
812
|
+
const current = String(form[f._key] ?? "");
|
|
813
|
+
return (
|
|
814
|
+
<div class="mt-1.5 space-y-1.5">
|
|
815
|
+
{opts.map((o) => (
|
|
816
|
+
<label class="flex items-center gap-2 text-sm">
|
|
817
|
+
<input
|
|
818
|
+
type="radio"
|
|
819
|
+
name={f._key}
|
|
820
|
+
checked={String(o.value) === current}
|
|
821
|
+
disabled={disabled}
|
|
822
|
+
onClick={this.setField}
|
|
823
|
+
data-args={JSON.stringify([f._key, o.value])}
|
|
824
|
+
class="h-4 w-4 border-input text-primary focus:ring-2 focus:ring-ring"
|
|
825
|
+
/>
|
|
826
|
+
<span>{o.label}</span>
|
|
827
|
+
</label>
|
|
828
|
+
))}
|
|
829
|
+
</div>
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (f._type === "checkboxList" || (f._type === "select" && f._multiple)) {
|
|
834
|
+
const arr = Array.isArray(form[f._key]) ? (form[f._key] as unknown[]).map(String) : [];
|
|
835
|
+
return (
|
|
836
|
+
<div class="mt-1.5 grid gap-1.5 sm:grid-cols-2">
|
|
837
|
+
{opts.map((o) => (
|
|
838
|
+
<label class="flex items-center gap-2 text-sm">
|
|
839
|
+
<input
|
|
840
|
+
type="checkbox"
|
|
841
|
+
checked={arr.includes(String(o.value))}
|
|
842
|
+
disabled={disabled}
|
|
843
|
+
onClick={this.toggleArrayValue}
|
|
844
|
+
data-args={JSON.stringify([f._key, o.value])}
|
|
845
|
+
class="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring"
|
|
846
|
+
/>
|
|
847
|
+
<span>{o.label}</span>
|
|
848
|
+
</label>
|
|
849
|
+
))}
|
|
850
|
+
</div>
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Searchable select — a filterable <datalist> combobox (also allows free entry
|
|
855
|
+
// when `.createOption()` is set, since datalist input is unrestricted).
|
|
856
|
+
if (f._type === "select" && f._searchable) {
|
|
857
|
+
const id = `dl-${f._key}`;
|
|
858
|
+
return (
|
|
859
|
+
<>
|
|
860
|
+
<input
|
|
861
|
+
list={id}
|
|
862
|
+
value={form[f._key]}
|
|
863
|
+
placeholder={f._placeholder ?? "Search…"}
|
|
864
|
+
disabled={disabled}
|
|
865
|
+
class={`${INPUT_CLASS} ${invalid}`}
|
|
866
|
+
{...live}
|
|
867
|
+
/>
|
|
868
|
+
<datalist id={id}>
|
|
869
|
+
{opts.map((o) => (
|
|
870
|
+
<option value={o.value}>{o.label}</option>
|
|
871
|
+
))}
|
|
872
|
+
</datalist>
|
|
873
|
+
</>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (f._type === "select") {
|
|
878
|
+
const current = String(form[f._key] ?? "");
|
|
879
|
+
return (
|
|
880
|
+
<select
|
|
881
|
+
value={form[f._key]}
|
|
882
|
+
disabled={disabled}
|
|
883
|
+
class={`${INPUT_CLASS} ${invalid}`}
|
|
884
|
+
{...live}
|
|
885
|
+
>
|
|
886
|
+
<option value="">{f._placeholder ?? "Select…"}</option>
|
|
887
|
+
{opts.map((o) => (
|
|
888
|
+
<option value={o.value} selected={String(o.value) === current}>
|
|
889
|
+
{o.label}
|
|
890
|
+
</option>
|
|
891
|
+
))}
|
|
892
|
+
</select>
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (f._type === "checkbox") {
|
|
897
|
+
return (
|
|
898
|
+
<label class="mt-1.5 inline-flex items-center gap-2">
|
|
899
|
+
<input
|
|
900
|
+
type="checkbox"
|
|
901
|
+
checked={form[f._key]}
|
|
902
|
+
disabled={disabled}
|
|
903
|
+
class="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring"
|
|
904
|
+
/>
|
|
905
|
+
<span class="text-sm text-muted-foreground">{f._placeholder ?? f.getLabel()}</span>
|
|
906
|
+
</label>
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (f._type === "tags") {
|
|
911
|
+
const arr = Array.isArray(form[f._key]) ? (form[f._key] as unknown[]) : [];
|
|
912
|
+
return (
|
|
913
|
+
<div class={`${INPUT_CLASS} ${invalid} flex flex-wrap items-center gap-1.5`}>
|
|
914
|
+
{arr.map((t, i) => (
|
|
915
|
+
<span class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
|
|
916
|
+
{String(t)}
|
|
917
|
+
<button
|
|
918
|
+
type="button"
|
|
919
|
+
onClick={this.removeTag}
|
|
920
|
+
data-args={JSON.stringify([f._key, i])}
|
|
921
|
+
class="text-muted-foreground transition hover:text-destructive"
|
|
922
|
+
>
|
|
923
|
+
×
|
|
924
|
+
</button>
|
|
925
|
+
</span>
|
|
926
|
+
))}
|
|
927
|
+
<input
|
|
928
|
+
value={this.tagDraft[f._key] ?? ""}
|
|
929
|
+
placeholder={f._placeholder ?? "Add…"}
|
|
930
|
+
disabled={disabled}
|
|
931
|
+
class="min-w-[6rem] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
932
|
+
/>
|
|
933
|
+
<button
|
|
934
|
+
type="button"
|
|
935
|
+
onClick={this.addTag}
|
|
936
|
+
data-args={JSON.stringify([f._key])}
|
|
937
|
+
class="rounded-md border border-input px-2 py-0.5 text-xs font-medium transition hover:bg-accent"
|
|
938
|
+
>
|
|
939
|
+
Add
|
|
940
|
+
</button>
|
|
941
|
+
</div>
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (f._type === "keyValue") {
|
|
946
|
+
return (
|
|
947
|
+
<textarea
|
|
948
|
+
value={form[f._key]}
|
|
949
|
+
rows={f._rows}
|
|
950
|
+
disabled={disabled}
|
|
951
|
+
placeholder={f._placeholder ?? "key: value (one per line)"}
|
|
952
|
+
class={`${INPUT_CLASS} ${invalid} font-mono text-xs`}
|
|
953
|
+
/>
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (f._type === "file") {
|
|
958
|
+
const val = form[f._key];
|
|
959
|
+
const single = !f._multiple && val && typeof val === "object";
|
|
960
|
+
const fileName = single
|
|
961
|
+
? String(
|
|
962
|
+
(val as { name?: string; filename?: string }).name ??
|
|
963
|
+
(val as { filename?: string }).filename ??
|
|
964
|
+
"Selected file",
|
|
965
|
+
)
|
|
966
|
+
: typeof val === "string" && val
|
|
967
|
+
? val
|
|
968
|
+
: "";
|
|
969
|
+
return (
|
|
970
|
+
<div class="mt-1.5 space-y-2">
|
|
971
|
+
<input
|
|
972
|
+
type="file"
|
|
973
|
+
flow:model={`form.${f._key}`}
|
|
974
|
+
accept={f._accept}
|
|
975
|
+
multiple={f._multiple}
|
|
976
|
+
disabled={disabled}
|
|
977
|
+
class="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground hover:file:bg-primary/90"
|
|
978
|
+
/>
|
|
979
|
+
{fileName ? (
|
|
980
|
+
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
|
981
|
+
<Icon name="document" class="h-4 w-4" />
|
|
982
|
+
<span class="truncate">{fileName}</span>
|
|
983
|
+
<button
|
|
984
|
+
type="button"
|
|
985
|
+
onClick={this.removeFile}
|
|
986
|
+
data-args={JSON.stringify([f._key])}
|
|
987
|
+
class="text-destructive hover:underline"
|
|
988
|
+
>
|
|
989
|
+
Remove
|
|
990
|
+
</button>
|
|
991
|
+
</div>
|
|
992
|
+
) : null}
|
|
993
|
+
</div>
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (f._type === "media") {
|
|
998
|
+
const current = typeof form[f._key] === "string" ? String(form[f._key]) : "";
|
|
999
|
+
const chosen = this._mediaItems.find((i) => i.path === current);
|
|
1000
|
+
return (
|
|
1001
|
+
<div class="mt-1.5 space-y-2">
|
|
1002
|
+
{current ? (
|
|
1003
|
+
<div class="flex items-center gap-3 rounded-lg border border-border bg-card p-2">
|
|
1004
|
+
{this._looksLikeImage(chosen, current) && this._mediaUrls[current] ? (
|
|
1005
|
+
<img
|
|
1006
|
+
src={this._mediaUrls[current]}
|
|
1007
|
+
alt={chosen?.alt ?? chosen?.name ?? ""}
|
|
1008
|
+
class="h-12 w-12 rounded object-cover"
|
|
1009
|
+
/>
|
|
1010
|
+
) : (
|
|
1011
|
+
<Icon name="document" class="h-5 w-5 text-muted-foreground" />
|
|
1012
|
+
)}
|
|
1013
|
+
<span class="flex-1 truncate text-xs">{chosen?.name ?? current}</span>
|
|
1014
|
+
<button
|
|
1015
|
+
type="button"
|
|
1016
|
+
onClick={this.removeFile}
|
|
1017
|
+
data-args={JSON.stringify([f._key])}
|
|
1018
|
+
class="text-xs text-destructive hover:underline"
|
|
1019
|
+
>
|
|
1020
|
+
Remove
|
|
1021
|
+
</button>
|
|
1022
|
+
</div>
|
|
1023
|
+
) : null}
|
|
1024
|
+
<button
|
|
1025
|
+
type="button"
|
|
1026
|
+
onClick={this.openMediaPicker}
|
|
1027
|
+
data-args={JSON.stringify([f._key])}
|
|
1028
|
+
disabled={disabled}
|
|
1029
|
+
class="inline-flex h-9 items-center gap-2 rounded-lg border border-input px-3 text-sm font-medium transition hover:bg-accent disabled:opacity-50"
|
|
1030
|
+
>
|
|
1031
|
+
<Icon name="image" class="h-4 w-4" />
|
|
1032
|
+
{current ? "Change" : "Choose from library"}
|
|
1033
|
+
</button>
|
|
1034
|
+
</div>
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (f._type === "slider") {
|
|
1039
|
+
return (
|
|
1040
|
+
<div class="mt-2 flex items-center gap-3">
|
|
1041
|
+
<input
|
|
1042
|
+
type="range"
|
|
1043
|
+
value={form[f._key]}
|
|
1044
|
+
min={f._min ?? 0}
|
|
1045
|
+
max={f._max ?? 100}
|
|
1046
|
+
step={f._step ?? 1}
|
|
1047
|
+
disabled={disabled}
|
|
1048
|
+
class="h-2 flex-1 cursor-pointer appearance-none rounded-full bg-input accent-primary"
|
|
1049
|
+
/>
|
|
1050
|
+
<span class="w-10 text-right text-sm tabular-nums text-muted-foreground">
|
|
1051
|
+
{String(form[f._key] ?? "")}
|
|
1052
|
+
</span>
|
|
1053
|
+
</div>
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (f._type === "toggleButtons") {
|
|
1058
|
+
const multi = f._multiple;
|
|
1059
|
+
const arr = Array.isArray(form[f._key]) ? (form[f._key] as unknown[]).map(String) : [];
|
|
1060
|
+
const cur = String(form[f._key] ?? "");
|
|
1061
|
+
return (
|
|
1062
|
+
<div class="mt-1.5 inline-flex flex-wrap gap-1 rounded-lg border border-input bg-background p-0.5">
|
|
1063
|
+
{opts.map((o) => {
|
|
1064
|
+
const on = multi ? arr.includes(String(o.value)) : cur === String(o.value);
|
|
1065
|
+
return (
|
|
1066
|
+
<button
|
|
1067
|
+
type="button"
|
|
1068
|
+
onClick={multi ? this.toggleArrayValue : this.setField}
|
|
1069
|
+
data-args={JSON.stringify([f._key, o.value])}
|
|
1070
|
+
disabled={disabled}
|
|
1071
|
+
class={`rounded-md px-3 py-1 text-sm font-medium transition ${
|
|
1072
|
+
on
|
|
1073
|
+
? "bg-primary text-primary-foreground shadow-sm"
|
|
1074
|
+
: "text-muted-foreground hover:text-foreground"
|
|
1075
|
+
}`}
|
|
1076
|
+
>
|
|
1077
|
+
{o.label}
|
|
1078
|
+
</button>
|
|
1079
|
+
);
|
|
1080
|
+
})}
|
|
1081
|
+
</div>
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (f._type === "code") {
|
|
1086
|
+
const id = `code-${f._key}`;
|
|
1087
|
+
return (
|
|
1088
|
+
<textarea
|
|
1089
|
+
id={id}
|
|
1090
|
+
value={form[f._key]}
|
|
1091
|
+
rows={f._rows}
|
|
1092
|
+
disabled={disabled}
|
|
1093
|
+
placeholder={f._placeholder}
|
|
1094
|
+
spellcheck="false"
|
|
1095
|
+
onkeydown={`__kTab(event,'${id}')`}
|
|
1096
|
+
class={`${INPUT_CLASS} ${invalid} font-mono text-xs leading-relaxed`}
|
|
1097
|
+
/>
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (f._type === "markdown") {
|
|
1102
|
+
const id = `md-${f._key}`;
|
|
1103
|
+
const btn =
|
|
1104
|
+
"rounded px-2 py-1 text-xs font-medium text-muted-foreground transition hover:bg-accent hover:text-foreground";
|
|
1105
|
+
return (
|
|
1106
|
+
<div class={`mt-1.5 overflow-hidden rounded-lg border border-input ${invalid}`}>
|
|
1107
|
+
<div class="flex flex-wrap items-center gap-0.5 border-b border-border bg-muted/40 p-1">
|
|
1108
|
+
<button type="button" class={`${btn} font-bold`} onclick={`__kMd('${id}','**','**')`}>
|
|
1109
|
+
B
|
|
1110
|
+
</button>
|
|
1111
|
+
<button type="button" class={`${btn} italic`} onclick={`__kMd('${id}','_','_')`}>
|
|
1112
|
+
I
|
|
1113
|
+
</button>
|
|
1114
|
+
<button type="button" class={btn} onclick={`__kMd('${id}','[','](url)')`}>
|
|
1115
|
+
Link
|
|
1116
|
+
</button>
|
|
1117
|
+
<button type="button" class={btn} onclick={`__kMd('${id}','\\n- ','')`}>
|
|
1118
|
+
List
|
|
1119
|
+
</button>
|
|
1120
|
+
<button type="button" class={`${btn} font-mono`} onclick={`__kMd('${id}','\`','\`')`}>
|
|
1121
|
+
Code
|
|
1122
|
+
</button>
|
|
1123
|
+
</div>
|
|
1124
|
+
<textarea
|
|
1125
|
+
id={id}
|
|
1126
|
+
value={form[f._key]}
|
|
1127
|
+
rows={f._rows}
|
|
1128
|
+
disabled={disabled}
|
|
1129
|
+
placeholder={f._placeholder}
|
|
1130
|
+
class="block w-full resize-y bg-background px-3 py-2 font-mono text-xs outline-none placeholder:text-muted-foreground"
|
|
1131
|
+
/>
|
|
1132
|
+
</div>
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (f._type === "richText") {
|
|
1137
|
+
const edId = `rich-${f._key}`;
|
|
1138
|
+
const hId = `rich-h-${f._key}`;
|
|
1139
|
+
const cmd = (c: string): string => `__kRichCmd('${c}')`;
|
|
1140
|
+
const btn =
|
|
1141
|
+
"rounded px-2 py-1 text-xs font-medium text-muted-foreground transition hover:bg-accent hover:text-foreground";
|
|
1142
|
+
return (
|
|
1143
|
+
<div class={`mt-1.5 overflow-hidden rounded-lg border border-input ${invalid}`}>
|
|
1144
|
+
<div class="flex flex-wrap items-center gap-0.5 border-b border-border bg-muted/40 p-1">
|
|
1145
|
+
<button type="button" class={`${btn} font-bold`} onclick={cmd("bold")}>
|
|
1146
|
+
B
|
|
1147
|
+
</button>
|
|
1148
|
+
<button type="button" class={`${btn} italic`} onclick={cmd("italic")}>
|
|
1149
|
+
I
|
|
1150
|
+
</button>
|
|
1151
|
+
<button type="button" class={`${btn} underline`} onclick={cmd("underline")}>
|
|
1152
|
+
U
|
|
1153
|
+
</button>
|
|
1154
|
+
<button type="button" class={btn} onclick={cmd("insertUnorderedList")}>
|
|
1155
|
+
• List
|
|
1156
|
+
</button>
|
|
1157
|
+
<button type="button" class={btn} onclick={cmd("insertOrderedList")}>
|
|
1158
|
+
1. List
|
|
1159
|
+
</button>
|
|
1160
|
+
</div>
|
|
1161
|
+
{/* Hidden, Flow-modeled field; the contenteditable syncs into it. */}
|
|
1162
|
+
<textarea id={hId} value={form[f._key]} class="hidden" />
|
|
1163
|
+
<div
|
|
1164
|
+
id={edId}
|
|
1165
|
+
contenteditable={disabled ? "false" : "true"}
|
|
1166
|
+
class="prose-sm min-h-[8rem] max-w-none bg-background px-3 py-2 text-sm outline-none [&_*]:my-1"
|
|
1167
|
+
/>
|
|
1168
|
+
<script dangerouslySetInnerHTML={{ __html: `__kRich('${edId}','${hId}')` }} />
|
|
1169
|
+
</div>
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (f._type === "color") {
|
|
1174
|
+
return (
|
|
1175
|
+
<input
|
|
1176
|
+
type="color"
|
|
1177
|
+
value={form[f._key]}
|
|
1178
|
+
disabled={disabled}
|
|
1179
|
+
class="mt-1.5 h-9 w-16 cursor-pointer rounded-lg border border-input bg-background p-1"
|
|
1180
|
+
/>
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
return (
|
|
1185
|
+
<input
|
|
1186
|
+
type={this._inputType(f)}
|
|
1187
|
+
value={form[f._key]}
|
|
1188
|
+
step={f._step}
|
|
1189
|
+
placeholder={f._placeholder}
|
|
1190
|
+
autocomplete={f._autocomplete}
|
|
1191
|
+
disabled={disabled}
|
|
1192
|
+
class={`${INPUT_CLASS} ${invalid}`}
|
|
1193
|
+
{...live}
|
|
1194
|
+
/>
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private _field(f: Field, data: Record<string, unknown>): HtmlNode {
|
|
1199
|
+
if (f._type === "hidden") {
|
|
1200
|
+
return <input type="hidden" value={(this.form as Record<string, unknown>)[f._key]} />;
|
|
1201
|
+
}
|
|
1202
|
+
if (f._type === "repeater" || f._type === "builder") {
|
|
1203
|
+
return this._repeaterField(f);
|
|
1204
|
+
}
|
|
1205
|
+
const disabled = f.isDisabledFor(data);
|
|
1206
|
+
const error = this._error(f._key);
|
|
1207
|
+
// Toggle / checkbox carry their own inline label, so skip the stacked one.
|
|
1208
|
+
const inlineLabel = f._type === "toggle" || f._type === "checkbox";
|
|
1209
|
+
return (
|
|
1210
|
+
<div class={SPAN_CLASS[Math.min(f._columnSpan, 4)]}>
|
|
1211
|
+
{inlineLabel ? null : (
|
|
1212
|
+
<label class="block text-sm font-medium text-foreground">
|
|
1213
|
+
{f.getLabel()}
|
|
1214
|
+
{f._required ? <span class="ml-0.5 text-destructive">*</span> : null}
|
|
1215
|
+
</label>
|
|
1216
|
+
)}
|
|
1217
|
+
{this._control(f, disabled)}
|
|
1218
|
+
{error ? (
|
|
1219
|
+
<p class="mt-1 text-xs text-destructive">{error}</p>
|
|
1220
|
+
) : f._helper ? (
|
|
1221
|
+
<p class="mt-1 text-xs text-muted-foreground">{f._helper}</p>
|
|
1222
|
+
) : null}
|
|
1223
|
+
</div>
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// ── Repeater / Builder rendering ────────────────────────────────────────────
|
|
1228
|
+
|
|
1229
|
+
/** A repeater or builder field — full-width, with add/remove/reorder rows. */
|
|
1230
|
+
private _repeaterField(f: Field): HtmlNode {
|
|
1231
|
+
const rows = this._rows(f._key);
|
|
1232
|
+
const isBuilder = f._type === "builder";
|
|
1233
|
+
|
|
1234
|
+
// Seed each visible sub-input's draft from the row value (guarded), so existing
|
|
1235
|
+
// values show up; thereafter the draft is the source of truth for that input.
|
|
1236
|
+
for (const row of rows) {
|
|
1237
|
+
for (const sf of this._rowFields(f, row)) {
|
|
1238
|
+
const dk = this._repKey(f._key, row.__id, sf._key);
|
|
1239
|
+
if (!(dk in this.repeaterDraft)) this.repeaterDraft[dk] = row[sf._key] ?? sf.defaultValue();
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const atMax = f._maxItems != null && rows.length >= f._maxItems;
|
|
1244
|
+
const error = this._error(f._key);
|
|
1245
|
+
|
|
1246
|
+
return (
|
|
1247
|
+
<div class="col-span-full">
|
|
1248
|
+
<label class="block text-sm font-medium text-foreground">
|
|
1249
|
+
{f.getLabel()}
|
|
1250
|
+
{f._required ? <span class="ml-0.5 text-destructive">*</span> : null}
|
|
1251
|
+
</label>
|
|
1252
|
+
{f._helper ? <p class="mt-0.5 text-xs text-muted-foreground">{f._helper}</p> : null}
|
|
1253
|
+
|
|
1254
|
+
<div class="mt-2 space-y-3">
|
|
1255
|
+
{rows.length === 0 ? (
|
|
1256
|
+
<p class="rounded-lg border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
|
1257
|
+
No items yet.
|
|
1258
|
+
</p>
|
|
1259
|
+
) : (
|
|
1260
|
+
rows.map((row, i) => this._repeaterRow(f, row, i, rows.length))
|
|
1261
|
+
)}
|
|
1262
|
+
</div>
|
|
1263
|
+
|
|
1264
|
+
{atMax ? null : isBuilder ? (
|
|
1265
|
+
<div class="mt-3 flex flex-wrap gap-2">
|
|
1266
|
+
{f._blocks.map((b) => (
|
|
1267
|
+
<button
|
|
1268
|
+
type="button"
|
|
1269
|
+
onClick={this.addBuilderBlock}
|
|
1270
|
+
data-args={JSON.stringify([f._key, b.name])}
|
|
1271
|
+
class="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-input px-3 py-1.5 text-sm font-medium text-muted-foreground transition hover:border-primary hover:text-foreground"
|
|
1272
|
+
>
|
|
1273
|
+
{b._icon ? (
|
|
1274
|
+
<Icon name={b._icon} class="h-4 w-4" />
|
|
1275
|
+
) : (
|
|
1276
|
+
<Icon name="plus" class="h-4 w-4" />
|
|
1277
|
+
)}
|
|
1278
|
+
{b.getLabel()}
|
|
1279
|
+
</button>
|
|
1280
|
+
))}
|
|
1281
|
+
</div>
|
|
1282
|
+
) : (
|
|
1283
|
+
<button
|
|
1284
|
+
type="button"
|
|
1285
|
+
onClick={this.addRepeaterItem}
|
|
1286
|
+
data-args={JSON.stringify([f._key])}
|
|
1287
|
+
class="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-dashed border-input px-3 py-1.5 text-sm font-medium text-muted-foreground transition hover:border-primary hover:text-foreground"
|
|
1288
|
+
>
|
|
1289
|
+
<Icon name="plus" class="h-4 w-4" />
|
|
1290
|
+
{f._addLabel ?? `Add ${f.getLabel()}`}
|
|
1291
|
+
</button>
|
|
1292
|
+
)}
|
|
1293
|
+
|
|
1294
|
+
{error ? <p class="mt-1 text-xs text-destructive">{error}</p> : null}
|
|
1295
|
+
</div>
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** A single repeater/builder row card — header (title + controls) + sub-fields. */
|
|
1300
|
+
private _repeaterRow(
|
|
1301
|
+
f: Field,
|
|
1302
|
+
row: Record<string, unknown>,
|
|
1303
|
+
index: number,
|
|
1304
|
+
total: number,
|
|
1305
|
+
): HtmlNode {
|
|
1306
|
+
const isBuilder = f._type === "builder";
|
|
1307
|
+
const block = isBuilder ? f._blocks.find((b) => b.name === row.__type) : null;
|
|
1308
|
+
const subs = this._rowFields(f, row);
|
|
1309
|
+
const title = f._itemLabel
|
|
1310
|
+
? f._itemLabel(row, index)
|
|
1311
|
+
: isBuilder
|
|
1312
|
+
? (block?.getLabel() ?? "Block")
|
|
1313
|
+
: `${f.getLabel()} ${index + 1}`;
|
|
1314
|
+
const ctrl =
|
|
1315
|
+
"flex h-7 w-7 items-center justify-center rounded-md border border-input text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-40";
|
|
1316
|
+
|
|
1317
|
+
return (
|
|
1318
|
+
<div class="rounded-lg border border-border bg-muted/20 p-4">
|
|
1319
|
+
<div class="mb-3 flex items-center justify-between gap-2">
|
|
1320
|
+
<span class="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
1321
|
+
{isBuilder && block?._icon ? <Icon name={block._icon} class="h-3.5 w-3.5" /> : null}
|
|
1322
|
+
{title}
|
|
1323
|
+
</span>
|
|
1324
|
+
<div class="flex items-center gap-1">
|
|
1325
|
+
{f._reorderable && index > 0 ? (
|
|
1326
|
+
<button
|
|
1327
|
+
type="button"
|
|
1328
|
+
onClick={this.moveRepeaterItem}
|
|
1329
|
+
data-args={JSON.stringify([f._key, row.__id, -1])}
|
|
1330
|
+
class={ctrl}
|
|
1331
|
+
aria-label="Move up"
|
|
1332
|
+
>
|
|
1333
|
+
<Icon name="chevron-down" class="h-4 w-4 rotate-180" />
|
|
1334
|
+
</button>
|
|
1335
|
+
) : null}
|
|
1336
|
+
{f._reorderable && index < total - 1 ? (
|
|
1337
|
+
<button
|
|
1338
|
+
type="button"
|
|
1339
|
+
onClick={this.moveRepeaterItem}
|
|
1340
|
+
data-args={JSON.stringify([f._key, row.__id, 1])}
|
|
1341
|
+
class={ctrl}
|
|
1342
|
+
aria-label="Move down"
|
|
1343
|
+
>
|
|
1344
|
+
<Icon name="chevron-down" class="h-4 w-4" />
|
|
1345
|
+
</button>
|
|
1346
|
+
) : null}
|
|
1347
|
+
<button
|
|
1348
|
+
type="button"
|
|
1349
|
+
onClick={this.removeRepeaterItem}
|
|
1350
|
+
data-args={JSON.stringify([f._key, row.__id])}
|
|
1351
|
+
class={`${ctrl} hover:border-destructive hover:text-destructive`}
|
|
1352
|
+
aria-label="Remove"
|
|
1353
|
+
>
|
|
1354
|
+
<Icon name="trash" class="h-4 w-4" />
|
|
1355
|
+
</button>
|
|
1356
|
+
</div>
|
|
1357
|
+
</div>
|
|
1358
|
+
<div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
|
1359
|
+
{subs.map((sf) => this._repeaterSub(f._key, row.__id, sf))}
|
|
1360
|
+
</div>
|
|
1361
|
+
</div>
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/** A labelled sub-field inside a repeater/builder row. */
|
|
1366
|
+
private _repeaterSub(fieldKey: string, rowId: unknown, sf: Field): HtmlNode {
|
|
1367
|
+
const dk = this._repKey(fieldKey, rowId, sf._key);
|
|
1368
|
+
const inlineLabel = sf._type === "toggle" || sf._type === "checkbox";
|
|
1369
|
+
return (
|
|
1370
|
+
<div class={SPAN_CLASS[Math.min(sf._columnSpan, 4)]}>
|
|
1371
|
+
{inlineLabel ? null : (
|
|
1372
|
+
<label class="block text-sm font-medium text-foreground">
|
|
1373
|
+
{sf.getLabel()}
|
|
1374
|
+
{sf._required ? <span class="ml-0.5 text-destructive">*</span> : null}
|
|
1375
|
+
</label>
|
|
1376
|
+
)}
|
|
1377
|
+
{this._repeaterControl(dk, sf)}
|
|
1378
|
+
{sf._helper ? <p class="mt-1 text-xs text-muted-foreground">{sf._helper}</p> : null}
|
|
1379
|
+
</div>
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** Render a repeater sub-input bound to its flat draft key (`flow:model`). */
|
|
1384
|
+
private _repeaterControl(dk: string, sf: Field): HtmlNode {
|
|
1385
|
+
const draft = this.repeaterDraft;
|
|
1386
|
+
|
|
1387
|
+
if (sf._type === "textarea") {
|
|
1388
|
+
return (
|
|
1389
|
+
<textarea
|
|
1390
|
+
value={draft[dk]}
|
|
1391
|
+
rows={sf._rows}
|
|
1392
|
+
placeholder={sf._placeholder}
|
|
1393
|
+
class={INPUT_CLASS}
|
|
1394
|
+
/>
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if (sf._type === "toggle" || sf._type === "checkbox") {
|
|
1399
|
+
return (
|
|
1400
|
+
<label class="mt-1.5 inline-flex items-center gap-2">
|
|
1401
|
+
<input
|
|
1402
|
+
type="checkbox"
|
|
1403
|
+
checked={draft[dk]}
|
|
1404
|
+
class="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring"
|
|
1405
|
+
/>
|
|
1406
|
+
<span class="text-sm text-muted-foreground">{sf._placeholder ?? sf.getLabel()}</span>
|
|
1407
|
+
</label>
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
if (sf._type === "select") {
|
|
1412
|
+
const cur = String(draft[dk] ?? "");
|
|
1413
|
+
return (
|
|
1414
|
+
<select value={draft[dk]} class={INPUT_CLASS}>
|
|
1415
|
+
<option value="">{sf._placeholder ?? "Select…"}</option>
|
|
1416
|
+
{(sf._options ?? []).map((o) => (
|
|
1417
|
+
<option value={o.value} selected={String(o.value) === cur}>
|
|
1418
|
+
{o.label}
|
|
1419
|
+
</option>
|
|
1420
|
+
))}
|
|
1421
|
+
</select>
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (sf._type === "color") {
|
|
1426
|
+
return (
|
|
1427
|
+
<input
|
|
1428
|
+
type="color"
|
|
1429
|
+
value={draft[dk]}
|
|
1430
|
+
class="mt-1.5 h-9 w-16 cursor-pointer rounded-lg border border-input bg-background p-1"
|
|
1431
|
+
/>
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return (
|
|
1436
|
+
<input
|
|
1437
|
+
type={this._inputType(sf)}
|
|
1438
|
+
value={draft[dk]}
|
|
1439
|
+
step={sf._step}
|
|
1440
|
+
placeholder={sf._placeholder}
|
|
1441
|
+
class={INPUT_CLASS}
|
|
1442
|
+
/>
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/** Tailwind column-count class for a section grid (1–4 columns). */
|
|
1447
|
+
private _gridClass(columns: number): string {
|
|
1448
|
+
const map: Record<number, string> = {
|
|
1449
|
+
1: "sm:grid-cols-1",
|
|
1450
|
+
2: "sm:grid-cols-2",
|
|
1451
|
+
3: "sm:grid-cols-3",
|
|
1452
|
+
4: "sm:grid-cols-4",
|
|
1453
|
+
};
|
|
1454
|
+
return map[Math.min(4, Math.max(1, columns))] ?? "sm:grid-cols-2";
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** Fields visible in the current mode and for the current form data. */
|
|
1458
|
+
private _visibleFields(fields: Field[], data: Record<string, unknown>): Field[] {
|
|
1459
|
+
return fields.filter(
|
|
1460
|
+
(f) => f.visibleIn(this.mode) && (f._type === "hidden" || f.visibleForData(data)),
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
private _fieldsGrid(fields: Field[], columns: number, data: Record<string, unknown>): HtmlNode {
|
|
1465
|
+
return (
|
|
1466
|
+
<div class={`grid grid-cols-1 gap-x-6 gap-y-5 ${this._gridClass(columns)}`}>
|
|
1467
|
+
{fields.map((f) => this._field(f, data))}
|
|
1468
|
+
</div>
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
private _sectionCard(section: FormSection, data: Record<string, unknown>): HtmlNode | null {
|
|
1473
|
+
const fields = this._visibleFields(section.getFields(), data);
|
|
1474
|
+
if (fields.length === 0) return null;
|
|
1475
|
+
|
|
1476
|
+
// Fieldset variant — a bordered <fieldset> with a <legend>, lighter than a card.
|
|
1477
|
+
if (section._fieldset) {
|
|
1478
|
+
return (
|
|
1479
|
+
<fieldset class="rounded-xl border border-border px-5 pb-5 pt-3 sm:px-6 sm:pb-6">
|
|
1480
|
+
{section._heading ? (
|
|
1481
|
+
<legend class="px-2 text-sm font-semibold">{section._heading}</legend>
|
|
1482
|
+
) : null}
|
|
1483
|
+
{section._description ? (
|
|
1484
|
+
<p class="mb-3 text-xs text-muted-foreground">{section._description}</p>
|
|
1485
|
+
) : null}
|
|
1486
|
+
{this._fieldsGrid(fields, section._columns, data)}
|
|
1487
|
+
</fieldset>
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
return (
|
|
1492
|
+
<div class="rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm sm:p-6">
|
|
1493
|
+
{section._heading ? (
|
|
1494
|
+
<div class="mb-4 flex items-start gap-3 border-b border-border pb-4">
|
|
1495
|
+
{section._icon ? (
|
|
1496
|
+
<span class="mt-0.5 flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
1497
|
+
<Icon name={section._icon} class="h-4 w-4" />
|
|
1498
|
+
</span>
|
|
1499
|
+
) : null}
|
|
1500
|
+
<div>
|
|
1501
|
+
<h2 class="text-sm font-semibold">{section._heading}</h2>
|
|
1502
|
+
{section._description ? (
|
|
1503
|
+
<p class="mt-0.5 text-xs text-muted-foreground">{section._description}</p>
|
|
1504
|
+
) : null}
|
|
1505
|
+
</div>
|
|
1506
|
+
</div>
|
|
1507
|
+
) : null}
|
|
1508
|
+
{this._fieldsGrid(fields, section._columns, data)}
|
|
1509
|
+
</div>
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Side-by-side sections. */
|
|
1514
|
+
private _splitCard(s: FormSplit, data: Record<string, unknown>): HtmlNode {
|
|
1515
|
+
const cards = s._sections
|
|
1516
|
+
.map((sec) => this._sectionCard(sec, data))
|
|
1517
|
+
.filter((c): c is HtmlNode => c !== null);
|
|
1518
|
+
return <div class="grid grid-cols-1 gap-5 lg:grid-cols-2">{cards}</div>;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/** A non-field callout / notice block. */
|
|
1522
|
+
private _calloutCard(c: Callout): HtmlNode {
|
|
1523
|
+
const tones: Record<CalloutTone, string> = {
|
|
1524
|
+
default: "border-border bg-muted/40",
|
|
1525
|
+
primary: "border-primary/30 bg-primary/5",
|
|
1526
|
+
success: "border-emerald-500/30 bg-emerald-500/5",
|
|
1527
|
+
warning: "border-amber-500/30 bg-amber-500/5",
|
|
1528
|
+
destructive: "border-destructive/30 bg-destructive/5",
|
|
1529
|
+
};
|
|
1530
|
+
return (
|
|
1531
|
+
<div class={`flex gap-3 rounded-xl border p-4 ${tones[c._tone]}`}>
|
|
1532
|
+
{c._icon ? (
|
|
1533
|
+
<Icon name={c._icon} class="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
|
1534
|
+
) : null}
|
|
1535
|
+
<div class="min-w-0">
|
|
1536
|
+
{c._heading ? <p class="text-sm font-semibold">{c._heading}</p> : null}
|
|
1537
|
+
<p class="text-sm text-muted-foreground">{c._content}</p>
|
|
1538
|
+
</div>
|
|
1539
|
+
</div>
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** A static display block — text, raw HTML, or an image. */
|
|
1544
|
+
private _primeCard(p: Prime): HtmlNode {
|
|
1545
|
+
if (p._kind === "image") {
|
|
1546
|
+
// Author-supplied, so usually already a URL — but a storage path passes
|
|
1547
|
+
// through the same resolver rather than becoming a page-relative fetch.
|
|
1548
|
+
const src = resolveMediaSrc(p._content, this._panel.mediaDisk()) ?? p._content;
|
|
1549
|
+
return <img src={src} alt={p._alt ?? ""} class="rounded-xl border border-border" />;
|
|
1550
|
+
}
|
|
1551
|
+
if (p._kind === "html") {
|
|
1552
|
+
return (
|
|
1553
|
+
<div
|
|
1554
|
+
class="prose-sm max-w-none text-sm text-foreground [&_a]:text-primary"
|
|
1555
|
+
dangerouslySetInnerHTML={{ __html: p._content }}
|
|
1556
|
+
/>
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
return <p class="text-sm text-muted-foreground">{p._content}</p>;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/** Render a tabbed group via flow-ui Tabs (client-side switching; all panels stay mounted). */
|
|
1563
|
+
private _tabsCard(tabs: FormTabs, data: Record<string, unknown>): HtmlNode {
|
|
1564
|
+
const items = tabs._tabs.map((t) => ({
|
|
1565
|
+
label: (
|
|
1566
|
+
<span class="inline-flex items-center gap-1.5">
|
|
1567
|
+
{t._icon ? <Icon name={t._icon} class="h-4 w-4" /> : null}
|
|
1568
|
+
{t._label}
|
|
1569
|
+
</span>
|
|
1570
|
+
),
|
|
1571
|
+
content: (
|
|
1572
|
+
<div class="rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm sm:p-6">
|
|
1573
|
+
{this._fieldsGrid(this._visibleFields(t.getFields(), data), t._columns, data)}
|
|
1574
|
+
</div>
|
|
1575
|
+
),
|
|
1576
|
+
}));
|
|
1577
|
+
return <Tabs items={items} />;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** Render a wizard as a complete stepped form (indicator + step card + nav). */
|
|
1581
|
+
private _wizardForm(wiz: Wizard, data: Record<string, unknown>, cancelHref: string): HtmlNode {
|
|
1582
|
+
const steps = wiz._steps;
|
|
1583
|
+
const idx = Math.min(Math.max(0, this.wizardStepIndex), steps.length - 1);
|
|
1584
|
+
const step = steps[idx];
|
|
1585
|
+
const fields = step ? this._visibleFields(step.getFields(), data) : [];
|
|
1586
|
+
const last = idx >= steps.length - 1;
|
|
1587
|
+
return (
|
|
1588
|
+
<form onSubmit={this.wizardSubmit} class="space-y-5">
|
|
1589
|
+
<ol class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
1590
|
+
{steps.map((s, i) => (
|
|
1591
|
+
<li class="flex items-center gap-2">
|
|
1592
|
+
<span
|
|
1593
|
+
class={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold ${
|
|
1594
|
+
i < idx
|
|
1595
|
+
? "bg-primary text-primary-foreground"
|
|
1596
|
+
: i === idx
|
|
1597
|
+
? "bg-primary/15 text-primary ring-2 ring-primary"
|
|
1598
|
+
: "bg-muted text-muted-foreground"
|
|
1599
|
+
}`}
|
|
1600
|
+
>
|
|
1601
|
+
{i < idx ? <Icon name="check-circle" class="h-4 w-4" /> : String(i + 1)}
|
|
1602
|
+
</span>
|
|
1603
|
+
<span
|
|
1604
|
+
class={`text-sm font-medium ${i === idx ? "text-foreground" : "text-muted-foreground"}`}
|
|
1605
|
+
>
|
|
1606
|
+
{s._label}
|
|
1607
|
+
</span>
|
|
1608
|
+
{i < steps.length - 1 ? (
|
|
1609
|
+
<span class="mx-1 hidden h-px w-6 bg-border sm:block" />
|
|
1610
|
+
) : null}
|
|
1611
|
+
</li>
|
|
1612
|
+
))}
|
|
1613
|
+
</ol>
|
|
1614
|
+
|
|
1615
|
+
<div class="rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm sm:p-6">
|
|
1616
|
+
{step?._description ? (
|
|
1617
|
+
<p class="mb-4 text-sm text-muted-foreground">{step._description}</p>
|
|
1618
|
+
) : null}
|
|
1619
|
+
{this._fieldsGrid(fields, step?._columns ?? 1, data)}
|
|
1620
|
+
</div>
|
|
1621
|
+
|
|
1622
|
+
<div class="flex items-center justify-between gap-2">
|
|
1623
|
+
<a
|
|
1624
|
+
href={cancelHref}
|
|
1625
|
+
navigate
|
|
1626
|
+
class="inline-flex h-9 items-center rounded-lg border border-input bg-background px-4 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
|
|
1627
|
+
>
|
|
1628
|
+
Cancel
|
|
1629
|
+
</a>
|
|
1630
|
+
<div class="flex items-center gap-2">
|
|
1631
|
+
{idx > 0 ? (
|
|
1632
|
+
<button
|
|
1633
|
+
type="button"
|
|
1634
|
+
onClick={this.prevStep}
|
|
1635
|
+
class="inline-flex h-9 items-center gap-1 rounded-lg border border-input bg-background px-4 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
|
|
1636
|
+
>
|
|
1637
|
+
<Icon name="chevron-left" class="h-4 w-4" /> Back
|
|
1638
|
+
</button>
|
|
1639
|
+
) : null}
|
|
1640
|
+
<button
|
|
1641
|
+
type="submit"
|
|
1642
|
+
loadingAttr="disabled"
|
|
1643
|
+
class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-60"
|
|
1644
|
+
>
|
|
1645
|
+
{last ? (
|
|
1646
|
+
<>
|
|
1647
|
+
<Icon name="check-circle" class="h-4 w-4" />
|
|
1648
|
+
{this.mode === "edit" ? "Save changes" : "Create"}
|
|
1649
|
+
</>
|
|
1650
|
+
) : (
|
|
1651
|
+
<>
|
|
1652
|
+
Next <Icon name="chevron-right" class="h-4 w-4" />
|
|
1653
|
+
</>
|
|
1654
|
+
)}
|
|
1655
|
+
</button>
|
|
1656
|
+
</div>
|
|
1657
|
+
</div>
|
|
1658
|
+
</form>
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/** Fallback for a wizard mixed with other blocks — render steps as stacked sections. */
|
|
1663
|
+
private _wizardFallback(wiz: Wizard, data: Record<string, unknown>): HtmlNode {
|
|
1664
|
+
return (
|
|
1665
|
+
<>
|
|
1666
|
+
{wiz._steps.map((s) => {
|
|
1667
|
+
const fields = this._visibleFields(s.getFields(), data);
|
|
1668
|
+
return fields.length === 0 ? null : (
|
|
1669
|
+
<div class="rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm sm:p-6">
|
|
1670
|
+
<h2 class="mb-4 text-sm font-semibold">{s._label}</h2>
|
|
1671
|
+
{this._fieldsGrid(fields, s._columns, data)}
|
|
1672
|
+
</div>
|
|
1673
|
+
);
|
|
1674
|
+
})}
|
|
1675
|
+
</>
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
override async render(): Promise<HtmlNode> {
|
|
1680
|
+
const meta = this._meta;
|
|
1681
|
+
const cfg = this._cfg;
|
|
1682
|
+
const base = this._panel.base();
|
|
1683
|
+
if (!meta || !cfg) {
|
|
1684
|
+
return (
|
|
1685
|
+
<div class="mx-auto w-full max-w-3xl">
|
|
1686
|
+
<div class="rounded-xl border border-dashed border-border p-12 text-center text-sm text-muted-foreground">
|
|
1687
|
+
This resource has no form.
|
|
1688
|
+
</div>
|
|
1689
|
+
</div>
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// Resolve dynamic select options (e.g. BelongsTo relationships) for this render.
|
|
1694
|
+
this._resolvedOptions = {};
|
|
1695
|
+
await Promise.all(
|
|
1696
|
+
cfg.fields
|
|
1697
|
+
.filter((f) => f._optionsLoader)
|
|
1698
|
+
.map(async (f) => {
|
|
1699
|
+
this._resolvedOptions[f._key] = await f._optionsLoader!();
|
|
1700
|
+
}),
|
|
1701
|
+
);
|
|
1702
|
+
|
|
1703
|
+
const R = meta.resource;
|
|
1704
|
+
const formData = this.form.data();
|
|
1705
|
+
// Layout blocks (sections + tab groups); loose fields are auto-wrapped.
|
|
1706
|
+
const blocks = toFormLayout(R.form());
|
|
1707
|
+
const parentId = this.parentId || undefined;
|
|
1708
|
+
const listHref = R.indexUrl(base, parentId);
|
|
1709
|
+
// Cancelling an edit returns to the record; a singular resource has no
|
|
1710
|
+
// record page to return to, so its form is its own destination.
|
|
1711
|
+
const cancelHref =
|
|
1712
|
+
this.mode === "edit" && !R.singular ? R.recordUrl(base, this.recordId, parentId) : listHref;
|
|
1713
|
+
const title =
|
|
1714
|
+
this.mode === "edit"
|
|
1715
|
+
? `Edit ${R.getLabel().toLowerCase()}`
|
|
1716
|
+
: `New ${R.getLabel().toLowerCase()}`;
|
|
1717
|
+
|
|
1718
|
+
// The library itself is only queried while the picker is open — a form with
|
|
1719
|
+
// a media field should not pay for that on every render. What is already
|
|
1720
|
+
// selected still needs a URL, so those paths are resolved either way.
|
|
1721
|
+
this._mediaItems = [];
|
|
1722
|
+
this._mediaUrls = {};
|
|
1723
|
+
const mediaKeys = this._cfg!.fields.filter((f) => f._type === "media").map((f) => f._key);
|
|
1724
|
+
if (mediaKeys.length > 0) {
|
|
1725
|
+
if (this.pickingFor) {
|
|
1726
|
+
const provider = this._panel.mediaProvider();
|
|
1727
|
+
this._mediaItems = provider
|
|
1728
|
+
? await provider.list({
|
|
1729
|
+
limit: 60,
|
|
1730
|
+
...(this.pickerSearch ? { search: this.pickerSearch } : {}),
|
|
1731
|
+
})
|
|
1732
|
+
: [];
|
|
1733
|
+
}
|
|
1734
|
+
const selected = mediaKeys
|
|
1735
|
+
.map((key) => (this.form as Record<string, unknown>)[key])
|
|
1736
|
+
.filter((v): v is string => typeof v === "string" && v !== "");
|
|
1737
|
+
const paths = [...new Set([...this._mediaItems.map((i) => i.path), ...selected])];
|
|
1738
|
+
for (const [path, url] of await Promise.all(
|
|
1739
|
+
paths.map(
|
|
1740
|
+
async (path) =>
|
|
1741
|
+
[
|
|
1742
|
+
path,
|
|
1743
|
+
await mediaUrl(
|
|
1744
|
+
this._mediaItems.find((i) => i.path === path) ??
|
|
1745
|
+
// A path with no catalogue entry still resolves through the
|
|
1746
|
+
// disk, so an existing value keeps working.
|
|
1747
|
+
({ id: path, path, name: path, mime: "", size: 0 } as MediaItem),
|
|
1748
|
+
this._panel.mediaDisk(),
|
|
1749
|
+
),
|
|
1750
|
+
] as const,
|
|
1751
|
+
),
|
|
1752
|
+
)) {
|
|
1753
|
+
// Only real URLs are recorded; a disk with none leaves the entry absent
|
|
1754
|
+
// so the template can render a placeholder instead of a broken image.
|
|
1755
|
+
if (url) this._mediaUrls[path] = url;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
return (
|
|
1760
|
+
<div class="mx-auto w-full max-w-3xl space-y-6">
|
|
1761
|
+
{/* Client helpers for code/markdown/rich editors (defined once). */}
|
|
1762
|
+
<script dangerouslySetInnerHTML={{ __html: EDITOR_SCRIPT }} />
|
|
1763
|
+
{/* Header */}
|
|
1764
|
+
<div>
|
|
1765
|
+
<Breadcrumbs
|
|
1766
|
+
trail={resourceTrail({
|
|
1767
|
+
panel: this._panel,
|
|
1768
|
+
resource: R,
|
|
1769
|
+
parentId,
|
|
1770
|
+
...(this.mode === "edit" && !R.singular ? { recordId: this.recordId } : {}),
|
|
1771
|
+
leaf: this.mode === "create" ? "New" : R.singular ? undefined : "Edit",
|
|
1772
|
+
})}
|
|
1773
|
+
/>
|
|
1774
|
+
<h1 class="text-2xl font-semibold tracking-tight capitalize">{title}</h1>
|
|
1775
|
+
</div>
|
|
1776
|
+
|
|
1777
|
+
{/* Locale tabs. One set of fields, edited one language at a time —
|
|
1778
|
+
switching banks the current text rather than discarding it. */}
|
|
1779
|
+
{R.translatable.length > 0 && R.locales.length > 1 ? (
|
|
1780
|
+
<div class="flex flex-wrap items-center gap-1 border-b border-border">
|
|
1781
|
+
{R.locales.map((code) => {
|
|
1782
|
+
const on = this._locale() === code;
|
|
1783
|
+
return (
|
|
1784
|
+
<button
|
|
1785
|
+
type="button"
|
|
1786
|
+
onClick={this.switchLocale}
|
|
1787
|
+
data-args={JSON.stringify([code])}
|
|
1788
|
+
class={`-mb-px border-b-2 px-3 py-2 text-sm font-medium uppercase transition ${
|
|
1789
|
+
on
|
|
1790
|
+
? "border-primary text-foreground"
|
|
1791
|
+
: "border-transparent text-muted-foreground hover:border-border hover:text-foreground"
|
|
1792
|
+
}`}
|
|
1793
|
+
>
|
|
1794
|
+
{code}
|
|
1795
|
+
</button>
|
|
1796
|
+
);
|
|
1797
|
+
})}
|
|
1798
|
+
</div>
|
|
1799
|
+
) : null}
|
|
1800
|
+
|
|
1801
|
+
{/* Media picker — opened by a media field, shared by all of them. */}
|
|
1802
|
+
{this.pickingFor ? this._mediaPicker() : null}
|
|
1803
|
+
|
|
1804
|
+
{resolveRenderHooks(this._panel.renderHooks("form.start"), {
|
|
1805
|
+
resource: R.getSlug(),
|
|
1806
|
+
page: "form",
|
|
1807
|
+
recordId: this.recordId || undefined,
|
|
1808
|
+
})}
|
|
1809
|
+
|
|
1810
|
+
{/* Form card. A real <form onSubmit={method}> emits `flow:submit="save"`
|
|
1811
|
+
(a server action) and submits on Enter — same pattern as the auth pages. */}
|
|
1812
|
+
{blocks.length === 1 && blocks[0]?.kind === "wizard" ? (
|
|
1813
|
+
// Whole-form wizard — stepped UI with its own Back/Next/Finish footer.
|
|
1814
|
+
this._wizardForm(blocks[0].wizard, formData, cancelHref)
|
|
1815
|
+
) : (
|
|
1816
|
+
<form onSubmit={this.save} class="space-y-5">
|
|
1817
|
+
{blocks.map((block) => {
|
|
1818
|
+
switch (block.kind) {
|
|
1819
|
+
case "tabs":
|
|
1820
|
+
return this._tabsCard(block.tabs, formData);
|
|
1821
|
+
case "wizard":
|
|
1822
|
+
return this._wizardFallback(block.wizard, formData);
|
|
1823
|
+
case "split":
|
|
1824
|
+
return this._splitCard(block.split, formData);
|
|
1825
|
+
case "callout":
|
|
1826
|
+
return this._calloutCard(block.callout);
|
|
1827
|
+
case "prime":
|
|
1828
|
+
return this._primeCard(block.prime);
|
|
1829
|
+
default:
|
|
1830
|
+
return this._sectionCard(block.section, formData);
|
|
1831
|
+
}
|
|
1832
|
+
})}
|
|
1833
|
+
|
|
1834
|
+
<div class="flex items-center justify-end gap-2">
|
|
1835
|
+
<a
|
|
1836
|
+
href={cancelHref}
|
|
1837
|
+
navigate
|
|
1838
|
+
class="inline-flex h-9 items-center rounded-lg border border-input bg-background px-4 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground"
|
|
1839
|
+
>
|
|
1840
|
+
Cancel
|
|
1841
|
+
</a>
|
|
1842
|
+
<button
|
|
1843
|
+
type="submit"
|
|
1844
|
+
loadingAttr="disabled"
|
|
1845
|
+
class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-60"
|
|
1846
|
+
>
|
|
1847
|
+
<Icon name="check-circle" class="h-4 w-4" />
|
|
1848
|
+
{this.mode === "edit" ? "Save changes" : "Create"}
|
|
1849
|
+
</button>
|
|
1850
|
+
</div>
|
|
1851
|
+
</form>
|
|
1852
|
+
)}
|
|
1853
|
+
</div>
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
}
|