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"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -195,10 +196,35 @@ func (s *Server) HandleGetSchema(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, schemaToResponse(sch))
|
writeJSON(w, http.StatusOK, schemaToResponse(sch))
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGetPropertySchema returns the property schema for a category.
|
// FormFieldDescriptor describes a single field in the form descriptor response.
|
||||||
func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request) {
|
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")
|
schemaName := chi.URLParam(r, "name")
|
||||||
category := r.URL.Query().Get("category")
|
|
||||||
|
|
||||||
sch, ok := s.schemas[schemaName]
|
sch, ok := s.schemas[schemaName]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -206,19 +232,194 @@ func (s *Server) HandleGetPropertySchema(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if sch.PropertySchemas == nil {
|
result := map[string]any{
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
"schema_name": sch.Name,
|
||||||
"version": 0,
|
"format": sch.Format,
|
||||||
"properties": map[string]any{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
props := sch.PropertySchemas.GetPropertiesForCategory(category)
|
// Category picker with auto-derived values_by_domain
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
if sch.UI != nil && sch.UI.CategoryPicker != nil {
|
||||||
"version": sch.PropertySchemas.Version,
|
picker := map[string]any{
|
||||||
"properties": props,
|
"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 {
|
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.Route("/schemas", func(r chi.Router) {
|
||||||
r.Get("/", server.HandleListSchemas)
|
r.Get("/", server.HandleListSchemas)
|
||||||
r.Get("/{name}", server.HandleGetSchema)
|
r.Get("/{name}", server.HandleGetSchema)
|
||||||
r.Get("/{name}/properties", server.HandleGetPropertySchema)
|
r.Get("/{name}/form", server.HandleGetFormDescriptor)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(server.RequireWritable)
|
r.Use(server.RequireWritable)
|
||||||
|
|||||||
@@ -22,6 +22,55 @@ type Schema struct {
|
|||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
Examples []string `yaml:"examples"`
|
Examples []string `yaml:"examples"`
|
||||||
PropertySchemas *PropertySchemas `yaml:"property_schemas,omitempty"`
|
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.
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +277,112 @@ func (seg *Segment) Validate() error {
|
|||||||
return nil
|
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.
|
// GetSegment returns a segment by name.
|
||||||
func (s *Schema) GetSegment(name string) *Segment {
|
func (s *Schema) GetSegment(name string) *Segment {
|
||||||
for i := range s.Segments {
|
for i := range s.Segments {
|
||||||
|
|||||||
@@ -846,3 +846,255 @@ schema:
|
|||||||
type: string
|
type: string
|
||||||
default: ""
|
default: ""
|
||||||
description: "Inspection/QC requirements"
|
description: "Inspection/QC requirements"
|
||||||
|
|
||||||
|
# UI configuration — drives form rendering for all clients.
|
||||||
|
ui:
|
||||||
|
category_picker:
|
||||||
|
style: multi_stage
|
||||||
|
stages:
|
||||||
|
- name: domain
|
||||||
|
label: "Domain"
|
||||||
|
values:
|
||||||
|
F: "Fasteners"
|
||||||
|
C: "Fluid Fittings"
|
||||||
|
R: "Motion"
|
||||||
|
S: "Structural"
|
||||||
|
E: "Electrical"
|
||||||
|
M: "Mechanical"
|
||||||
|
T: "Tooling"
|
||||||
|
A: "Assemblies"
|
||||||
|
P: "Purchased"
|
||||||
|
X: "Custom Fabricated"
|
||||||
|
|
||||||
|
# Item-level fields (stored on items table, not in revision properties)
|
||||||
|
item_fields:
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
widget: text
|
||||||
|
label: "Description"
|
||||||
|
item_type:
|
||||||
|
type: string
|
||||||
|
widget: select
|
||||||
|
label: "Type"
|
||||||
|
options: [part, assembly, consumable, tool]
|
||||||
|
derived_from_category:
|
||||||
|
A: assembly
|
||||||
|
T: tool
|
||||||
|
default: part
|
||||||
|
sourcing_type:
|
||||||
|
type: string
|
||||||
|
widget: select
|
||||||
|
label: "Sourcing Type"
|
||||||
|
options: [manufactured, purchased]
|
||||||
|
default: manufactured
|
||||||
|
long_description:
|
||||||
|
type: string
|
||||||
|
widget: textarea
|
||||||
|
label: "Long Description"
|
||||||
|
projects:
|
||||||
|
type: string_array
|
||||||
|
widget: tag_input
|
||||||
|
label: "Projects"
|
||||||
|
search_endpoint: "/api/projects"
|
||||||
|
|
||||||
|
field_groups:
|
||||||
|
identity:
|
||||||
|
label: "Identity"
|
||||||
|
order: 1
|
||||||
|
fields: [item_type, description]
|
||||||
|
sourcing:
|
||||||
|
label: "Sourcing"
|
||||||
|
order: 2
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
sourcing_type,
|
||||||
|
manufacturer,
|
||||||
|
manufacturer_pn,
|
||||||
|
supplier,
|
||||||
|
supplier_pn,
|
||||||
|
sourcing_link,
|
||||||
|
]
|
||||||
|
cost:
|
||||||
|
label: "Cost & Lead Time"
|
||||||
|
order: 3
|
||||||
|
fields: [standard_cost, lead_time_days, minimum_order_qty]
|
||||||
|
status:
|
||||||
|
label: "Status"
|
||||||
|
order: 4
|
||||||
|
fields: [lifecycle_status, rohs_compliant, country_of_origin]
|
||||||
|
details:
|
||||||
|
label: "Details"
|
||||||
|
order: 5
|
||||||
|
fields: [long_description, projects, notes]
|
||||||
|
|
||||||
|
# Per-category-prefix field groups (rendered after global groups)
|
||||||
|
category_field_groups:
|
||||||
|
F:
|
||||||
|
fastener_specs:
|
||||||
|
label: "Fastener Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
material,
|
||||||
|
finish,
|
||||||
|
thread_size,
|
||||||
|
thread_pitch,
|
||||||
|
length,
|
||||||
|
head_type,
|
||||||
|
drive_type,
|
||||||
|
strength_grade,
|
||||||
|
torque_spec,
|
||||||
|
]
|
||||||
|
C:
|
||||||
|
fitting_specs:
|
||||||
|
label: "Fitting Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
material,
|
||||||
|
connection_type,
|
||||||
|
size_1,
|
||||||
|
size_2,
|
||||||
|
pressure_rating,
|
||||||
|
temperature_min,
|
||||||
|
temperature_max,
|
||||||
|
media_compatibility,
|
||||||
|
seal_material,
|
||||||
|
]
|
||||||
|
R:
|
||||||
|
motion_specs:
|
||||||
|
label: "Motion Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
load_capacity,
|
||||||
|
speed_rating,
|
||||||
|
power_rating,
|
||||||
|
voltage_nominal,
|
||||||
|
current_nominal,
|
||||||
|
torque_continuous,
|
||||||
|
bore_diameter,
|
||||||
|
travel,
|
||||||
|
stroke,
|
||||||
|
operating_pressure,
|
||||||
|
]
|
||||||
|
S:
|
||||||
|
structural_specs:
|
||||||
|
label: "Structural Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
material,
|
||||||
|
material_spec,
|
||||||
|
profile,
|
||||||
|
dimension_a,
|
||||||
|
dimension_b,
|
||||||
|
wall_thickness,
|
||||||
|
length,
|
||||||
|
weight_per_length,
|
||||||
|
finish,
|
||||||
|
temper,
|
||||||
|
]
|
||||||
|
E:
|
||||||
|
electrical_specs:
|
||||||
|
label: "Electrical Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
voltage_rating,
|
||||||
|
current_rating,
|
||||||
|
power_rating,
|
||||||
|
value,
|
||||||
|
tolerance,
|
||||||
|
package,
|
||||||
|
mounting,
|
||||||
|
pin_count,
|
||||||
|
wire_gauge,
|
||||||
|
connector_type,
|
||||||
|
]
|
||||||
|
M:
|
||||||
|
mechanical_specs:
|
||||||
|
label: "Mechanical Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
material,
|
||||||
|
spring_rate,
|
||||||
|
free_length,
|
||||||
|
max_load,
|
||||||
|
travel,
|
||||||
|
inner_diameter,
|
||||||
|
outer_diameter,
|
||||||
|
hardness,
|
||||||
|
temperature_range,
|
||||||
|
]
|
||||||
|
T:
|
||||||
|
tooling_specs:
|
||||||
|
label: "Tooling Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
material,
|
||||||
|
tolerance,
|
||||||
|
surface_finish,
|
||||||
|
hardness,
|
||||||
|
associated_part,
|
||||||
|
machine,
|
||||||
|
cycle_life,
|
||||||
|
]
|
||||||
|
A:
|
||||||
|
assembly_specs:
|
||||||
|
label: "Assembly Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
weight,
|
||||||
|
dimensions,
|
||||||
|
component_count,
|
||||||
|
assembly_time,
|
||||||
|
test_procedure,
|
||||||
|
voltage_rating,
|
||||||
|
current_rating,
|
||||||
|
ip_rating,
|
||||||
|
]
|
||||||
|
P:
|
||||||
|
purchased_specs:
|
||||||
|
label: "Purchased Item Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
material,
|
||||||
|
form,
|
||||||
|
grade,
|
||||||
|
quantity_per_unit,
|
||||||
|
unit_of_measure,
|
||||||
|
shelf_life,
|
||||||
|
]
|
||||||
|
X:
|
||||||
|
fabrication_specs:
|
||||||
|
label: "Fabrication Specifications"
|
||||||
|
order: 10
|
||||||
|
fields:
|
||||||
|
[
|
||||||
|
material,
|
||||||
|
material_spec,
|
||||||
|
finish,
|
||||||
|
critical_dimensions,
|
||||||
|
weight,
|
||||||
|
process,
|
||||||
|
secondary_operations,
|
||||||
|
drawing_rev,
|
||||||
|
inspection_requirements,
|
||||||
|
]
|
||||||
|
|
||||||
|
field_overrides:
|
||||||
|
standard_cost:
|
||||||
|
widget: currency
|
||||||
|
currency: USD
|
||||||
|
sourcing_link:
|
||||||
|
widget: url
|
||||||
|
lifecycle_status:
|
||||||
|
widget: select
|
||||||
|
options: [active, deprecated, obsolete, prototype]
|
||||||
|
rohs_compliant:
|
||||||
|
widget: checkbox
|
||||||
|
|||||||
@@ -248,6 +248,68 @@ export interface PropertyDef {
|
|||||||
|
|
||||||
export type PropertySchema = Record<string, PropertyDef>;
|
export type PropertySchema = Record<string, PropertyDef>;
|
||||||
|
|
||||||
|
// Form Descriptor (from GET /api/schemas/{name}/form)
|
||||||
|
export interface FormFieldDescriptor {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
widget?: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
unit?: string;
|
||||||
|
description?: string;
|
||||||
|
options?: string[];
|
||||||
|
currency?: string;
|
||||||
|
derived_from_category?: Record<string, string>;
|
||||||
|
search_endpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormFieldGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
fields: FormFieldDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryPickerStage {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
values_by_domain?: Record<string, Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryPickerDescriptor {
|
||||||
|
style: string;
|
||||||
|
stages: CategoryPickerStage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemFieldDef {
|
||||||
|
type: string;
|
||||||
|
widget: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: unknown;
|
||||||
|
options?: string[];
|
||||||
|
derived_from_category?: Record<string, string>;
|
||||||
|
search_endpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldOverride {
|
||||||
|
widget?: string;
|
||||||
|
currency?: string;
|
||||||
|
options?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormDescriptor {
|
||||||
|
schema_name: string;
|
||||||
|
format: string;
|
||||||
|
category_picker?: CategoryPickerDescriptor;
|
||||||
|
item_fields?: Record<string, ItemFieldDef>;
|
||||||
|
field_groups?: FormFieldGroup[];
|
||||||
|
category_field_groups?: Record<string, FormFieldGroup[]>;
|
||||||
|
field_overrides?: Record<string, FieldOverride>;
|
||||||
|
}
|
||||||
|
|
||||||
// API Token
|
// API Token
|
||||||
export interface ApiToken {
|
export interface ApiToken {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,21 +1,48 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from "react";
|
import { useState, useMemo, useRef, useEffect } from "react";
|
||||||
|
import type { CategoryPickerStage } from "../../api/types";
|
||||||
|
|
||||||
interface CategoryPickerProps {
|
interface CategoryPickerProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (code: string) => void;
|
onChange: (code: string) => void;
|
||||||
categories: Record<string, string>;
|
categories: Record<string, string>;
|
||||||
|
stages?: CategoryPickerStage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryPicker({
|
export function CategoryPicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
categories,
|
categories,
|
||||||
|
stages,
|
||||||
}: CategoryPickerProps) {
|
}: CategoryPickerProps) {
|
||||||
|
const [selectedDomain, setSelectedDomain] = useState<string>("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const selectedRef = useRef<HTMLDivElement>(null);
|
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 entries = useMemo(() => {
|
||||||
const all = Object.entries(categories).sort(([a], [b]) =>
|
const all = Object.entries(filteredCategories).sort(([a], [b]) =>
|
||||||
a.localeCompare(b),
|
a.localeCompare(b),
|
||||||
);
|
);
|
||||||
if (!search) return all;
|
if (!search) return all;
|
||||||
@@ -24,7 +51,7 @@ export function CategoryPicker({
|
|||||||
([code, desc]) =>
|
([code, desc]) =>
|
||||||
code.toLowerCase().includes(q) || desc.toLowerCase().includes(q),
|
code.toLowerCase().includes(q) || desc.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}, [categories, search]);
|
}, [filteredCategories, search]);
|
||||||
|
|
||||||
// Scroll selected into view on mount.
|
// Scroll selected into view on mount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,12 +67,70 @@ export function CategoryPicker({
|
|||||||
overflow: "hidden",
|
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 */}
|
{/* Search */}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Search categories..."
|
placeholder={
|
||||||
|
isMultiStage && !selectedDomain
|
||||||
|
? "Select a domain above..."
|
||||||
|
: "Search categories..."
|
||||||
|
}
|
||||||
|
disabled={isMultiStage && !selectedDomain}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.4rem 0.5rem",
|
padding: "0.4rem 0.5rem",
|
||||||
@@ -61,7 +146,18 @@ export function CategoryPicker({
|
|||||||
|
|
||||||
{/* Scrollable list */}
|
{/* Scrollable list */}
|
||||||
<div style={{ maxHeight: 200, overflowY: "auto" }}>
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "0.75rem",
|
padding: "0.75rem",
|
||||||
@@ -90,9 +186,7 @@ export function CategoryPicker({
|
|||||||
backgroundColor: isSelected
|
backgroundColor: isSelected
|
||||||
? "rgba(203,166,247,0.12)"
|
? "rgba(203,166,247,0.12)"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
color: isSelected
|
color: isSelected ? "var(--ctp-mauve)" : "var(--ctp-text)",
|
||||||
? "var(--ctp-mauve)"
|
|
||||||
: "var(--ctp-text)",
|
|
||||||
fontWeight: isSelected ? 600 : 400,
|
fontWeight: isSelected ? 600 : 400,
|
||||||
transition: "background-color 0.1s",
|
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 { 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 { TagInput, type TagOption } from "../TagInput";
|
||||||
import { CategoryPicker } from "./CategoryPicker";
|
import { CategoryPicker } from "./CategoryPicker";
|
||||||
import { FileDropZone } from "./FileDropZone";
|
import { FileDropZone } from "./FileDropZone";
|
||||||
import { useCategories } from "../../hooks/useCategories";
|
import { useFormDescriptor } from "../../hooks/useFormDescriptor";
|
||||||
import {
|
import {
|
||||||
useFileUpload,
|
useFileUpload,
|
||||||
type PendingAttachment,
|
type PendingAttachment,
|
||||||
} from "../../hooks/useFileUpload";
|
} from "../../hooks/useFileUpload";
|
||||||
import { useAuth } from "../../hooks/useAuth";
|
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 {
|
interface CreateItemPaneProps {
|
||||||
onCreated: (partNumber: string) => void;
|
onCreated: (partNumber: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -18,20 +31,13 @@ interface CreateItemPaneProps {
|
|||||||
|
|
||||||
export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { categories } = useCategories();
|
const { descriptor, categories } = useFormDescriptor();
|
||||||
const { upload } = useFileUpload();
|
const { upload } = useFileUpload();
|
||||||
|
|
||||||
// Form state.
|
// Single form state for all fields (item-level + properties).
|
||||||
const [itemType, setItemType] = useState("part");
|
|
||||||
const [category, setCategory] = useState("");
|
const [category, setCategory] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [fields, setFields] = useState<Record<string, string>>({});
|
||||||
const [sourcingType, setSourcingType] = useState("manufactured");
|
|
||||||
const [longDescription, setLongDescription] = useState("");
|
|
||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
const [catProps, setCatProps] = useState<Record<string, string>>({});
|
|
||||||
const [catPropDefs, setCatPropDefs] = useState<
|
|
||||||
Record<string, { type: string }>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
// Attachments.
|
// Attachments.
|
||||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||||
@@ -42,27 +48,33 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load category-specific properties.
|
const setField = (name: string, value: string) =>
|
||||||
useEffect(() => {
|
setFields((prev) => ({ ...prev, [name]: value }));
|
||||||
if (!category) {
|
|
||||||
setCatPropDefs({});
|
const getField = (name: string) => fields[name] ?? "";
|
||||||
setCatProps({});
|
|
||||||
return;
|
// 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 }>>(
|
const mapping = descriptor.item_fields.item_type.derived_from_category;
|
||||||
`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`,
|
const prefix = cat[0]!;
|
||||||
)
|
return mapping[prefix] ?? mapping["default"] ?? "part";
|
||||||
.then((defs) => {
|
};
|
||||||
setCatPropDefs(defs);
|
|
||||||
const defaults: Record<string, string> = {};
|
const handleCategoryChange = (cat: string) => {
|
||||||
for (const key of Object.keys(defs)) defaults[key] = "";
|
setCategory(cat);
|
||||||
setCatProps(defaults);
|
// Auto-derive item_type when category changes
|
||||||
})
|
if (descriptor?.item_fields?.item_type?.derived_from_category) {
|
||||||
.catch(() => {
|
const derived = cat
|
||||||
setCatPropDefs({});
|
? (descriptor.item_fields.item_type.derived_from_category[cat[0]!] ??
|
||||||
setCatProps({});
|
descriptor.item_fields.item_type.derived_from_category["default"] ??
|
||||||
});
|
"part")
|
||||||
}, [category]);
|
: "part";
|
||||||
|
setField("item_type", derived);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const searchProjects = useCallback(
|
const searchProjects = useCallback(
|
||||||
async (query: string): Promise<TagOption[]> => {
|
async (query: string): Promise<TagOption[]> => {
|
||||||
@@ -88,10 +100,8 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
const startIdx = attachments.length;
|
const startIdx = attachments.length;
|
||||||
setAttachments((prev) => [...prev, ...files]);
|
setAttachments((prev) => [...prev, ...files]);
|
||||||
|
|
||||||
// Upload each file.
|
|
||||||
files.forEach((f, i) => {
|
files.forEach((f, i) => {
|
||||||
const idx = startIdx + i;
|
const idx = startIdx + i;
|
||||||
// Mark uploading.
|
|
||||||
setAttachments((prev) =>
|
setAttachments((prev) =>
|
||||||
prev.map((a, j) =>
|
prev.map((a, j) =>
|
||||||
j === idx ? { ...a, uploadStatus: "uploading" } : a,
|
j === idx ? { ...a, uploadStatus: "uploading" } : a,
|
||||||
@@ -153,12 +163,15 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Split fields into item-level and properties
|
||||||
const properties: Record<string, unknown> = {};
|
const properties: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(catProps)) {
|
for (const [k, v] of Object.entries(fields)) {
|
||||||
if (!v) continue;
|
if (!v) continue;
|
||||||
const def = catPropDefs[k];
|
if (ITEM_LEVEL_FIELDS.has(k)) continue; // handled separately
|
||||||
if (def?.type === "number") properties[k] = Number(v);
|
// Coerce type from descriptor
|
||||||
else if (def?.type === "boolean") properties[k] = v === "true";
|
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;
|
else properties[k] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,12 +179,12 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
const result = await post<{ part_number: string }>("/api/items", {
|
const result = await post<{ part_number: string }>("/api/items", {
|
||||||
schema: "kindred-rd",
|
schema: "kindred-rd",
|
||||||
category,
|
category,
|
||||||
description,
|
description: getField("description") || undefined,
|
||||||
item_type: itemType,
|
item_type: deriveItemType(category),
|
||||||
projects: selectedProjects.length > 0 ? selectedProjects : undefined,
|
projects: selectedProjects.length > 0 ? selectedProjects : undefined,
|
||||||
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
||||||
sourcing_type: sourcingType || undefined,
|
sourcing_type: getField("sourcing_type") || undefined,
|
||||||
long_description: longDescription || undefined,
|
long_description: getField("long_description") || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pn = result.part_number;
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -258,110 +298,52 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
<div style={{ overflow: "auto", padding: "0.75rem" }}>
|
<div style={{ overflow: "auto", padding: "0.75rem" }}>
|
||||||
{error && <div style={errorStyle}>{error}</div>}
|
{error && <div style={errorStyle}>{error}</div>}
|
||||||
|
|
||||||
{/* Identity section */}
|
{/* Category picker */}
|
||||||
<SectionHeader>Identity</SectionHeader>
|
<SectionHeader>Category *</SectionHeader>
|
||||||
<div style={fieldGridStyle}>
|
<CategoryPicker
|
||||||
<FormGroup label="Type *">
|
value={category}
|
||||||
<select
|
onChange={handleCategoryChange}
|
||||||
value={itemType}
|
categories={categories}
|
||||||
onChange={(e) => setItemType(e.target.value)}
|
stages={descriptor?.category_picker?.stages}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Sourcing section */}
|
{/* Dynamic field groups from descriptor */}
|
||||||
<SectionHeader>Sourcing</SectionHeader>
|
{descriptor?.field_groups?.map((group) => (
|
||||||
<div style={fieldGridStyle}>
|
<div key={group.key}>
|
||||||
<FormGroup label="Sourcing Type">
|
<SectionHeader>{group.label}</SectionHeader>
|
||||||
<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>
|
|
||||||
<div style={fieldGridStyle}>
|
<div style={fieldGridStyle}>
|
||||||
{Object.entries(catPropDefs).map(([key, def]) => (
|
{group.fields.map((field) =>
|
||||||
<FormGroup key={key} label={key}>
|
renderField(
|
||||||
{def.type === "boolean" ? (
|
field,
|
||||||
<select
|
getField(field.name),
|
||||||
value={catProps[key] ?? ""}
|
(v) => setField(field.name, v),
|
||||||
onChange={(e) =>
|
selectedProjects,
|
||||||
setCatProps({ ...catProps, [key]: e.target.value })
|
setSelectedProjects,
|
||||||
}
|
searchProjects,
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Right: sidebar */}
|
{/* 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 ---
|
// --- Sub-components ---
|
||||||
|
|
||||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { get } from "../api/client";
|
|
||||||
import type { Schema } from "../api/types";
|
|
||||||
|
|
||||||
// Module-level cache to avoid refetching across mounts.
|
|
||||||
let cached: Record<string, string> | null = null;
|
|
||||||
|
|
||||||
export function useCategories() {
|
|
||||||
const [categories, setCategories] = useState<Record<string, string>>(
|
|
||||||
cached ?? {},
|
|
||||||
);
|
|
||||||
const [loading, setLoading] = useState(cached === null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (cached) return;
|
|
||||||
get<Schema>("/api/schemas/kindred-rd")
|
|
||||||
.then((schema) => {
|
|
||||||
const seg = schema.segments.find((s) => s.name === "category");
|
|
||||||
const vals = seg?.values ?? {};
|
|
||||||
cached = vals;
|
|
||||||
setCategories(vals);
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { categories, loading };
|
|
||||||
}
|
|
||||||
37
web/src/hooks/useFormDescriptor.ts
Normal file
37
web/src/hooks/useFormDescriptor.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { get } from "../api/client";
|
||||||
|
import type { FormDescriptor } from "../api/types";
|
||||||
|
|
||||||
|
// Module-level cache to avoid refetching across mounts.
|
||||||
|
let cached: FormDescriptor | null = null;
|
||||||
|
|
||||||
|
export function useFormDescriptor(schemaName = "kindred-rd") {
|
||||||
|
const [descriptor, setDescriptor] = useState<FormDescriptor | null>(cached);
|
||||||
|
const [loading, setLoading] = useState(cached === null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cached) return;
|
||||||
|
get<FormDescriptor>(`/api/schemas/${encodeURIComponent(schemaName)}/form`)
|
||||||
|
.then((desc) => {
|
||||||
|
cached = desc;
|
||||||
|
setDescriptor(desc);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [schemaName]);
|
||||||
|
|
||||||
|
// Derive flat categories map from the category_picker stages
|
||||||
|
const categories: Record<string, string> = {};
|
||||||
|
if (descriptor?.category_picker) {
|
||||||
|
const subcatStage = descriptor.category_picker.stages.find(
|
||||||
|
(s) => s.values_by_domain,
|
||||||
|
);
|
||||||
|
if (subcatStage?.values_by_domain) {
|
||||||
|
for (const domainVals of Object.values(subcatStage.values_by_domain)) {
|
||||||
|
Object.assign(categories, domainVals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { descriptor, categories, loading };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user