feat: schema-driven form descriptor API and dynamic form rendering
- Add ui section to kindred-rd.yaml with category_picker (multi-stage),
item_fields, field_groups, category_field_groups, and field_overrides
- Add UIConfig structs to Go schema parser with full YAML/JSON tags
- Add ValidateUI() to validate field references against property schemas
- Add ValuesByDomain() helper to auto-derive subcategory picker stages
- Implement GET /api/schemas/{name}/form endpoint that returns resolved
form descriptor with field metadata, widget hints, and category picker
- Replace GET /api/schemas/{name}/properties route with /form
- Add FormDescriptor TypeScript types
- Create useFormDescriptor hook (replaces useCategories)
- Rewrite CreateItemPane to render all sections dynamically from descriptor
- Update CategoryPicker with multi-stage domain/subcategory selection
- Delete useCategories.ts (superseded by useFormDescriptor)
This commit is contained in:
@@ -1,21 +1,48 @@
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import type { CategoryPickerStage } from "../../api/types";
|
||||
|
||||
interface CategoryPickerProps {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
categories: Record<string, string>;
|
||||
stages?: CategoryPickerStage[];
|
||||
}
|
||||
|
||||
export function CategoryPicker({
|
||||
value,
|
||||
onChange,
|
||||
categories,
|
||||
stages,
|
||||
}: CategoryPickerProps) {
|
||||
const [selectedDomain, setSelectedDomain] = useState<string>("");
|
||||
const [search, setSearch] = useState("");
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Derive domain from current value
|
||||
useEffect(() => {
|
||||
if (value && value.length > 0) {
|
||||
setSelectedDomain(value[0]!);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const isMultiStage = stages && stages.length >= 2;
|
||||
|
||||
// Domain stage (first stage)
|
||||
const domainStage = isMultiStage ? stages[0] : undefined;
|
||||
const subcatStage = isMultiStage
|
||||
? stages.find((s) => s.values_by_domain)
|
||||
: undefined;
|
||||
|
||||
// Filtered categories for current domain in multi-stage mode
|
||||
const filteredCategories = useMemo(() => {
|
||||
if (!isMultiStage || !selectedDomain || !subcatStage?.values_by_domain) {
|
||||
return categories;
|
||||
}
|
||||
return subcatStage.values_by_domain[selectedDomain] ?? {};
|
||||
}, [isMultiStage, selectedDomain, subcatStage, categories]);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = Object.entries(categories).sort(([a], [b]) =>
|
||||
const all = Object.entries(filteredCategories).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
if (!search) return all;
|
||||
@@ -24,7 +51,7 @@ export function CategoryPicker({
|
||||
([code, desc]) =>
|
||||
code.toLowerCase().includes(q) || desc.toLowerCase().includes(q),
|
||||
);
|
||||
}, [categories, search]);
|
||||
}, [filteredCategories, search]);
|
||||
|
||||
// Scroll selected into view on mount.
|
||||
useEffect(() => {
|
||||
@@ -40,12 +67,70 @@ export function CategoryPicker({
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Multi-stage domain picker */}
|
||||
{isMultiStage && domainStage?.values && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.25rem",
|
||||
padding: "0.4rem 0.5rem",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
}}
|
||||
>
|
||||
{Object.entries(domainStage.values)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([code, label]) => {
|
||||
const isActive = code === selectedDomain;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => {
|
||||
setSelectedDomain(code);
|
||||
setSearch("");
|
||||
// Clear selection if switching domain
|
||||
if (value && value[0] !== code) {
|
||||
onChange("");
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: "0.2rem 0.5rem",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
border: "none",
|
||||
borderRadius: "0.25rem",
|
||||
cursor: "pointer",
|
||||
backgroundColor: isActive
|
||||
? "rgba(203,166,247,0.2)"
|
||||
: "transparent",
|
||||
color: isActive
|
||||
? "var(--ctp-mauve)"
|
||||
: "var(--ctp-subtext0)",
|
||||
transition: "background-color 0.1s",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{code}
|
||||
</span>{" "}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search categories..."
|
||||
placeholder={
|
||||
isMultiStage && !selectedDomain
|
||||
? "Select a domain above..."
|
||||
: "Search categories..."
|
||||
}
|
||||
disabled={isMultiStage && !selectedDomain}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.4rem 0.5rem",
|
||||
@@ -61,7 +146,18 @@ export function CategoryPicker({
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div style={{ maxHeight: 200, overflowY: "auto" }}>
|
||||
{entries.length === 0 ? (
|
||||
{isMultiStage && !selectedDomain ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
textAlign: "center",
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
Select a domain to see categories
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
@@ -90,9 +186,7 @@ export function CategoryPicker({
|
||||
backgroundColor: isSelected
|
||||
? "rgba(203,166,247,0.12)"
|
||||
: "transparent",
|
||||
color: isSelected
|
||||
? "var(--ctp-mauve)"
|
||||
: "var(--ctp-text)",
|
||||
color: isSelected ? "var(--ctp-mauve)" : "var(--ctp-text)",
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
transition: "background-color 0.1s",
|
||||
}}
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { get, post, put } from "../../api/client";
|
||||
import type { Project } from "../../api/types";
|
||||
import type {
|
||||
Project,
|
||||
FormFieldDescriptor,
|
||||
FormFieldGroup,
|
||||
} from "../../api/types";
|
||||
import { TagInput, type TagOption } from "../TagInput";
|
||||
import { CategoryPicker } from "./CategoryPicker";
|
||||
import { FileDropZone } from "./FileDropZone";
|
||||
import { useCategories } from "../../hooks/useCategories";
|
||||
import { useFormDescriptor } from "../../hooks/useFormDescriptor";
|
||||
import {
|
||||
useFileUpload,
|
||||
type PendingAttachment,
|
||||
} from "../../hooks/useFileUpload";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
|
||||
// Item-level field names that are sent as top-level API fields, not properties.
|
||||
const ITEM_LEVEL_FIELDS = new Set([
|
||||
"item_type",
|
||||
"description",
|
||||
"sourcing_type",
|
||||
"long_description",
|
||||
"projects",
|
||||
]);
|
||||
|
||||
interface CreateItemPaneProps {
|
||||
onCreated: (partNumber: string) => void;
|
||||
onCancel: () => void;
|
||||
@@ -18,20 +31,13 @@ interface CreateItemPaneProps {
|
||||
|
||||
export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const { user } = useAuth();
|
||||
const { categories } = useCategories();
|
||||
const { descriptor, categories } = useFormDescriptor();
|
||||
const { upload } = useFileUpload();
|
||||
|
||||
// Form state.
|
||||
const [itemType, setItemType] = useState("part");
|
||||
// Single form state for all fields (item-level + properties).
|
||||
const [category, setCategory] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sourcingType, setSourcingType] = useState("manufactured");
|
||||
const [longDescription, setLongDescription] = useState("");
|
||||
const [fields, setFields] = useState<Record<string, string>>({});
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||
const [catProps, setCatProps] = useState<Record<string, string>>({});
|
||||
const [catPropDefs, setCatPropDefs] = useState<
|
||||
Record<string, { type: string }>
|
||||
>({});
|
||||
|
||||
// Attachments.
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
@@ -42,27 +48,33 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load category-specific properties.
|
||||
useEffect(() => {
|
||||
if (!category) {
|
||||
setCatPropDefs({});
|
||||
setCatProps({});
|
||||
return;
|
||||
const setField = (name: string, value: string) =>
|
||||
setFields((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
const getField = (name: string) => fields[name] ?? "";
|
||||
|
||||
// Derive item_type from category using derived_from_category mapping
|
||||
const deriveItemType = (cat: string): string => {
|
||||
if (!cat || !descriptor?.item_fields?.item_type?.derived_from_category) {
|
||||
return getField("item_type") || "part";
|
||||
}
|
||||
get<Record<string, { type: string }>>(
|
||||
`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`,
|
||||
)
|
||||
.then((defs) => {
|
||||
setCatPropDefs(defs);
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const key of Object.keys(defs)) defaults[key] = "";
|
||||
setCatProps(defaults);
|
||||
})
|
||||
.catch(() => {
|
||||
setCatPropDefs({});
|
||||
setCatProps({});
|
||||
});
|
||||
}, [category]);
|
||||
const mapping = descriptor.item_fields.item_type.derived_from_category;
|
||||
const prefix = cat[0]!;
|
||||
return mapping[prefix] ?? mapping["default"] ?? "part";
|
||||
};
|
||||
|
||||
const handleCategoryChange = (cat: string) => {
|
||||
setCategory(cat);
|
||||
// Auto-derive item_type when category changes
|
||||
if (descriptor?.item_fields?.item_type?.derived_from_category) {
|
||||
const derived = cat
|
||||
? (descriptor.item_fields.item_type.derived_from_category[cat[0]!] ??
|
||||
descriptor.item_fields.item_type.derived_from_category["default"] ??
|
||||
"part")
|
||||
: "part";
|
||||
setField("item_type", derived);
|
||||
}
|
||||
};
|
||||
|
||||
const searchProjects = useCallback(
|
||||
async (query: string): Promise<TagOption[]> => {
|
||||
@@ -88,10 +100,8 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const startIdx = attachments.length;
|
||||
setAttachments((prev) => [...prev, ...files]);
|
||||
|
||||
// Upload each file.
|
||||
files.forEach((f, i) => {
|
||||
const idx = startIdx + i;
|
||||
// Mark uploading.
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, j) =>
|
||||
j === idx ? { ...a, uploadStatus: "uploading" } : a,
|
||||
@@ -153,12 +163,15 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
// Split fields into item-level and properties
|
||||
const properties: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(catProps)) {
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (!v) continue;
|
||||
const def = catPropDefs[k];
|
||||
if (def?.type === "number") properties[k] = Number(v);
|
||||
else if (def?.type === "boolean") properties[k] = v === "true";
|
||||
if (ITEM_LEVEL_FIELDS.has(k)) continue; // handled separately
|
||||
// Coerce type from descriptor
|
||||
const fieldDef = findFieldDef(k);
|
||||
if (fieldDef?.type === "number") properties[k] = Number(v);
|
||||
else if (fieldDef?.type === "boolean") properties[k] = v === "true";
|
||||
else properties[k] = v;
|
||||
}
|
||||
|
||||
@@ -166,12 +179,12 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const result = await post<{ part_number: string }>("/api/items", {
|
||||
schema: "kindred-rd",
|
||||
category,
|
||||
description,
|
||||
item_type: itemType,
|
||||
description: getField("description") || undefined,
|
||||
item_type: deriveItemType(category),
|
||||
projects: selectedProjects.length > 0 ? selectedProjects : undefined,
|
||||
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
||||
sourcing_type: sourcingType || undefined,
|
||||
long_description: longDescription || undefined,
|
||||
sourcing_type: getField("sourcing_type") || undefined,
|
||||
long_description: getField("long_description") || undefined,
|
||||
});
|
||||
|
||||
const pn = result.part_number;
|
||||
@@ -215,6 +228,33 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Find field definition from descriptor (global groups + category groups).
|
||||
function findFieldDef(name: string): FormFieldDescriptor | undefined {
|
||||
if (descriptor?.field_groups) {
|
||||
for (const group of descriptor.field_groups) {
|
||||
const f = group.fields.find((fd) => fd.name === name);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
if (descriptor?.category_field_groups && category) {
|
||||
const prefix = category[0]!;
|
||||
const catGroups = descriptor.category_field_groups[prefix];
|
||||
if (catGroups) {
|
||||
for (const group of catGroups) {
|
||||
const f = group.fields.find((fd) => fd.name === name);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get category-specific field groups for the selected category.
|
||||
const catFieldGroups: FormFieldGroup[] =
|
||||
category && descriptor?.category_field_groups
|
||||
? (descriptor.category_field_groups[category[0]!] ?? [])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header */}
|
||||
@@ -258,110 +298,52 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
<div style={{ overflow: "auto", padding: "0.75rem" }}>
|
||||
{error && <div style={errorStyle}>{error}</div>}
|
||||
|
||||
{/* Identity section */}
|
||||
<SectionHeader>Identity</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
<FormGroup label="Type *">
|
||||
<select
|
||||
value={itemType}
|
||||
onChange={(e) => setItemType(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="part">Part</option>
|
||||
<option value="assembly">Assembly</option>
|
||||
<option value="consumable">Consumable</option>
|
||||
<option value="tool">Tool</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
<FormGroup label="Description">
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="Item description"
|
||||
/>
|
||||
</FormGroup>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label="Category *">
|
||||
<CategoryPicker
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
categories={categories}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
{/* Category picker */}
|
||||
<SectionHeader>Category *</SectionHeader>
|
||||
<CategoryPicker
|
||||
value={category}
|
||||
onChange={handleCategoryChange}
|
||||
categories={categories}
|
||||
stages={descriptor?.category_picker?.stages}
|
||||
/>
|
||||
|
||||
{/* Sourcing section */}
|
||||
<SectionHeader>Sourcing</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
<FormGroup label="Sourcing Type">
|
||||
<select
|
||||
value={sourcingType}
|
||||
onChange={(e) => setSourcingType(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="manufactured">Manufactured</option>
|
||||
<option value="purchased">Purchased</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
{/* Details section */}
|
||||
<SectionHeader>Details</SectionHeader>
|
||||
<FormGroup label="Long Description">
|
||||
<textarea
|
||||
value={longDescription}
|
||||
onChange={(e) => setLongDescription(e.target.value)}
|
||||
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
|
||||
placeholder="Detailed description..."
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Projects">
|
||||
<TagInput
|
||||
value={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
placeholder="Search projects\u2026"
|
||||
searchFn={searchProjects}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{/* Category properties */}
|
||||
{Object.keys(catPropDefs).length > 0 && (
|
||||
<>
|
||||
<SectionHeader>
|
||||
{categories[category] ?? category} Properties
|
||||
</SectionHeader>
|
||||
{/* Dynamic field groups from descriptor */}
|
||||
{descriptor?.field_groups?.map((group) => (
|
||||
<div key={group.key}>
|
||||
<SectionHeader>{group.label}</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
{Object.entries(catPropDefs).map(([key, def]) => (
|
||||
<FormGroup key={key} label={key}>
|
||||
{def.type === "boolean" ? (
|
||||
<select
|
||||
value={catProps[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setCatProps({ ...catProps, [key]: e.target.value })
|
||||
}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">---</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={def.type === "number" ? "number" : "text"}
|
||||
value={catProps[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setCatProps({ ...catProps, [key]: e.target.value })
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
))}
|
||||
{group.fields.map((field) =>
|
||||
renderField(
|
||||
field,
|
||||
getField(field.name),
|
||||
(v) => setField(field.name, v),
|
||||
selectedProjects,
|
||||
setSelectedProjects,
|
||||
searchProjects,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Category-specific field groups */}
|
||||
{catFieldGroups.map((group) => (
|
||||
<div key={group.key}>
|
||||
<SectionHeader>{group.label}</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
{group.fields.map((field) =>
|
||||
renderField(
|
||||
field,
|
||||
getField(field.name),
|
||||
(v) => setField(field.name, v),
|
||||
selectedProjects,
|
||||
setSelectedProjects,
|
||||
searchProjects,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: sidebar */}
|
||||
@@ -437,6 +419,138 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Field renderer ---
|
||||
|
||||
function renderField(
|
||||
field: FormFieldDescriptor,
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
selectedProjects: string[],
|
||||
setSelectedProjects: (v: string[]) => void,
|
||||
searchProjects: (q: string) => Promise<{ id: string; label: string }[]>,
|
||||
) {
|
||||
const widget =
|
||||
field.widget ?? (field.type === "boolean" ? "checkbox" : "text");
|
||||
|
||||
// Projects field gets special tag_input treatment
|
||||
if (widget === "tag_input") {
|
||||
return (
|
||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label={field.label}>
|
||||
<TagInput
|
||||
value={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
placeholder="Search projects\u2026"
|
||||
searchFn={searchProjects}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "textarea") {
|
||||
return (
|
||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label={field.label}>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
|
||||
placeholder={field.description ?? ""}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "select" && field.options) {
|
||||
return (
|
||||
<FormGroup key={field.name} label={field.label}>
|
||||
<select
|
||||
value={value || (field.default != null ? String(field.default) : "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
{!field.required && <option value="">---</option>}
|
||||
{field.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "checkbox") {
|
||||
return (
|
||||
<FormGroup key={field.name} label={field.label}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">---</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "currency") {
|
||||
return (
|
||||
<FormGroup
|
||||
key={field.name}
|
||||
label={`${field.label}${field.currency ? ` (${field.currency})` : ""}`}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget === "url") {
|
||||
return (
|
||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label={field.label}>
|
||||
<input
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: text or number input
|
||||
const inputType = field.type === "number" ? "number" : "text";
|
||||
const placeholder = field.unit
|
||||
? `${field.description ?? ""} (${field.unit})`
|
||||
: (field.description ?? "");
|
||||
|
||||
return (
|
||||
<FormGroup key={field.name} label={field.label}>
|
||||
<input
|
||||
type={inputType}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
|
||||
Reference in New Issue
Block a user