- 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)
395 lines
12 KiB
Go
395 lines
12 KiB
Go
// Package schema handles YAML schema parsing and validation for part numbering.
|
|
package schema
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Schema represents a part numbering schema.
|
|
type Schema struct {
|
|
Name string `yaml:"name"`
|
|
Version int `yaml:"version"`
|
|
Description string `yaml:"description"`
|
|
Separator string `yaml:"separator"`
|
|
Uniqueness Uniqueness `yaml:"uniqueness"`
|
|
Segments []Segment `yaml:"segments"`
|
|
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.
|
|
type PropertySchemas struct {
|
|
Version int `yaml:"version" json:"version"`
|
|
Defaults map[string]PropertyDefinition `yaml:"defaults" json:"defaults"`
|
|
Categories map[string]map[string]PropertyDefinition `yaml:"categories" json:"categories"`
|
|
}
|
|
|
|
// PropertyDefinition defines a single property's schema.
|
|
type PropertyDefinition struct {
|
|
Type string `yaml:"type" json:"type"` // string, number, boolean, date
|
|
Default any `yaml:"default" json:"default"`
|
|
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
|
|
Unit string `yaml:"unit,omitempty" json:"unit,omitempty"`
|
|
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
|
}
|
|
|
|
// GetPropertiesForCategory returns merged properties for a category.
|
|
func (ps *PropertySchemas) GetPropertiesForCategory(category string) map[string]PropertyDefinition {
|
|
result := make(map[string]PropertyDefinition)
|
|
|
|
// Start with defaults
|
|
for k, v := range ps.Defaults {
|
|
result[k] = v
|
|
}
|
|
|
|
// Add category-specific (first character of category code)
|
|
if len(category) > 0 {
|
|
categoryPrefix := string(category[0])
|
|
if catProps, ok := ps.Categories[categoryPrefix]; ok {
|
|
for k, v := range catProps {
|
|
result[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ApplyDefaults fills in missing properties with defaults.
|
|
func (ps *PropertySchemas) ApplyDefaults(properties map[string]any, category string) map[string]any {
|
|
if properties == nil {
|
|
properties = make(map[string]any)
|
|
}
|
|
|
|
defs := ps.GetPropertiesForCategory(category)
|
|
|
|
for key, def := range defs {
|
|
if _, exists := properties[key]; !exists && def.Default != nil {
|
|
properties[key] = def.Default
|
|
}
|
|
}
|
|
|
|
return properties
|
|
}
|
|
|
|
// Uniqueness defines how part number uniqueness is enforced.
|
|
type Uniqueness struct {
|
|
Scope string `yaml:"scope"` // global, per-project, per-type, per-schema
|
|
CaseSensitive bool `yaml:"case_sensitive"` // default false
|
|
}
|
|
|
|
// Segment represents a part number segment.
|
|
type Segment struct {
|
|
Name string `yaml:"name"`
|
|
Type string `yaml:"type"` // string, enum, serial, date, constant
|
|
Length int `yaml:"length"`
|
|
MinLength int `yaml:"min_length"`
|
|
MaxLength int `yaml:"max_length"`
|
|
Case string `yaml:"case"` // upper, lower, preserve
|
|
Padding string `yaml:"padding"`
|
|
Start int `yaml:"start"`
|
|
Scope string `yaml:"scope"` // template for serial scope
|
|
Value string `yaml:"value"` // for constant type
|
|
Values map[string]string `yaml:"values"`
|
|
Validation Validation `yaml:"validation"`
|
|
Required bool `yaml:"required"`
|
|
Description string `yaml:"description"`
|
|
}
|
|
|
|
// Validation defines validation rules for a segment.
|
|
type Validation struct {
|
|
Pattern string `yaml:"pattern"`
|
|
Message string `yaml:"message"`
|
|
}
|
|
|
|
// SchemaFile wraps the schema in a file structure.
|
|
type SchemaFile struct {
|
|
Schema Schema `yaml:"schema"`
|
|
}
|
|
|
|
// Load reads a schema from a YAML file.
|
|
func Load(path string) (*Schema, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading schema file: %w", err)
|
|
}
|
|
|
|
var sf SchemaFile
|
|
if err := yaml.Unmarshal(data, &sf); err != nil {
|
|
return nil, fmt.Errorf("parsing schema YAML: %w", err)
|
|
}
|
|
|
|
// Set defaults
|
|
if sf.Schema.Separator == "" {
|
|
sf.Schema.Separator = "-"
|
|
}
|
|
if sf.Schema.Uniqueness.Scope == "" {
|
|
sf.Schema.Uniqueness.Scope = "global"
|
|
}
|
|
|
|
return &sf.Schema, nil
|
|
}
|
|
|
|
// LoadAll reads all schemas from a directory.
|
|
func LoadAll(dir string) (map[string]*Schema, error) {
|
|
schemas := make(map[string]*Schema)
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading schema directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") {
|
|
continue
|
|
}
|
|
|
|
path := filepath.Join(dir, entry.Name())
|
|
schema, err := Load(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading %s: %w", entry.Name(), err)
|
|
}
|
|
schemas[schema.Name] = schema
|
|
}
|
|
|
|
return schemas, nil
|
|
}
|
|
|
|
// Validate checks that the schema definition is valid.
|
|
func (s *Schema) Validate() error {
|
|
if s.Name == "" {
|
|
return fmt.Errorf("schema name is required")
|
|
}
|
|
if len(s.Segments) == 0 {
|
|
return fmt.Errorf("schema must have at least one segment")
|
|
}
|
|
|
|
for i, seg := range s.Segments {
|
|
if err := seg.Validate(); err != nil {
|
|
return fmt.Errorf("segment %d (%s): %w", i, seg.Name, err)
|
|
}
|
|
}
|
|
|
|
if err := s.ValidateUI(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Validate checks that a segment definition is valid.
|
|
func (seg *Segment) Validate() error {
|
|
if seg.Name == "" {
|
|
return fmt.Errorf("segment name is required")
|
|
}
|
|
|
|
validTypes := map[string]bool{
|
|
"string": true,
|
|
"enum": true,
|
|
"serial": true,
|
|
"date": true,
|
|
"constant": true,
|
|
}
|
|
if !validTypes[seg.Type] {
|
|
return fmt.Errorf("invalid segment type: %s", seg.Type)
|
|
}
|
|
|
|
switch seg.Type {
|
|
case "enum":
|
|
if len(seg.Values) == 0 {
|
|
return fmt.Errorf("enum segment requires values")
|
|
}
|
|
case "constant":
|
|
if seg.Value == "" {
|
|
return fmt.Errorf("constant segment requires value")
|
|
}
|
|
case "serial":
|
|
if seg.Length <= 0 {
|
|
return fmt.Errorf("serial segment requires positive length")
|
|
}
|
|
}
|
|
|
|
if seg.Validation.Pattern != "" {
|
|
if _, err := regexp.Compile(seg.Validation.Pattern); err != nil {
|
|
return fmt.Errorf("invalid validation pattern: %w", err)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if s.Segments[i].Name == name {
|
|
return &s.Segments[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|