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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,6 +22,55 @@ type Schema struct {
|
||||
Format string `yaml:"format"`
|
||||
Examples []string `yaml:"examples"`
|
||||
PropertySchemas *PropertySchemas `yaml:"property_schemas,omitempty"`
|
||||
UI *UIConfig `yaml:"ui,omitempty" json:"ui,omitempty"`
|
||||
}
|
||||
|
||||
// UIConfig defines form layout for all clients.
|
||||
type UIConfig struct {
|
||||
CategoryPicker *CategoryPickerConfig `yaml:"category_picker,omitempty" json:"category_picker,omitempty"`
|
||||
ItemFields map[string]ItemFieldDef `yaml:"item_fields,omitempty" json:"item_fields,omitempty"`
|
||||
FieldGroups map[string]FieldGroup `yaml:"field_groups,omitempty" json:"field_groups"`
|
||||
CategoryFieldGroups map[string]map[string]FieldGroup `yaml:"category_field_groups,omitempty" json:"category_field_groups,omitempty"`
|
||||
FieldOverrides map[string]FieldOverride `yaml:"field_overrides,omitempty" json:"field_overrides,omitempty"`
|
||||
}
|
||||
|
||||
// CategoryPickerConfig defines how the category picker is rendered.
|
||||
type CategoryPickerConfig struct {
|
||||
Style string `yaml:"style" json:"style"`
|
||||
Stages []CategoryPickerStage `yaml:"stages" json:"stages"`
|
||||
}
|
||||
|
||||
// CategoryPickerStage defines one stage of a multi-stage category picker.
|
||||
type CategoryPickerStage struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Values map[string]string `yaml:"values,omitempty" json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// ItemFieldDef defines a field stored on the items table (not in revision properties).
|
||||
type ItemFieldDef struct {
|
||||
Type string `yaml:"type" json:"type"`
|
||||
Widget string `yaml:"widget" json:"widget"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
|
||||
Default any `yaml:"default,omitempty" json:"default,omitempty"`
|
||||
Options []string `yaml:"options,omitempty" json:"options,omitempty"`
|
||||
DerivedFromCategory map[string]string `yaml:"derived_from_category,omitempty" json:"derived_from_category,omitempty"`
|
||||
SearchEndpoint string `yaml:"search_endpoint,omitempty" json:"search_endpoint,omitempty"`
|
||||
}
|
||||
|
||||
// FieldGroup defines an ordered group of fields for form layout.
|
||||
type FieldGroup struct {
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Order int `yaml:"order" json:"order"`
|
||||
Fields []string `yaml:"fields" json:"fields"`
|
||||
}
|
||||
|
||||
// FieldOverride overrides display hints for a field.
|
||||
type FieldOverride struct {
|
||||
Widget string `yaml:"widget,omitempty" json:"widget,omitempty"`
|
||||
Currency string `yaml:"currency,omitempty" json:"currency,omitempty"`
|
||||
Options []string `yaml:"options,omitempty" json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// PropertySchemas defines property schemas per category.
|
||||
@@ -180,6 +229,10 @@ func (s *Schema) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ValidateUI(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -224,6 +277,112 @@ func (seg *Segment) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValuesByDomain groups category enum values by their first character (domain prefix).
|
||||
func (s *Schema) ValuesByDomain() map[string]map[string]string {
|
||||
catSeg := s.GetSegment("category")
|
||||
if catSeg == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]map[string]string)
|
||||
for code, desc := range catSeg.Values {
|
||||
if len(code) == 0 {
|
||||
continue
|
||||
}
|
||||
domain := string(code[0])
|
||||
if result[domain] == nil {
|
||||
result[domain] = make(map[string]string)
|
||||
}
|
||||
result[domain][code] = desc
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateUI validates the UI configuration against property schemas and segments.
|
||||
func (s *Schema) ValidateUI() error {
|
||||
if s.UI == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a set of all known fields (item_fields + property defaults)
|
||||
knownGlobal := make(map[string]bool)
|
||||
if s.UI.ItemFields != nil {
|
||||
for k := range s.UI.ItemFields {
|
||||
knownGlobal[k] = true
|
||||
}
|
||||
}
|
||||
if s.PropertySchemas != nil {
|
||||
for k := range s.PropertySchemas.Defaults {
|
||||
knownGlobal[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field_groups: every field must be a known global field
|
||||
for groupKey, group := range s.UI.FieldGroups {
|
||||
for _, field := range group.Fields {
|
||||
if !knownGlobal[field] {
|
||||
return fmt.Errorf("ui.field_groups.%s: field %q not found in item_fields or property_schemas.defaults", groupKey, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate category_field_groups: every field must exist in the category's property schema
|
||||
if s.PropertySchemas != nil {
|
||||
for prefix, groups := range s.UI.CategoryFieldGroups {
|
||||
catProps := s.PropertySchemas.Categories[prefix]
|
||||
for groupKey, group := range groups {
|
||||
for _, field := range group.Fields {
|
||||
if catProps == nil {
|
||||
return fmt.Errorf("ui.category_field_groups.%s.%s: category prefix %q has no property schema", prefix, groupKey, prefix)
|
||||
}
|
||||
if _, ok := catProps[field]; !ok {
|
||||
return fmt.Errorf("ui.category_field_groups.%s.%s: field %q not found in property_schemas.categories.%s", prefix, groupKey, field, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field_overrides: keys must be known fields
|
||||
for key := range s.UI.FieldOverrides {
|
||||
if !knownGlobal[key] {
|
||||
// Also check category-level properties
|
||||
found := false
|
||||
if s.PropertySchemas != nil {
|
||||
for _, catProps := range s.PropertySchemas.Categories {
|
||||
if _, ok := catProps[key]; ok {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("ui.field_overrides: field %q not found in any property schema", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate category_picker stages: first stage values must be valid domain prefixes
|
||||
if s.UI.CategoryPicker != nil && len(s.UI.CategoryPicker.Stages) > 0 {
|
||||
catSeg := s.GetSegment("category")
|
||||
if catSeg != nil {
|
||||
validPrefixes := make(map[string]bool)
|
||||
for code := range catSeg.Values {
|
||||
if len(code) > 0 {
|
||||
validPrefixes[string(code[0])] = true
|
||||
}
|
||||
}
|
||||
firstStage := s.UI.CategoryPicker.Stages[0]
|
||||
for key := range firstStage.Values {
|
||||
if !validPrefixes[key] {
|
||||
return fmt.Errorf("ui.category_picker.stages[0]: value %q is not a valid category prefix", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSegment returns a segment by name.
|
||||
func (s *Schema) GetSegment(name string) *Segment {
|
||||
for i := range s.Segments {
|
||||
|
||||
Reference in New Issue
Block a user