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:
2026-02-11 10:14:00 -06:00
parent b3c748ef10
commit 4edaa35c49
9 changed files with 1084 additions and 193 deletions

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -195,10 +196,35 @@ func (s *Server) HandleGetSchema(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, schemaToResponse(sch))
}
// HandleGetPropertySchema returns the property schema for a category.
func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request) {
// FormFieldDescriptor describes a single field in the form descriptor response.
type FormFieldDescriptor struct {
Name string `json:"name"`
Type string `json:"type"`
Widget string `json:"widget,omitempty"`
Label string `json:"label"`
Required bool `json:"required,omitempty"`
Default any `json:"default,omitempty"`
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
Options []string `json:"options,omitempty"`
Currency string `json:"currency,omitempty"`
// Item-field specific
DerivedFromCategory map[string]string `json:"derived_from_category,omitempty"`
SearchEndpoint string `json:"search_endpoint,omitempty"`
}
// FormFieldGroupDescriptor describes an ordered group of resolved fields.
type FormFieldGroupDescriptor struct {
Key string `json:"key"`
Label string `json:"label"`
Order int `json:"order"`
Fields []FormFieldDescriptor `json:"fields"`
}
// HandleGetFormDescriptor returns the full form descriptor for a schema.
func (s *Server) HandleGetFormDescriptor(w http.ResponseWriter, r *http.Request) {
schemaName := chi.URLParam(r, "name")
category := r.URL.Query().Get("category")
sch, ok := s.schemas[schemaName]
if !ok {
@@ -206,19 +232,194 @@ func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request)
return
}
if sch.PropertySchemas == nil {
writeJSON(w, http.StatusOK, map[string]any{
"version": 0,
"properties": map[string]any{},
})
return
result := map[string]any{
"schema_name": sch.Name,
"format": sch.Format,
}
props := sch.PropertySchemas.GetPropertiesForCategory(category)
writeJSON(w, http.StatusOK, map[string]any{
"version": sch.PropertySchemas.Version,
"properties": props,
// Category picker with auto-derived values_by_domain
if sch.UI != nil && sch.UI.CategoryPicker != nil {
picker := map[string]any{
"style": sch.UI.CategoryPicker.Style,
}
vbd := sch.ValuesByDomain()
stages := make([]map[string]any, 0, len(sch.UI.CategoryPicker.Stages)+1)
for _, stage := range sch.UI.CategoryPicker.Stages {
stg := map[string]any{
"name": stage.Name,
"label": stage.Label,
}
if stage.Values != nil {
stg["values"] = stage.Values
}
stages = append(stages, stg)
}
// Auto-add subcategory stage from values_by_domain
if vbd != nil {
stages = append(stages, map[string]any{
"name": "subcategory",
"label": "Type",
"values_by_domain": vbd,
})
}
picker["stages"] = stages
result["category_picker"] = picker
}
// Item fields
if sch.UI != nil && sch.UI.ItemFields != nil {
result["item_fields"] = sch.UI.ItemFields
}
// Resolve field groups into ordered list with full field metadata
if sch.UI != nil && sch.UI.FieldGroups != nil {
groups := s.resolveFieldGroups(sch, sch.UI.FieldGroups)
result["field_groups"] = groups
}
// Category field groups
if sch.UI != nil && sch.UI.CategoryFieldGroups != nil {
catGroups := make(map[string][]FormFieldGroupDescriptor)
for prefix, groups := range sch.UI.CategoryFieldGroups {
catGroups[prefix] = s.resolveCategoryFieldGroups(sch, prefix, groups)
}
result["category_field_groups"] = catGroups
}
// Field overrides (pass through)
if sch.UI != nil && sch.UI.FieldOverrides != nil {
result["field_overrides"] = sch.UI.FieldOverrides
}
writeJSON(w, http.StatusOK, result)
}
// resolveFieldGroups converts field group definitions into fully resolved descriptors.
func (s *Server) resolveFieldGroups(sch *schema.Schema, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor {
result := make([]FormFieldGroupDescriptor, 0, len(groups))
for key, group := range groups {
desc := FormFieldGroupDescriptor{
Key: key,
Label: group.Label,
Order: group.Order,
}
for _, fieldName := range group.Fields {
fd := s.resolveField(sch, fieldName)
desc.Fields = append(desc.Fields, fd)
}
result = append(result, desc)
}
// Sort by order
sort.Slice(result, func(i, j int) bool {
return result[i].Order < result[j].Order
})
return result
}
// resolveCategoryFieldGroups resolves category-specific field groups.
func (s *Server) resolveCategoryFieldGroups(sch *schema.Schema, prefix string, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor {
result := make([]FormFieldGroupDescriptor, 0, len(groups))
for key, group := range groups {
desc := FormFieldGroupDescriptor{
Key: key,
Label: group.Label,
Order: group.Order,
}
for _, fieldName := range group.Fields {
fd := s.resolveCategoryField(sch, prefix, fieldName)
desc.Fields = append(desc.Fields, fd)
}
result = append(result, desc)
}
sort.Slice(result, func(i, j int) bool {
return result[i].Order < result[j].Order
})
return result
}
// resolveField builds a FormFieldDescriptor from item_fields or property_schemas.defaults.
func (s *Server) resolveField(sch *schema.Schema, name string) FormFieldDescriptor {
fd := FormFieldDescriptor{Name: name}
// Check item_fields first
if sch.UI != nil && sch.UI.ItemFields != nil {
if def, ok := sch.UI.ItemFields[name]; ok {
fd.Type = def.Type
fd.Widget = def.Widget
fd.Label = def.Label
fd.Required = def.Required
fd.Default = def.Default
fd.Options = def.Options
fd.DerivedFromCategory = def.DerivedFromCategory
fd.SearchEndpoint = def.SearchEndpoint
s.applyOverrides(sch, name, &fd)
return fd
}
}
// Check property_schemas.defaults
if sch.PropertySchemas != nil && sch.PropertySchemas.Defaults != nil {
if def, ok := sch.PropertySchemas.Defaults[name]; ok {
fd.Type = def.Type
fd.Label = name // Use field name as label if not overridden
fd.Default = def.Default
fd.Unit = def.Unit
fd.Description = def.Description
fd.Required = def.Required
s.applyOverrides(sch, name, &fd)
return fd
}
}
// Fallback — field name only
fd.Label = name
fd.Type = "string"
s.applyOverrides(sch, name, &fd)
return fd
}
// resolveCategoryField builds a FormFieldDescriptor from category-specific property schema.
func (s *Server) resolveCategoryField(sch *schema.Schema, prefix, name string) FormFieldDescriptor {
fd := FormFieldDescriptor{Name: name, Label: name, Type: "string"}
if sch.PropertySchemas != nil {
if catProps, ok := sch.PropertySchemas.Categories[prefix]; ok {
if def, ok := catProps[name]; ok {
fd.Type = def.Type
fd.Default = def.Default
fd.Unit = def.Unit
fd.Description = def.Description
fd.Required = def.Required
}
}
}
s.applyOverrides(sch, name, &fd)
return fd
}
// applyOverrides applies field_overrides to a field descriptor.
func (s *Server) applyOverrides(sch *schema.Schema, name string, fd *FormFieldDescriptor) {
if sch.UI == nil || sch.UI.FieldOverrides == nil {
return
}
ov, ok := sch.UI.FieldOverrides[name]
if !ok {
return
}
if ov.Widget != "" {
fd.Widget = ov.Widget
}
if ov.Currency != "" {
fd.Currency = ov.Currency
}
if len(ov.Options) > 0 {
fd.Options = ov.Options
}
}
func schemaToResponse(sch *schema.Schema) SchemaResponse {

View File

@@ -86,7 +86,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Route("/schemas", func(r chi.Router) {
r.Get("/", server.HandleListSchemas)
r.Get("/{name}", server.HandleGetSchema)
r.Get("/{name}/properties", server.HandleGetPropertySchema)
r.Get("/{name}/form", server.HandleGetFormDescriptor)
r.Group(func(r chi.Router) {
r.Use(server.RequireWritable)