create-davepi-ui 0.1.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/LICENSE +21 -0
- package/README.md +29 -0
- package/bin/index.js +229 -0
- package/bin/sync-templates.js +100 -0
- package/package.json +40 -0
- package/templates/default/.env.example +1 -0
- package/templates/default/index.html +13 -0
- package/templates/default/package.json +49 -0
- package/templates/default/postcss.config.cjs +6 -0
- package/templates/default/src/App.tsx +42 -0
- package/templates/default/src/components/AppShell.tsx +23 -0
- package/templates/default/src/components/BulkActionBar.tsx +47 -0
- package/templates/default/src/components/RelatedCreateModal.tsx +91 -0
- package/templates/default/src/components/RelatedList.tsx +70 -0
- package/templates/default/src/components/ResourceForm.tsx +311 -0
- package/templates/default/src/components/ResourceTable.tsx +475 -0
- package/templates/default/src/components/RowActions.tsx +54 -0
- package/templates/default/src/components/Sidebar.tsx +171 -0
- package/templates/default/src/components/ui/button.tsx +43 -0
- package/templates/default/src/components/ui/card.tsx +47 -0
- package/templates/default/src/components/ui/checkbox.tsx +24 -0
- package/templates/default/src/components/ui/command.tsx +117 -0
- package/templates/default/src/components/ui/dialog.tsx +95 -0
- package/templates/default/src/components/ui/dropdown-menu.tsx +78 -0
- package/templates/default/src/components/ui/input.tsx +18 -0
- package/templates/default/src/components/ui/label.tsx +17 -0
- package/templates/default/src/components/ui/popover.tsx +27 -0
- package/templates/default/src/components/ui/select.tsx +83 -0
- package/templates/default/src/components/ui/switch.tsx +21 -0
- package/templates/default/src/components/ui/table.tsx +66 -0
- package/templates/default/src/components/ui/tabs.tsx +53 -0
- package/templates/default/src/components/ui/textarea.tsx +17 -0
- package/templates/default/src/davepi-ui.config.ts +14 -0
- package/templates/default/src/index.css +55 -0
- package/templates/default/src/lib/utils.ts +10 -0
- package/templates/default/src/main.tsx +34 -0
- package/templates/default/src/pages/DashboardPage.tsx +42 -0
- package/templates/default/src/pages/LoginScreen.tsx +77 -0
- package/templates/default/src/pages/ResourceCreatePage.tsx +58 -0
- package/templates/default/src/pages/ResourceDetailPage.tsx +171 -0
- package/templates/default/src/pages/ResourceEditPage.tsx +52 -0
- package/templates/default/src/pages/ResourceListPage.tsx +8 -0
- package/templates/default/src/resourceOverrides.ts +34 -0
- package/templates/default/src/resources/account.ts +25 -0
- package/templates/default/src/resources/category.ts +7 -0
- package/templates/default/src/resources/contact.ts +40 -0
- package/templates/default/src/resources/product.ts +7 -0
- package/templates/default/src/resources/project.ts +7 -0
- package/templates/default/src/resources/quote.ts +12 -0
- package/templates/default/src/vite-env.d.ts +9 -0
- package/templates/default/src/widgets/CurrencyInput.tsx +44 -0
- package/templates/default/src/widgets/DateInput.tsx +36 -0
- package/templates/default/src/widgets/EmailInput.tsx +28 -0
- package/templates/default/src/widgets/EnumSelect.tsx +35 -0
- package/templates/default/src/widgets/FileUploaderStub.tsx +9 -0
- package/templates/default/src/widgets/JsonEditor.tsx +64 -0
- package/templates/default/src/widgets/NumberInput.tsx +36 -0
- package/templates/default/src/widgets/RelationPicker.tsx +349 -0
- package/templates/default/src/widgets/SwitchWidget.tsx +20 -0
- package/templates/default/src/widgets/TagInput.tsx +83 -0
- package/templates/default/src/widgets/TextAreaWidget.tsx +27 -0
- package/templates/default/src/widgets/TextInput.tsx +32 -0
- package/templates/default/src/widgets/UrlInput.tsx +27 -0
- package/templates/default/src/widgets/registry.ts +51 -0
- package/templates/default/src/widgets/types.ts +26 -0
- package/templates/default/tailwind.config.ts +54 -0
- package/templates/default/tsconfig.json +40 -0
- package/templates/default/vite.config.ts +16 -0
- package/templates/pinned-versions.json +5 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useCreateResource, useDescribe } from '@davepi/ui-react';
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from '@/components/ui/dialog';
|
|
10
|
+
import { ResourceForm } from './ResourceForm';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Inline create dialog for a related resource.
|
|
14
|
+
*
|
|
15
|
+
* Wraps `<ResourceForm>` in a shadcn Dialog. Fields named in `prefill`
|
|
16
|
+
* render hidden + readonly but submit normally — used by `<RelatedList>`
|
|
17
|
+
* to stamp the parent foreign key and by `<RelationPicker>`'s
|
|
18
|
+
* "+ Create new" button.
|
|
19
|
+
*
|
|
20
|
+
* On success, fires `onCreated(record)` with the newly created object.
|
|
21
|
+
* Callers commonly use the returned `_id` to auto-select the new record
|
|
22
|
+
* in the picker that opened the modal.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* <RelatedCreateModal
|
|
26
|
+
* open={open}
|
|
27
|
+
* onOpenChange={setOpen}
|
|
28
|
+
* resource="contact"
|
|
29
|
+
* prefill={{ accountId }}
|
|
30
|
+
* onCreated={(record) => picker.select(record._id)}
|
|
31
|
+
* />
|
|
32
|
+
*/
|
|
33
|
+
export interface RelatedCreateModalProps {
|
|
34
|
+
open: boolean;
|
|
35
|
+
onOpenChange: (next: boolean) => void;
|
|
36
|
+
resource: string;
|
|
37
|
+
prefill?: Record<string, unknown>;
|
|
38
|
+
onCreated?: (record: Record<string, unknown>) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function RelatedCreateModal({
|
|
42
|
+
open,
|
|
43
|
+
onOpenChange,
|
|
44
|
+
resource,
|
|
45
|
+
prefill,
|
|
46
|
+
onCreated,
|
|
47
|
+
}: RelatedCreateModalProps) {
|
|
48
|
+
const { data: describe } = useDescribe();
|
|
49
|
+
const create = useCreateResource(resource);
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
|
|
52
|
+
const display = describe?.registry.display(resource);
|
|
53
|
+
const hidden = prefill ? Object.keys(prefill) : [];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Dialog
|
|
57
|
+
open={open}
|
|
58
|
+
onOpenChange={(next) => {
|
|
59
|
+
if (!next) setError(null);
|
|
60
|
+
onOpenChange(next);
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
|
|
64
|
+
<DialogHeader>
|
|
65
|
+
<DialogTitle>New {display?.label ?? resource}</DialogTitle>
|
|
66
|
+
<DialogDescription>
|
|
67
|
+
Create a {display?.label?.toLowerCase() ?? resource} without leaving this page.
|
|
68
|
+
</DialogDescription>
|
|
69
|
+
</DialogHeader>
|
|
70
|
+
<ResourceForm
|
|
71
|
+
resourcePath={resource}
|
|
72
|
+
initial={prefill}
|
|
73
|
+
hiddenFields={hidden}
|
|
74
|
+
submitLabel={`Create ${display?.label ?? resource}`}
|
|
75
|
+
serverError={error}
|
|
76
|
+
onCancel={() => onOpenChange(false)}
|
|
77
|
+
onSubmit={async (values) => {
|
|
78
|
+
setError(null);
|
|
79
|
+
try {
|
|
80
|
+
const created = await create.mutateAsync(values);
|
|
81
|
+
onOpenChange(false);
|
|
82
|
+
onCreated?.(created as Record<string, unknown>);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
setError(err instanceof Error ? err.message : 'Create failed');
|
|
85
|
+
}
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
</DialogContent>
|
|
89
|
+
</Dialog>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Plus } from 'lucide-react';
|
|
3
|
+
import { useDescribe } from '@davepi/ui-react';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { ResourceTable } from './ResourceTable';
|
|
6
|
+
import { RelatedCreateModal } from './RelatedCreateModal';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Embedded list of related child records.
|
|
10
|
+
*
|
|
11
|
+
* Renders an inline `<ResourceTable>` for the child resource, pre-filtered
|
|
12
|
+
* by the parent foreign key. A header button opens `<RelatedCreateModal>`
|
|
13
|
+
* with the FK already stamped, so creating a child from inside the
|
|
14
|
+
* parent's detail page is a single click — no manual id entry, no
|
|
15
|
+
* navigation away.
|
|
16
|
+
*
|
|
17
|
+
* Discovery: feed the parent's path + id + the relation edge from
|
|
18
|
+
* `SchemaRegistry.relations(parent)`. Inverse edges synthesised from FK-by-
|
|
19
|
+
* convention work without backend changes.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <RelatedList
|
|
23
|
+
* parentPath="account"
|
|
24
|
+
* parentId={account._id}
|
|
25
|
+
* target="contact"
|
|
26
|
+
* foreignKey="accountId"
|
|
27
|
+
* />
|
|
28
|
+
*/
|
|
29
|
+
export interface RelatedListProps {
|
|
30
|
+
parentPath: string;
|
|
31
|
+
parentId: string;
|
|
32
|
+
target: string;
|
|
33
|
+
foreignKey: string;
|
|
34
|
+
/** Override default columns. */
|
|
35
|
+
columns?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function RelatedList({ parentPath: _parentPath, parentId, target, foreignKey, columns }: RelatedListProps) {
|
|
39
|
+
const { data: describe } = useDescribe();
|
|
40
|
+
const [creating, setCreating] = useState(false);
|
|
41
|
+
const display = describe?.registry.display(target);
|
|
42
|
+
|
|
43
|
+
if (!describe || !display) return null;
|
|
44
|
+
|
|
45
|
+
const filter = { [foreignKey]: parentId };
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="space-y-2">
|
|
49
|
+
<header className="flex items-center justify-between">
|
|
50
|
+
<h3 className="text-base font-semibold">{display.pluralLabel}</h3>
|
|
51
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setCreating(true)}>
|
|
52
|
+
<Plus className="mr-1 h-4 w-4" />
|
|
53
|
+
New {display.label}
|
|
54
|
+
</Button>
|
|
55
|
+
</header>
|
|
56
|
+
<ResourceTable
|
|
57
|
+
resourcePath={target}
|
|
58
|
+
filters={filter}
|
|
59
|
+
columns={columns}
|
|
60
|
+
embedded
|
|
61
|
+
/>
|
|
62
|
+
<RelatedCreateModal
|
|
63
|
+
open={creating}
|
|
64
|
+
onOpenChange={setCreating}
|
|
65
|
+
resource={target}
|
|
66
|
+
prefill={filter}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { useMemo, type ComponentType, type ReactNode } from 'react';
|
|
2
|
+
import { Controller, useForm, type DefaultValues, type FieldValues } from 'react-hook-form';
|
|
3
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
4
|
+
import {
|
|
5
|
+
labelize,
|
|
6
|
+
resolveWidget,
|
|
7
|
+
zodFromDescribe,
|
|
8
|
+
type DescribeField,
|
|
9
|
+
type WidgetKind,
|
|
10
|
+
} from '@davepi/ui-core';
|
|
11
|
+
import { useAuth, useDescribe, useResourceConfig } from '@davepi/ui-react';
|
|
12
|
+
import { Button } from '@/components/ui/button';
|
|
13
|
+
import { Label } from '@/components/ui/label';
|
|
14
|
+
import { cn } from '@/lib/utils';
|
|
15
|
+
import { resolveWidgetComponent, type WidgetComponentProps } from '@/widgets/registry';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Schema-driven create/edit form.
|
|
19
|
+
*
|
|
20
|
+
* Three configuration sources compose:
|
|
21
|
+
* 1. `/_describe` — fields, types, required, ACL.
|
|
22
|
+
* 2. Consumer config (`davepi-ui.config.ts` + per-resource override file)
|
|
23
|
+
* — `formSections` (groups fields under headings), `widgets`
|
|
24
|
+
* (kind overrides keyed by field name).
|
|
25
|
+
* 3. Inline JSX props — `widgetOverrides`, `hiddenFields`, `omitFields`.
|
|
26
|
+
*
|
|
27
|
+
* Field-level ACL is enforced UI-side: fields with `acl.read` excluded
|
|
28
|
+
* from the current user's roles are dropped from the form, and fields
|
|
29
|
+
* with `acl.create`/`update` excluded render disabled. The server still
|
|
30
|
+
* enforces — this is purely declutter.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* <ResourceForm resourcePath="account" onSubmit={mutate.mutateAsync} />
|
|
34
|
+
*/
|
|
35
|
+
export interface ResourceFormProps {
|
|
36
|
+
resourcePath: string;
|
|
37
|
+
initial?: Record<string, unknown>;
|
|
38
|
+
/** Field-kind overrides keyed by field name → component. */
|
|
39
|
+
widgetOverrides?: Partial<Record<WidgetKind, ComponentType<WidgetComponentProps>>>;
|
|
40
|
+
/** Fields to render hidden + readonly (e.g. parent FK from URL). */
|
|
41
|
+
hiddenFields?: string[];
|
|
42
|
+
/** Fields to omit entirely. Defaults to server-stamped fields. */
|
|
43
|
+
omitFields?: string[];
|
|
44
|
+
onSubmit: (values: Record<string, unknown>) => Promise<unknown> | unknown;
|
|
45
|
+
onCancel?: () => void;
|
|
46
|
+
submitLabel?: string;
|
|
47
|
+
/** Top-level submission error (e.g. from server). */
|
|
48
|
+
serverError?: string | null;
|
|
49
|
+
/** Detected mode — controls whether create vs update ACL applies. */
|
|
50
|
+
mode?: 'create' | 'update';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_OMIT = new Set([
|
|
54
|
+
'_id',
|
|
55
|
+
'__v',
|
|
56
|
+
'createdAt',
|
|
57
|
+
'updatedAt',
|
|
58
|
+
'deletedAt',
|
|
59
|
+
'userId',
|
|
60
|
+
'accountId',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
interface RenderField {
|
|
64
|
+
field: DescribeField;
|
|
65
|
+
isHidden: boolean;
|
|
66
|
+
isReadOnly: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface RenderSection {
|
|
70
|
+
title?: string;
|
|
71
|
+
description?: string;
|
|
72
|
+
fields: RenderField[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function ResourceForm({
|
|
76
|
+
resourcePath,
|
|
77
|
+
initial,
|
|
78
|
+
widgetOverrides,
|
|
79
|
+
hiddenFields,
|
|
80
|
+
omitFields,
|
|
81
|
+
onSubmit,
|
|
82
|
+
onCancel,
|
|
83
|
+
submitLabel = 'Save',
|
|
84
|
+
serverError,
|
|
85
|
+
mode = initial?._id ? 'update' : 'create',
|
|
86
|
+
}: ResourceFormProps) {
|
|
87
|
+
const { data: describe } = useDescribe();
|
|
88
|
+
const config = useResourceConfig(resourcePath);
|
|
89
|
+
const { user } = useAuth();
|
|
90
|
+
const entry = describe?.registry.get(resourcePath);
|
|
91
|
+
|
|
92
|
+
const omit = useMemo(() => {
|
|
93
|
+
const base = new Set(DEFAULT_OMIT);
|
|
94
|
+
for (const f of omitFields ?? []) base.add(f);
|
|
95
|
+
return base;
|
|
96
|
+
}, [omitFields]);
|
|
97
|
+
|
|
98
|
+
const hidden = useMemo(() => new Set(hiddenFields ?? []), [hiddenFields]);
|
|
99
|
+
|
|
100
|
+
const userRoles = useMemo(() => new Set(user?.roles ?? []), [user?.roles]);
|
|
101
|
+
|
|
102
|
+
const fieldsByName = useMemo(() => {
|
|
103
|
+
const out = new Map<string, DescribeField>();
|
|
104
|
+
for (const f of entry?.fields ?? []) out.set(f.name, f);
|
|
105
|
+
return out;
|
|
106
|
+
}, [entry?.fields]);
|
|
107
|
+
|
|
108
|
+
/** Set of fields the user is allowed to read at all. */
|
|
109
|
+
const readableFields = useMemo(() => {
|
|
110
|
+
const allow = new Set<string>();
|
|
111
|
+
for (const field of entry?.fields ?? []) {
|
|
112
|
+
const required = field.acl?.read;
|
|
113
|
+
if (!required?.length || required.some((r) => userRoles.has(r))) {
|
|
114
|
+
allow.add(field.name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return allow;
|
|
118
|
+
}, [entry?.fields, userRoles]);
|
|
119
|
+
|
|
120
|
+
/** Set of fields the user can edit (write). Hidden fields always pass. */
|
|
121
|
+
const writableFields = useMemo(() => {
|
|
122
|
+
const allow = new Set<string>();
|
|
123
|
+
for (const field of entry?.fields ?? []) {
|
|
124
|
+
const op = mode === 'create' ? 'create' : 'update';
|
|
125
|
+
const required = field.acl?.[op];
|
|
126
|
+
if (!required?.length || required.some((r) => userRoles.has(r))) {
|
|
127
|
+
allow.add(field.name);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return allow;
|
|
131
|
+
}, [entry?.fields, mode, userRoles]);
|
|
132
|
+
|
|
133
|
+
const sections = useMemo<RenderSection[]>(() => {
|
|
134
|
+
if (!entry) return [];
|
|
135
|
+
const visibleFieldNames = entry.fields
|
|
136
|
+
.map((f) => f.name)
|
|
137
|
+
.filter((name) => {
|
|
138
|
+
if (hidden.has(name)) return true; // always include hidden FKs
|
|
139
|
+
if (omit.has(name)) return false;
|
|
140
|
+
if (!readableFields.has(name)) return false;
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const buildSection = (sectionFields: readonly string[]): RenderField[] => {
|
|
145
|
+
const out: RenderField[] = [];
|
|
146
|
+
for (const name of sectionFields) {
|
|
147
|
+
const f = fieldsByName.get(name);
|
|
148
|
+
if (!f) continue;
|
|
149
|
+
if (!visibleFieldNames.includes(name)) continue;
|
|
150
|
+
const isHidden = hidden.has(name);
|
|
151
|
+
const isReadOnly = isHidden || !writableFields.has(name);
|
|
152
|
+
out.push({ field: f, isHidden, isReadOnly });
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (config.formSections?.length) {
|
|
158
|
+
const placed = new Set<string>();
|
|
159
|
+
const built: RenderSection[] = [];
|
|
160
|
+
for (const section of config.formSections) {
|
|
161
|
+
const fields = buildSection(section.fields.map((f) => f.field));
|
|
162
|
+
for (const rf of fields) placed.add(rf.field.name);
|
|
163
|
+
if (fields.length) {
|
|
164
|
+
built.push({ title: section.title, description: section.description, fields });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const remaining = visibleFieldNames.filter((n) => !placed.has(n));
|
|
168
|
+
if (remaining.length) {
|
|
169
|
+
built.push({ fields: buildSection(remaining) });
|
|
170
|
+
}
|
|
171
|
+
return built;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return [{ fields: buildSection(visibleFieldNames) }];
|
|
175
|
+
}, [config.formSections, entry, fieldsByName, hidden, omit, readableFields, writableFields]);
|
|
176
|
+
|
|
177
|
+
const schema = useMemo(() => {
|
|
178
|
+
if (!entry) return null;
|
|
179
|
+
return zodFromDescribe(entry, {
|
|
180
|
+
includeStampedFields: Array.from(hidden),
|
|
181
|
+
});
|
|
182
|
+
}, [entry, hidden]);
|
|
183
|
+
|
|
184
|
+
const defaultValues = useMemo<DefaultValues<FieldValues>>(() => {
|
|
185
|
+
if (!entry) return {};
|
|
186
|
+
const out: Record<string, unknown> = {};
|
|
187
|
+
for (const f of entry.fields) {
|
|
188
|
+
if (omit.has(f.name) && !hidden.has(f.name)) continue;
|
|
189
|
+
out[f.name] = initial?.[f.name] ?? defaultForField(f.type);
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}, [entry, hidden, initial, omit]);
|
|
193
|
+
|
|
194
|
+
const form = useForm({
|
|
195
|
+
defaultValues,
|
|
196
|
+
resolver: schema ? zodResolver(schema) : undefined,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!describe || !entry || !schema) {
|
|
200
|
+
return <p className="text-muted-foreground">Loading…</p>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Combine config-level widget map with inline overrides; inline wins.
|
|
204
|
+
const fieldKindMap = config.widgets ?? {};
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<form
|
|
208
|
+
className="space-y-6"
|
|
209
|
+
onSubmit={form.handleSubmit(async (values) => {
|
|
210
|
+
await onSubmit(values);
|
|
211
|
+
})}
|
|
212
|
+
>
|
|
213
|
+
{sections.map((section, sectionIdx) => (
|
|
214
|
+
<section
|
|
215
|
+
key={section.title ?? `__section_${sectionIdx}__`}
|
|
216
|
+
className="space-y-4"
|
|
217
|
+
>
|
|
218
|
+
{section.title ? (
|
|
219
|
+
<header className="space-y-0.5">
|
|
220
|
+
<h2 className="text-base font-semibold">{section.title}</h2>
|
|
221
|
+
{section.description ? (
|
|
222
|
+
<p className="text-xs text-muted-foreground">{section.description}</p>
|
|
223
|
+
) : null}
|
|
224
|
+
</header>
|
|
225
|
+
) : null}
|
|
226
|
+
<div className="space-y-4">
|
|
227
|
+
{section.fields.map(({ field, isHidden, isReadOnly }) => {
|
|
228
|
+
const spec = resolveWidget(field, {
|
|
229
|
+
resourcePath,
|
|
230
|
+
registry: describe.registry,
|
|
231
|
+
resourceOverrides: fieldKindMap,
|
|
232
|
+
});
|
|
233
|
+
const Component = resolveWidgetComponent(spec.kind, widgetOverrides);
|
|
234
|
+
return (
|
|
235
|
+
<div key={field.name} className={cn(isHidden && 'hidden')}>
|
|
236
|
+
<Controller
|
|
237
|
+
control={form.control}
|
|
238
|
+
name={field.name}
|
|
239
|
+
render={({ field: ctrl, fieldState }) => (
|
|
240
|
+
<FormField
|
|
241
|
+
label={
|
|
242
|
+
labelize(field.name, {
|
|
243
|
+
stripIdSuffix: field.name.endsWith('Id') && field.name !== 'Id',
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
required={field.required}
|
|
247
|
+
error={fieldState.error?.message}
|
|
248
|
+
>
|
|
249
|
+
<Component
|
|
250
|
+
spec={spec}
|
|
251
|
+
id={field.name}
|
|
252
|
+
name={field.name}
|
|
253
|
+
value={ctrl.value}
|
|
254
|
+
onChange={ctrl.onChange}
|
|
255
|
+
onBlur={ctrl.onBlur}
|
|
256
|
+
readOnly={isReadOnly}
|
|
257
|
+
disabled={isReadOnly && !isHidden}
|
|
258
|
+
/>
|
|
259
|
+
</FormField>
|
|
260
|
+
)}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
})}
|
|
265
|
+
</div>
|
|
266
|
+
</section>
|
|
267
|
+
))}
|
|
268
|
+
{serverError ? (
|
|
269
|
+
<p role="alert" className="text-sm text-destructive">
|
|
270
|
+
{serverError}
|
|
271
|
+
</p>
|
|
272
|
+
) : null}
|
|
273
|
+
<div className="flex items-center justify-end gap-2">
|
|
274
|
+
{onCancel ? (
|
|
275
|
+
<Button type="button" variant="outline" onClick={onCancel}>
|
|
276
|
+
Cancel
|
|
277
|
+
</Button>
|
|
278
|
+
) : null}
|
|
279
|
+
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
280
|
+
{form.formState.isSubmitting ? 'Saving…' : submitLabel}
|
|
281
|
+
</Button>
|
|
282
|
+
</div>
|
|
283
|
+
</form>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface FormFieldProps {
|
|
288
|
+
label: string;
|
|
289
|
+
required?: boolean;
|
|
290
|
+
error?: string;
|
|
291
|
+
children: ReactNode;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function FormField({ label, required, error, children }: FormFieldProps) {
|
|
295
|
+
return (
|
|
296
|
+
<div className="flex flex-col gap-2">
|
|
297
|
+
<Label>
|
|
298
|
+
{label}
|
|
299
|
+
{required ? <span className="ml-0.5 text-destructive">*</span> : null}
|
|
300
|
+
</Label>
|
|
301
|
+
{children}
|
|
302
|
+
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function defaultForField(type: string): unknown {
|
|
308
|
+
if (type === 'Boolean') return false;
|
|
309
|
+
if (type.startsWith('[')) return [];
|
|
310
|
+
return '';
|
|
311
|
+
}
|