From b82e22c38ddfd97acb72e74f597b3617d2cdca80 Mon Sep 17 00:00:00 2001 From: Mason Payne Date: Tue, 9 Sep 2025 22:30:00 -0600 Subject: [PATCH] allow for sections and components in any order on pages --- examples/lang/debug.go | 10 +- .../templates/page-admin.tmpl | 17 +- .../templates/page-public.tmpl | 17 +- interpreter/html_interpreter.go | 28 +- lang/lang.go | 23 +- lang/parser_ui_advanced_test.go | 538 +++++++++++------- lang/parser_ui_component_test.go | 456 ++++++++------- lang/parser_ui_page_test.go | 395 ++++++++----- lang/parser_ui_section_test.go | 486 +++++++++------- lang/test_ui_comparisons.go | 41 +- temp_multi-output-template-plan.md | 16 +- 11 files changed, 1179 insertions(+), 848 deletions(-) diff --git a/examples/lang/debug.go b/examples/lang/debug.go index 4236175..3fe75fc 100644 --- a/examples/lang/debug.go +++ b/examples/lang/debug.go @@ -96,7 +96,7 @@ func main() { for _, def := range ast.Definitions { if def.Page != nil { pageCount++ - totalContent := len(def.Page.Meta) + len(def.Page.Sections) + len(def.Page.Components) + totalContent := len(def.Page.Meta) + len(def.Page.Elements) if totalContent > 0 { fmt.Printf(" ✓ Page '%s' has %d content items (block syntax working)\n", def.Page.Name, totalContent) } @@ -110,9 +110,11 @@ func main() { var totalSections, nestedSections int for _, def := range ast.Definitions { if def.Page != nil { - totalSections += len(def.Page.Sections) - for _, section := range def.Page.Sections { - nestedSections += countNestedSections(section) + for _, element := range def.Page.Elements { + if element.Section != nil { + totalSections++ + nestedSections += countNestedSections(*element.Section) + } } } } diff --git a/examples/react-app-generator/templates/page-admin.tmpl b/examples/react-app-generator/templates/page-admin.tmpl index ae06d25..3e0837f 100644 --- a/examples/react-app-generator/templates/page-admin.tmpl +++ b/examples/react-app-generator/templates/page-admin.tmpl @@ -2,9 +2,9 @@ import React, { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; {{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}'; {{end}}{{end}} -{{range .Page.Sections}}import {{.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Name | title}}Section'; -{{end}}{{range .Page.Components}}import {{.Type | title}}Component from '{{$relativePrefix}}components/{{.Type | title}}Component'; -{{end}} +{{range .Page.Elements}}{{if .Section}}import {{.Section.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Section.Name | title}}Section'; +{{end}}{{if .Component}}import {{.Component.Type | title}}Component from '{{$relativePrefix}}components/{{.Component.Type | title}}Component'; +{{end}}{{end}} export default function {{.Page.Name}}Page() { const navigate = useNavigate(); @@ -53,12 +53,13 @@ export default function {{.Page.Name}}Page() { {{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}} - {{range .Page.Sections}} - <{{.Name | title}}Section /> + {{range .Page.Elements}} + {{if .Section}} + <{{.Section.Name | title}}Section /> + {{end}} + {{if .Component}} + <{{.Component.Type | title}}Component /> {{end}} - - {{range .Page.Components}} - <{{.Type | title}}Component /> {{end}} diff --git a/examples/react-app-generator/templates/page-public.tmpl b/examples/react-app-generator/templates/page-public.tmpl index c747e0e..164731f 100644 --- a/examples/react-app-generator/templates/page-public.tmpl +++ b/examples/react-app-generator/templates/page-public.tmpl @@ -2,9 +2,9 @@ import React from 'react'; import { Link } from 'react-router-dom'; {{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}'; {{end}}{{end}} -{{range .Page.Sections}}import {{.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Name | title}}Section'; -{{end}}{{range .Page.Components}}import {{.Type | title}}Component from '{{$relativePrefix}}components/{{.Type | title}}Component'; -{{end}} +{{range .Page.Elements}}{{if .Section}}import {{.Section.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Section.Name | title}}Section'; +{{end}}{{if .Component}}import {{.Component.Type | title}}Component from '{{$relativePrefix}}components/{{.Component.Type | title}}Component'; +{{end}}{{end}} export default function {{.Page.Name}}Page() { return ( @@ -36,12 +36,13 @@ export default function {{.Page.Name}}Page() { {{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}} - {{range .Page.Sections}} - <{{.Name | title}}Section /> + {{range .Page.Elements}} + {{if .Section}} + <{{.Section.Name | title}}Section /> + {{end}} + {{if .Component}} + <{{.Component.Type | title}}Component /> {{end}} - - {{range .Page.Components}} - <{{.Type | title}}Component /> {{end}} diff --git a/interpreter/html_interpreter.go b/interpreter/html_interpreter.go index 418194d..3650897 100644 --- a/interpreter/html_interpreter.go +++ b/interpreter/html_interpreter.go @@ -117,22 +117,22 @@ func (hi *HTMLInterpreter) generatePageHTML(page *lang.Page) (string, error) { html.WriteString("
\n") html.WriteString(fmt.Sprintf("

%s

\n", hi.escapeHTML(title))) - // Generate sections - for _, section := range page.Sections { - sectionHTML, err := hi.generateSectionHTML(§ion, 2) - if err != nil { - return "", err + // Generate page elements + for _, element := range page.Elements { + if element.Section != nil { + sectionHTML, err := hi.generateSectionHTML(element.Section, 2) + if err != nil { + return "", err + } + html.WriteString(sectionHTML) } - html.WriteString(sectionHTML) - } - - // Generate direct components - for _, component := range page.Components { - componentHTML, err := hi.generateComponentHTML(&component, 2) - if err != nil { - return "", err + if element.Component != nil { + componentHTML, err := hi.generateComponentHTML(element.Component, 2) + if err != nil { + return "", err + } + html.WriteString(componentHTML) } - html.WriteString(componentHTML) } html.WriteString("
\n") diff --git a/lang/lang.go b/lang/lang.go index f23c9fd..25b9d1a 100644 --- a/lang/lang.go +++ b/lang/lang.go @@ -122,15 +122,20 @@ type ResponseSpec struct { // Page Enhanced Page definitions with unified section model type Page struct { - Name string `parser:"'page' @Ident"` - Path string `parser:"'at' @String"` - Layout string `parser:"'layout' @Ident"` - Title *string `parser:"('title' @String)?"` - Description *string `parser:"('desc' @String)?"` - Auth bool `parser:"@'auth'?"` - Meta []MetaTag `parser:"('{' @@*"` // Block-delimited content - Sections []Section `parser:"@@*"` // Unified sections replace containers/tabs/panels/modals - Components []Component `parser:"@@* '}')?"` // Direct components within the block + Name string `parser:"'page' @Ident"` + Path string `parser:"'at' @String"` + Layout string `parser:"'layout' @Ident"` + Title *string `parser:"('title' @String)?"` + Description *string `parser:"('desc' @String)?"` + Auth bool `parser:"@'auth'?"` + Meta []MetaTag `parser:"('{' @@*"` // Block-delimited content + Elements []PageElement `parser:"@@* '}')?"` // Unified elements allowing any order +} + +// PageElement Unified element type for pages allowing sections and components in any order +type PageElement struct { + Section *Section `parser:"@@"` + Component *Component `parser:"| @@"` } // MetaTag Meta tags for SEO diff --git a/lang/parser_ui_advanced_test.go b/lang/parser_ui_advanced_test.go index 9a2490b..42acb59 100644 --- a/lang/parser_ui_advanced_test.go +++ b/lang/parser_ui_advanced_test.go @@ -4,13 +4,121 @@ import ( "testing" ) -func TestParseAdvancedUIFeatures(t *testing.T) { +func TestParseAdvancedUIStructures(t *testing.T) { tests := []struct { name string input string want AST wantErr bool }{ + { + name: "complex form with validation and conditional fields", + input: `page Test at "/test" layout main { + component form for User { + field name type text required validate min_length "3" + field email type email required validate email + field account_type type select options ["personal", "business"] default "personal" + when account_type equals "business" { + field company_name type text required + field tax_id type text validate pattern "[0-9]{9}" + } + button submit label "Create Account" style "primary" + button cancel label "Cancel" style "secondary" + } + }`, + want: AST{ + Definitions: []Definition{ + { + Page: &Page{ + Name: "Test", + Path: "/test", + Layout: "main", + Elements: []PageElement{ + { + Component: &Component{ + Type: "form", + Entity: stringPtr("User"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "name", + Type: "text", + Attributes: []ComponentFieldAttribute{ + {Required: true}, + {Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}}, + }, + }, + }, + { + Field: &ComponentField{ + Name: "email", + Type: "email", + Attributes: []ComponentFieldAttribute{ + {Required: true}, + {Validation: &ComponentValidation{Type: "email"}}, + }, + }, + }, + { + Field: &ComponentField{ + Name: "account_type", + Type: "select", + Attributes: []ComponentFieldAttribute{ + {Options: []string{"personal", "business"}}, + {Default: stringPtr("personal")}, + }, + }, + }, + { + When: &WhenCondition{ + Field: "account_type", + Operator: "equals", + Value: "business", + Fields: []ComponentField{ + { + Name: "company_name", + Type: "text", + Attributes: []ComponentFieldAttribute{ + {Required: true}, + }, + }, + { + Name: "tax_id", + Type: "text", + Attributes: []ComponentFieldAttribute{ + {Validation: &ComponentValidation{Type: "pattern", Value: stringPtr("[0-9]{9}")}}, + }, + }, + }, + }, + }, + { + Button: &ComponentButton{ + Name: "submit", + Label: "Create Account", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "primary"}}, + }, + }, + }, + { + Button: &ComponentButton{ + Name: "cancel", + Label: "Cancel", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "secondary"}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, { name: "complex conditional rendering with multiple operators", input: `page Test at "/test" layout main { @@ -42,86 +150,88 @@ func TestParseAdvancedUIFeatures(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Components: []Component{ + Elements: []PageElement{ { - Type: "form", - Entity: stringPtr("User"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "status", - Type: "select", - Attributes: []ComponentFieldAttribute{ - {Options: []string{"active", "inactive", "pending"}}, + Component: &Component{ + Type: "form", + Entity: stringPtr("User"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "status", + Type: "select", + Attributes: []ComponentFieldAttribute{ + {Options: []string{"active", "inactive", "pending"}}, + }, }, }, - }, - { - When: &WhenCondition{ - Field: "status", - Operator: "equals", - Value: "active", - Fields: []ComponentField{ - {Name: "last_login", Type: "datetime"}, - {Name: "permissions", Type: "multiselect"}, - }, - Buttons: []ComponentButton{ - { - Name: "deactivate", - Label: "Deactivate User", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "warning"}}, + { + When: &WhenCondition{ + Field: "status", + Operator: "equals", + Value: "active", + Fields: []ComponentField{ + {Name: "last_login", Type: "datetime"}, + {Name: "permissions", Type: "multiselect"}, + }, + Buttons: []ComponentButton{ + { + Name: "deactivate", + Label: "Deactivate User", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "warning"}}, + }, }, }, }, }, - }, - { - When: &WhenCondition{ - Field: "status", - Operator: "not_equals", - Value: "active", - Fields: []ComponentField{ - { - Name: "reason", - Type: "textarea", - Attributes: []ComponentFieldAttribute{ - {Placeholder: stringPtr("Reason for status")}, + { + When: &WhenCondition{ + Field: "status", + Operator: "not_equals", + Value: "active", + Fields: []ComponentField{ + { + Name: "reason", + Type: "textarea", + Attributes: []ComponentFieldAttribute{ + {Placeholder: stringPtr("Reason for status")}, + }, }, }, - }, - Buttons: []ComponentButton{ - { - Name: "activate", - Label: "Activate User", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "success"}}, + Buttons: []ComponentButton{ + { + Name: "activate", + Label: "Activate User", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "success"}}, + }, }, }, }, }, - }, - { - When: &WhenCondition{ - Field: "status", - Operator: "contains", - Value: "pending", - Fields: []ComponentField{ - {Name: "approval_date", Type: "date"}, - }, - Buttons: []ComponentButton{ - { - Name: "approve", - Label: "Approve", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "primary"}}, - }, + { + When: &WhenCondition{ + Field: "status", + Operator: "contains", + Value: "pending", + Fields: []ComponentField{ + {Name: "approval_date", Type: "date"}, }, - { - Name: "reject", - Label: "Reject", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "danger"}}, + Buttons: []ComponentButton{ + { + Name: "approve", + Label: "Approve", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "primary"}}, + }, + }, + { + Name: "reject", + Label: "Reject", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "danger"}}, + }, }, }, }, @@ -203,104 +313,106 @@ func TestParseAdvancedUIFeatures(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Components: []Component{ + Elements: []PageElement{ { - Type: "form", - Entity: stringPtr("Product"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "name", - Type: "text", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Product Name")}, - {Placeholder: stringPtr("Enter product name")}, - {Required: true}, - {Default: stringPtr("New Product")}, - {Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}}, - {Size: stringPtr("large")}, - {Display: stringPtr("block")}, + Component: &Component{ + Type: "form", + Entity: stringPtr("Product"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "name", + Type: "text", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Product Name")}, + {Placeholder: stringPtr("Enter product name")}, + {Required: true}, + {Default: stringPtr("New Product")}, + {Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}}, + {Size: stringPtr("large")}, + {Display: stringPtr("block")}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "price", - Type: "number", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Price ($)")}, - {Format: stringPtr("currency")}, - {Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}}, - {Validation: &ComponentValidation{Type: "max", Value: stringPtr("10000")}}, + { + Field: &ComponentField{ + Name: "price", + Type: "number", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Price ($)")}, + {Format: stringPtr("currency")}, + {Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}}, + {Validation: &ComponentValidation{Type: "max", Value: stringPtr("10000")}}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "category", - Type: "autocomplete", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Category")}, - {Placeholder: stringPtr("Start typing...")}, - {Relates: &FieldRelation{Type: "Category"}}, - {Searchable: true}, - {Source: stringPtr("categories/search")}, + { + Field: &ComponentField{ + Name: "category", + Type: "autocomplete", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Category")}, + {Placeholder: stringPtr("Start typing...")}, + {Relates: &FieldRelation{Type: "Category"}}, + {Searchable: true}, + {Source: stringPtr("categories/search")}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "tags", - Type: "multiselect", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Tags")}, - {Options: []string{"electronics", "clothing", "books", "home"}}, - {Source: stringPtr("tags/popular")}, + { + Field: &ComponentField{ + Name: "tags", + Type: "multiselect", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Tags")}, + {Options: []string{"electronics", "clothing", "books", "home"}}, + {Source: stringPtr("tags/popular")}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "description", - Type: "richtext", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Description")}, - {Rows: intPtr(10)}, - {Placeholder: stringPtr("Describe your product...")}, + { + Field: &ComponentField{ + Name: "description", + Type: "richtext", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Description")}, + {Rows: intPtr(10)}, + {Placeholder: stringPtr("Describe your product...")}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "thumbnail", - Type: "image", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Product Image")}, - {Accept: stringPtr("image/jpeg,image/png")}, - {Thumbnail: true}, + { + Field: &ComponentField{ + Name: "thumbnail", + Type: "image", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Product Image")}, + {Accept: stringPtr("image/jpeg,image/png")}, + {Thumbnail: true}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "featured", - Type: "checkbox", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Featured Product")}, - {Default: stringPtr("false")}, - {Value: stringPtr("true")}, + { + Field: &ComponentField{ + Name: "featured", + Type: "checkbox", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Featured Product")}, + {Default: stringPtr("false")}, + {Value: stringPtr("true")}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "availability", - Type: "select", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Availability")}, - {Options: []string{"in_stock", "out_of_stock", "pre_order"}}, - {Default: stringPtr("in_stock")}, - {Sortable: true}, + { + Field: &ComponentField{ + Name: "availability", + Type: "select", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Availability")}, + {Options: []string{"in_stock", "out_of_stock", "pre_order"}}, + {Default: stringPtr("in_stock")}, + {Sortable: true}, + }, }, }, }, @@ -332,76 +444,78 @@ func TestParseAdvancedUIFeatures(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Components: []Component{ + Elements: []PageElement{ { - Type: "form", - Entity: stringPtr("Order"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "status", - Type: "select", - Attributes: []ComponentFieldAttribute{ - {Options: []string{"draft", "submitted", "approved"}}, + Component: &Component{ + Type: "form", + Entity: stringPtr("Order"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "status", + Type: "select", + Attributes: []ComponentFieldAttribute{ + {Options: []string{"draft", "submitted", "approved"}}, + }, }, }, - }, - { - Button: &ComponentButton{ - Name: "save", - Label: "Save Draft", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "secondary"}}, - {Icon: &ComponentButtonIcon{Value: "save"}}, - {Position: &ComponentButtonPosition{Value: "left"}}, + { + Button: &ComponentButton{ + Name: "save", + Label: "Save Draft", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "secondary"}}, + {Icon: &ComponentButtonIcon{Value: "save"}}, + {Position: &ComponentButtonPosition{Value: "left"}}, + }, }, }, - }, - { - Button: &ComponentButton{ - Name: "submit", - Label: "Submit Order", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "primary"}}, - {Icon: &ComponentButtonIcon{Value: "send"}}, - {Loading: &ComponentButtonLoading{Value: "Submitting..."}}, - {Confirm: &ComponentButtonConfirm{Value: "Submit this order?"}}, + { + Button: &ComponentButton{ + Name: "submit", + Label: "Submit Order", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "primary"}}, + {Icon: &ComponentButtonIcon{Value: "send"}}, + {Loading: &ComponentButtonLoading{Value: "Submitting..."}}, + {Confirm: &ComponentButtonConfirm{Value: "Submit this order?"}}, + }, }, }, - }, - { - Button: &ComponentButton{ - Name: "approve", - Label: "Approve", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "success"}}, - {Loading: &ComponentButtonLoading{Value: "Approving..."}}, - {Disabled: &ComponentButtonDisabled{Value: "status"}}, - {Confirm: &ComponentButtonConfirm{Value: "Approve this order?"}}, - {Target: &ComponentButtonTarget{Value: "approval_modal"}}, - {Via: &ComponentButtonVia{Value: "api/orders/approve"}}, + { + Button: &ComponentButton{ + Name: "approve", + Label: "Approve", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "success"}}, + {Loading: &ComponentButtonLoading{Value: "Approving..."}}, + {Disabled: &ComponentButtonDisabled{Value: "status"}}, + {Confirm: &ComponentButtonConfirm{Value: "Approve this order?"}}, + {Target: &ComponentButtonTarget{Value: "approval_modal"}}, + {Via: &ComponentButtonVia{Value: "api/orders/approve"}}, + }, }, }, - }, - { - Button: &ComponentButton{ - Name: "reject", - Label: "Reject", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "danger"}}, - {Icon: &ComponentButtonIcon{Value: "x"}}, - {Confirm: &ComponentButtonConfirm{Value: "Are you sure you want to reject this order?"}}, + { + Button: &ComponentButton{ + Name: "reject", + Label: "Reject", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "danger"}}, + {Icon: &ComponentButtonIcon{Value: "x"}}, + {Confirm: &ComponentButtonConfirm{Value: "Are you sure you want to reject this order?"}}, + }, }, }, - }, - { - Button: &ComponentButton{ - Name: "print", - Label: "Print", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "outline"}}, - {Icon: &ComponentButtonIcon{Value: "printer"}}, - {Position: &ComponentButtonPosition{Value: "right"}}, + { + Button: &ComponentButton{ + Name: "print", + Label: "Print", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "outline"}}, + {Icon: &ComponentButtonIcon{Value: "printer"}}, + {Position: &ComponentButtonPosition{Value: "right"}}, + }, }, }, }, @@ -482,12 +596,12 @@ func TestParseFieldValidationTypes(t *testing.T) { } page := got.Definitions[0].Page - if len(page.Components) != 1 || len(page.Components[0].Elements) != 1 { + if len(page.Elements) != 1 || page.Elements[0].Component == nil || len(page.Elements[0].Component.Elements) != 1 { t.Errorf("ParseInput() failed to parse component for validation %s", vt.validation) return } - element := page.Components[0].Elements[0] + element := page.Elements[0].Component.Elements[0] if element.Field == nil || len(element.Field.Attributes) != 1 { t.Errorf("ParseInput() failed to parse field attributes for validation %s", vt.validation) return @@ -527,7 +641,7 @@ func TestParseConditionalOperators(t *testing.T) { // Verify the when condition was parsed correctly page := got.Definitions[0].Page - component := page.Components[0] + component := page.Elements[0].Component whenElement := component.Elements[1].When if whenElement == nil || whenElement.Operator != op { diff --git a/lang/parser_ui_component_test.go b/lang/parser_ui_component_test.go index eaa9b77..98e43cc 100644 --- a/lang/parser_ui_component_test.go +++ b/lang/parser_ui_component_test.go @@ -23,10 +23,12 @@ func TestParseComponentDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Components: []Component{ + Elements: []PageElement{ { - Type: "table", - Entity: stringPtr("User"), + Component: &Component{ + Type: "table", + Entity: stringPtr("User"), + }, }, }, }, @@ -52,58 +54,60 @@ func TestParseComponentDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Components: []Component{ + Elements: []PageElement{ { - Type: "form", - Entity: stringPtr("User"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "name", - Type: "text", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Full Name")}, - {Placeholder: stringPtr("Enter your name")}, - {Required: true}, + Component: &Component{ + Type: "form", + Entity: stringPtr("User"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "name", + Type: "text", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Full Name")}, + {Placeholder: stringPtr("Enter your name")}, + {Required: true}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "email", - Type: "email", - Attributes: []ComponentFieldAttribute{ - {Label: stringPtr("Email Address")}, - {Required: true}, + { + Field: &ComponentField{ + Name: "email", + Type: "email", + Attributes: []ComponentFieldAttribute{ + {Label: stringPtr("Email Address")}, + {Required: true}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "bio", - Type: "textarea", - Attributes: []ComponentFieldAttribute{ - {Rows: intPtr(5)}, - {Placeholder: stringPtr("Tell us about yourself")}, + { + Field: &ComponentField{ + Name: "bio", + Type: "textarea", + Attributes: []ComponentFieldAttribute{ + {Rows: intPtr(5)}, + {Placeholder: stringPtr("Tell us about yourself")}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "avatar", - Type: "file", - Attributes: []ComponentFieldAttribute{ - {Accept: stringPtr("image/*")}, + { + Field: &ComponentField{ + Name: "avatar", + Type: "file", + Attributes: []ComponentFieldAttribute{ + {Accept: stringPtr("image/*")}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "role", - Type: "select", - Attributes: []ComponentFieldAttribute{ - {Options: []string{"admin", "user", "guest"}}, - {Default: stringPtr("user")}, + { + Field: &ComponentField{ + Name: "role", + Type: "select", + Attributes: []ComponentFieldAttribute{ + {Options: []string{"admin", "user", "guest"}}, + {Default: stringPtr("user")}, + }, }, }, }, @@ -135,70 +139,72 @@ func TestParseComponentDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Components: []Component{ + Elements: []PageElement{ { - Type: "form", - Entity: stringPtr("Product"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "name", - Type: "text", - Attributes: []ComponentFieldAttribute{ - {Required: true}, - {Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}}, + Component: &Component{ + Type: "form", + Entity: stringPtr("Product"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "name", + Type: "text", + Attributes: []ComponentFieldAttribute{ + {Required: true}, + {Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "price", - Type: "number", - Attributes: []ComponentFieldAttribute{ - {Format: stringPtr("currency")}, - {Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}}, + { + Field: &ComponentField{ + Name: "price", + Type: "number", + Attributes: []ComponentFieldAttribute{ + {Format: stringPtr("currency")}, + {Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "category", - Type: "autocomplete", - Attributes: []ComponentFieldAttribute{ - {Relates: &FieldRelation{Type: "Category"}}, + { + Field: &ComponentField{ + Name: "category", + Type: "autocomplete", + Attributes: []ComponentFieldAttribute{ + {Relates: &FieldRelation{Type: "Category"}}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "tags", - Type: "multiselect", - Attributes: []ComponentFieldAttribute{ - {Source: stringPtr("tags/popular")}, + { + Field: &ComponentField{ + Name: "tags", + Type: "multiselect", + Attributes: []ComponentFieldAttribute{ + {Source: stringPtr("tags/popular")}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "description", - Type: "richtext", - }, - }, - { - Field: &ComponentField{ - Name: "featured", - Type: "checkbox", - Attributes: []ComponentFieldAttribute{ - {Default: stringPtr("false")}, + { + Field: &ComponentField{ + Name: "description", + Type: "richtext", }, }, - }, - { - Field: &ComponentField{ - Name: "thumbnail", - Type: "image", - Attributes: []ComponentFieldAttribute{ - {Thumbnail: true}, + { + Field: &ComponentField{ + Name: "featured", + Type: "checkbox", + Attributes: []ComponentFieldAttribute{ + {Default: stringPtr("false")}, + }, + }, + }, + { + Field: &ComponentField{ + Name: "thumbnail", + Type: "image", + Attributes: []ComponentFieldAttribute{ + {Thumbnail: true}, + }, }, }, }, @@ -227,44 +233,46 @@ func TestParseComponentDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Components: []Component{ + Elements: []PageElement{ { - Type: "form", - Entity: stringPtr("User"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "name", - Type: "text", - }, - }, - { - Button: &ComponentButton{ - Name: "save", - Label: "Save User", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "primary"}}, - {Icon: &ComponentButtonIcon{Value: "save"}}, + Component: &Component{ + Type: "form", + Entity: stringPtr("User"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "name", + Type: "text", }, }, - }, - { - Button: &ComponentButton{ - Name: "cancel", - Label: "Cancel", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "secondary"}}, + { + Button: &ComponentButton{ + Name: "save", + Label: "Save User", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "primary"}}, + {Icon: &ComponentButtonIcon{Value: "save"}}, + }, }, }, - }, - { - Button: &ComponentButton{ - Name: "delete", - Label: "Delete", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "danger"}}, - {Confirm: &ComponentButtonConfirm{Value: "Are you sure?"}}, - {Disabled: &ComponentButtonDisabled{Value: "is_protected"}}, + { + Button: &ComponentButton{ + Name: "cancel", + Label: "Cancel", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "secondary"}}, + }, + }, + }, + { + Button: &ComponentButton{ + Name: "delete", + Label: "Delete", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "danger"}}, + {Confirm: &ComponentButtonConfirm{Value: "Are you sure?"}}, + {Disabled: &ComponentButtonDisabled{Value: "is_protected"}}, + }, }, }, }, @@ -298,55 +306,57 @@ func TestParseComponentDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Components: []Component{ + Elements: []PageElement{ { - Type: "form", - Entity: stringPtr("User"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "account_type", - Type: "select", - Attributes: []ComponentFieldAttribute{ - {Options: []string{"personal", "business"}}, + Component: &Component{ + Type: "form", + Entity: stringPtr("User"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "account_type", + Type: "select", + Attributes: []ComponentFieldAttribute{ + {Options: []string{"personal", "business"}}, + }, }, }, - }, - { - When: &WhenCondition{ - Field: "account_type", - Operator: "equals", - Value: "business", - Fields: []ComponentField{ - { - Name: "company_name", - Type: "text", - Attributes: []ComponentFieldAttribute{ - {Required: true}, + { + When: &WhenCondition{ + Field: "account_type", + Operator: "equals", + Value: "business", + Fields: []ComponentField{ + { + Name: "company_name", + Type: "text", + Attributes: []ComponentFieldAttribute{ + {Required: true}, + }, + }, + { + Name: "tax_id", + Type: "text", }, }, - { - Name: "tax_id", - Type: "text", - }, - }, - Buttons: []ComponentButton{ - { - Name: "verify_business", - Label: "Verify Business", + Buttons: []ComponentButton{ + { + Name: "verify_business", + Label: "Verify Business", + }, }, }, }, - }, - { - When: &WhenCondition{ - Field: "account_type", - Operator: "equals", - Value: "personal", - Fields: []ComponentField{ - { - Name: "date_of_birth", - Type: "date", + { + When: &WhenCondition{ + Field: "account_type", + Operator: "equals", + Value: "personal", + Fields: []ComponentField{ + { + Name: "date_of_birth", + Type: "date", + }, }, }, }, @@ -383,36 +393,38 @@ func TestParseComponentDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Components: []Component{ + Elements: []PageElement{ { - Type: "dashboard", - Elements: []ComponentElement{ - { - Section: &Section{ - Name: "stats", - Type: stringPtr("container"), - Class: stringPtr("stats-grid"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "metric", - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "total_users", - Type: "display", - Attributes: []ComponentFieldAttribute{ - {Value: stringPtr("1,234")}, + Component: &Component{ + Type: "dashboard", + Elements: []ComponentElement{ + { + Section: &Section{ + Name: "stats", + Type: stringPtr("container"), + Class: stringPtr("stats-grid"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "metric", + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "total_users", + Type: "display", + Attributes: []ComponentFieldAttribute{ + {Value: stringPtr("1,234")}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "revenue", - Type: "display", - Attributes: []ComponentFieldAttribute{ - {Format: stringPtr("currency")}, - {Value: stringPtr("45,678")}, + { + Field: &ComponentField{ + Name: "revenue", + Type: "display", + Attributes: []ComponentFieldAttribute{ + {Format: stringPtr("currency")}, + {Value: stringPtr("45,678")}, + }, }, }, }, @@ -421,20 +433,20 @@ func TestParseComponentDefinitions(t *testing.T) { }, }, }, - }, - { - Section: &Section{ - Name: "charts", - Type: stringPtr("container"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "chart", - Entity: stringPtr("Analytics"), - Elements: []ComponentElement{ - { - Attribute: &ComponentAttr{ - DataSource: stringPtr("analytics/monthly"), + { + Section: &Section{ + Name: "charts", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "chart", + Entity: stringPtr("Analytics"), + Elements: []ComponentElement{ + { + Attribute: &ComponentAttr{ + DataSource: stringPtr("analytics/monthly"), + }, }, }, }, @@ -461,7 +473,7 @@ func TestParseComponentDefinitions(t *testing.T) { return } if !astEqual(got, tt.want) { - t.Errorf("ParseInput() got = %v, want %v", got, tt.want) + t.Errorf("ParseInput() got = %+v, want %+v", got, tt.want) } }) } @@ -495,14 +507,20 @@ func TestParseComponentFieldTypes(t *testing.T) { } page := got.Definitions[0].Page - if len(page.Components) != 1 || len(page.Components[0].Elements) != 1 { + if len(page.Elements) != 1 { t.Errorf("ParseInput() failed to parse component for field type %s", fieldType) return } - element := page.Components[0].Elements[0] - if element.Field == nil || element.Field.Type != fieldType { - t.Errorf("ParseInput() field type mismatch: got %v, want %s", element.Field, fieldType) + element := page.Elements[0] + if element.Component == nil || len(element.Component.Elements) != 1 { + t.Errorf("ParseInput() failed to parse component for field type %s", fieldType) + return + } + + fieldElement := element.Component.Elements[0] + if fieldElement.Field == nil || fieldElement.Field.Type != fieldType { + t.Errorf("ParseInput() field type mismatch: got %v, want %s", fieldElement.Field, fieldType) } }) } diff --git a/lang/parser_ui_page_test.go b/lang/parser_ui_page_test.go index 83efbca..3d1d5e9 100644 --- a/lang/parser_ui_page_test.go +++ b/lang/parser_ui_page_test.go @@ -12,14 +12,14 @@ func TestParsePageDefinitions(t *testing.T) { wantErr bool }{ { - name: "basic page with minimal fields", - input: `page Dashboard at "/dashboard" layout main`, + name: "basic page definition", + input: `page Home at "/" layout main`, want: AST{ Definitions: []Definition{ { Page: &Page{ - Name: "Dashboard", - Path: "/dashboard", + Name: "Home", + Path: "/", Layout: "main", }, }, @@ -27,17 +27,17 @@ func TestParsePageDefinitions(t *testing.T) { }, }, { - name: "page with all optional fields", - input: `page UserProfile at "/profile" layout main title "User Profile" desc "Manage user profile settings" auth`, + name: "page with optional fields", + input: `page Settings at "/settings" layout main title "User Settings" desc "Manage your account settings" auth`, want: AST{ Definitions: []Definition{ { Page: &Page{ - Name: "UserProfile", - Path: "/profile", + Name: "Settings", + Path: "/settings", Layout: "main", - Title: stringPtr("User Profile"), - Description: stringPtr("Manage user profile settings"), + Title: stringPtr("User Settings"), + Description: stringPtr("Manage your account settings"), Auth: true, }, }, @@ -46,20 +46,22 @@ func TestParsePageDefinitions(t *testing.T) { }, { name: "page with meta tags", - input: `page HomePage at "/" layout main { - meta description "Welcome to our application" - meta keywords "app, dashboard, management" + input: `page Settings at "/settings" layout main { + meta description "Settings page description" + meta keywords "settings, user, account" + meta author "My App" }`, want: AST{ Definitions: []Definition{ { Page: &Page{ - Name: "HomePage", - Path: "/", + Name: "Settings", + Path: "/settings", Layout: "main", Meta: []MetaTag{ - {Name: "description", Content: "Welcome to our application"}, - {Name: "keywords", Content: "app, dashboard, management"}, + {Name: "description", Content: "Settings page description"}, + {Name: "keywords", Content: "settings, user, account"}, + {Name: "author", Content: "My App"}, }, }, }, @@ -67,19 +69,17 @@ func TestParsePageDefinitions(t *testing.T) { }, }, { - name: "page with nested sections", + name: "page with sections", input: `page Settings at "/settings" layout main { section tabs type tab { section profile label "Profile" active { - component form for User { - field name type text - } + component form for User } - section security label "Security" { - component form for User { - field password type password - } + component form for Security + } + section notifications label "Notifications" { + component toggle for NotificationSettings } } }`, @@ -90,50 +90,50 @@ func TestParsePageDefinitions(t *testing.T) { Name: "Settings", Path: "/settings", Layout: "main", - Sections: []Section{ + Elements: []PageElement{ { - Name: "tabs", - Type: stringPtr("tab"), - Elements: []SectionElement{ - { - Section: &Section{ - Name: "profile", - Label: stringPtr("Profile"), - Active: true, - Elements: []SectionElement{ - { - Component: &Component{ - Type: "form", - Entity: stringPtr("User"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "name", - Type: "text", - }, - }, + Section: &Section{ + Name: "tabs", + Type: stringPtr("tab"), + Elements: []SectionElement{ + { + Section: &Section{ + Name: "profile", + Label: stringPtr("Profile"), + Active: true, + Elements: []SectionElement{ + { + Component: &Component{ + Type: "form", + Entity: stringPtr("User"), }, }, }, }, }, - }, - { - Section: &Section{ - Name: "security", - Label: stringPtr("Security"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "form", - Entity: stringPtr("User"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "password", - Type: "password", - }, - }, + { + Section: &Section{ + Name: "security", + Label: stringPtr("Security"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "form", + Entity: stringPtr("Security"), + }, + }, + }, + }, + }, + { + Section: &Section{ + Name: "notifications", + Label: stringPtr("Notifications"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "toggle", + Entity: stringPtr("NotificationSettings"), }, }, }, @@ -149,71 +149,124 @@ func TestParsePageDefinitions(t *testing.T) { }, }, { - name: "page with modal and panel sections", - input: `page ProductList at "/products" layout main { - section main type container { - component table for Product + name: "page with components", + input: `page Dashboard at "/dashboard" layout main { + component stats for Analytics { + field total_users type display + field revenue type display format "currency" } - - section editModal type modal trigger "edit-product" { - component form for Product { - field name type text required - button save label "Save Changes" style "primary" - } - } - - section filters type panel position "left" { - component form { - field category type select - field price_range type range - } + component chart for SalesData { + data from "analytics/sales" } }`, want: AST{ Definitions: []Definition{ { Page: &Page{ - Name: "ProductList", - Path: "/products", + Name: "Dashboard", + Path: "/dashboard", Layout: "main", - Sections: []Section{ + Elements: []PageElement{ { - Name: "main", - Type: stringPtr("container"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "table", - Entity: stringPtr("Product"), + Component: &Component{ + Type: "stats", + Entity: stringPtr("Analytics"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "total_users", + Type: "display", + }, + }, + { + Field: &ComponentField{ + Name: "revenue", + Type: "display", + Attributes: []ComponentFieldAttribute{ + {Format: stringPtr("currency")}, + }, + }, }, }, }, }, { - Name: "editModal", - Type: stringPtr("modal"), - Trigger: stringPtr("edit-product"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "form", - Entity: stringPtr("Product"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "name", - Type: "text", - Attributes: []ComponentFieldAttribute{ - {Required: true}, - }, - }, - }, - { - Button: &ComponentButton{ - Name: "save", - Label: "Save Changes", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "primary"}}, + Component: &Component{ + Type: "chart", + Entity: stringPtr("SalesData"), + Elements: []ComponentElement{ + { + Attribute: &ComponentAttr{ + DataSource: stringPtr("analytics/sales"), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "page with mixed sections and components", + input: `page Home at "/" layout main { + component hero for Banner { + field title type display + field subtitle type display + } + section content type container { + component posts for Post { + fields [title, excerpt, date] + } + } + component newsletter for Subscription { + field email type email required + button subscribe label "Subscribe" + } + }`, + want: AST{ + Definitions: []Definition{ + { + Page: &Page{ + Name: "Home", + Path: "/", + Layout: "main", + Elements: []PageElement{ + { + Component: &Component{ + Type: "hero", + Entity: stringPtr("Banner"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "title", + Type: "display", + }, + }, + { + Field: &ComponentField{ + Name: "subtitle", + Type: "display", + }, + }, + }, + }, + }, + { + Section: &Section{ + Name: "content", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "posts", + Entity: stringPtr("Post"), + Elements: []ComponentElement{ + { + Attribute: &ComponentAttr{ + Fields: []string{"title", "excerpt", "date"}, }, }, }, @@ -223,28 +276,25 @@ func TestParsePageDefinitions(t *testing.T) { }, }, { - Name: "filters", - Type: stringPtr("panel"), - Position: stringPtr("left"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "form", - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "category", - Type: "select", - }, - }, - { - Field: &ComponentField{ - Name: "price_range", - Type: "range", - }, + Component: &Component{ + Type: "newsletter", + Entity: stringPtr("Subscription"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "email", + Type: "email", + Attributes: []ComponentFieldAttribute{ + {Required: true}, }, }, }, + { + Button: &ComponentButton{ + Name: "subscribe", + Label: "Subscribe", + }, + }, }, }, }, @@ -264,7 +314,66 @@ func TestParsePageDefinitions(t *testing.T) { return } if !astEqual(got, tt.want) { - t.Errorf("ParseInput() got = %v, want %v", got, tt.want) + t.Errorf("ParseInput() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParsePageLayouts(t *testing.T) { + layouts := []string{"main", "admin", "public", "auth", "minimal", "dashboard"} + + for _, layout := range layouts { + t.Run("layout_"+layout, func(t *testing.T) { + input := `page Test at "/test" layout ` + layout + + got, err := ParseInput(input) + if err != nil { + t.Errorf("ParseInput() failed for layout %s: %v", layout, err) + return + } + + if len(got.Definitions) != 1 || got.Definitions[0].Page == nil { + t.Errorf("ParseInput() failed to parse page for layout %s", layout) + return + } + + page := got.Definitions[0].Page + if page.Layout != layout { + t.Errorf("ParseInput() layout mismatch: got %s, want %s", page.Layout, layout) + } + }) + } +} + +func TestParsePagePaths(t *testing.T) { + tests := []struct { + name string + path string + }{ + {"root", "/"}, + {"simple", "/about"}, + {"nested", "/admin/users"}, + {"deep_nested", "/api/v1/users/profile"}, + {"with_params", "/users/:id"}, + {"with_multiple_params", "/users/:userId/posts/:postId"}, + {"with_query", "/search?q=:query"}, + {"with_extension", "/api/users.json"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := `page Test at "` + tt.path + `" layout main` + + got, err := ParseInput(input) + if err != nil { + t.Errorf("ParseInput() failed for path %s: %v", tt.path, err) + return + } + + page := got.Definitions[0].Page + if page.Path != tt.path { + t.Errorf("ParseInput() path mismatch: got %s, want %s", page.Path, tt.path) } }) } @@ -276,16 +385,26 @@ func TestParsePageErrors(t *testing.T) { input string }{ { - name: "missing layout", - input: `page Dashboard at "/dashboard"`, + name: "missing page name", + input: `page at "/" layout main`, }, { name: "missing path", - input: `page Dashboard layout main`, + input: `page Test layout main`, + }, + { + name: "missing layout", + input: `page Test at "/"`, }, { name: "invalid path format", - input: `page Dashboard at dashboard layout main`, + input: `page Test at /invalid layout main`, + }, + { + name: "unclosed page block", + input: `page Test at "/" layout main { + section test type container + `, }, } diff --git a/lang/parser_ui_section_test.go b/lang/parser_ui_section_test.go index a2bbbc2..3e8a411 100644 --- a/lang/parser_ui_section_test.go +++ b/lang/parser_ui_section_test.go @@ -23,10 +23,12 @@ func TestParseSectionDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Sections: []Section{ + Elements: []PageElement{ { - Name: "main", - Type: stringPtr("container"), + Section: &Section{ + Name: "main", + Type: stringPtr("container"), + }, }, }, }, @@ -46,15 +48,57 @@ func TestParseSectionDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Sections: []Section{ + Elements: []PageElement{ { - Name: "sidebar", - Type: stringPtr("panel"), - Position: stringPtr("left"), - Class: stringPtr("sidebar-nav"), - Label: stringPtr("Navigation"), - Trigger: stringPtr("toggle-sidebar"), - Entity: stringPtr("User"), + Section: &Section{ + Name: "sidebar", + Type: stringPtr("panel"), + Class: stringPtr("sidebar-nav"), + Label: stringPtr("Navigation"), + Trigger: stringPtr("toggle-sidebar"), + Position: stringPtr("left"), + Entity: stringPtr("User"), + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "sections with separate attributes", + input: `page Dashboard at "/dashboard" layout main { + section content type container { + data from "/api/data" + style "padding: 20px" + } + }`, + want: AST{ + Definitions: []Definition{ + { + Page: &Page{ + Name: "Dashboard", + Path: "/dashboard", + Layout: "main", + Elements: []PageElement{ + { + Section: &Section{ + Name: "content", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + Attribute: &SectionAttribute{ + DataSource: stringPtr("/api/data"), + }, + }, + { + Attribute: &SectionAttribute{ + Style: stringPtr("padding: 20px"), + }, + }, + }, + }, }, }, }, @@ -78,28 +122,30 @@ func TestParseSectionDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Sections: []Section{ + Elements: []PageElement{ { - Name: "tabs", - Type: stringPtr("tab"), - Elements: []SectionElement{ - { - Section: &Section{ - Name: "overview", - Label: stringPtr("Overview"), - Active: true, + Section: &Section{ + Name: "tabs", + Type: stringPtr("tab"), + Elements: []SectionElement{ + { + Section: &Section{ + Name: "overview", + Label: stringPtr("Overview"), + Active: true, + }, }, - }, - { - Section: &Section{ - Name: "details", - Label: stringPtr("Details"), + { + Section: &Section{ + Name: "details", + Label: stringPtr("Details"), + }, }, - }, - { - Section: &Section{ - Name: "settings", - Label: stringPtr("Settings"), + { + Section: &Section{ + Name: "settings", + Label: stringPtr("Settings"), + }, }, }, }, @@ -129,50 +175,52 @@ func TestParseSectionDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Sections: []Section{ + Elements: []PageElement{ { - Name: "userModal", - Type: stringPtr("modal"), - Trigger: stringPtr("edit-user"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "form", - Entity: stringPtr("User"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "name", - Type: "text", - Attributes: []ComponentFieldAttribute{ - {Required: true}, + Section: &Section{ + Name: "userModal", + Type: stringPtr("modal"), + Trigger: stringPtr("edit-user"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "form", + Entity: stringPtr("User"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "name", + Type: "text", + Attributes: []ComponentFieldAttribute{ + {Required: true}, + }, }, }, - }, - { - Field: &ComponentField{ - Name: "email", - Type: "email", - Attributes: []ComponentFieldAttribute{ - {Required: true}, + { + Field: &ComponentField{ + Name: "email", + Type: "email", + Attributes: []ComponentFieldAttribute{ + {Required: true}, + }, }, }, - }, - { - Button: &ComponentButton{ - Name: "save", - Label: "Save Changes", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "primary"}}, + { + Button: &ComponentButton{ + Name: "save", + Label: "Save Changes", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "primary"}}, + }, }, }, - }, - { - Button: &ComponentButton{ - Name: "cancel", - Label: "Cancel", - Attributes: []ComponentButtonAttr{ - {Style: &ComponentButtonStyle{Value: "secondary"}}, + { + Button: &ComponentButton{ + Name: "cancel", + Label: "Cancel", + Attributes: []ComponentButtonAttr{ + {Style: &ComponentButtonStyle{Value: "secondary"}}, + }, }, }, }, @@ -213,24 +261,26 @@ func TestParseSectionDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Sections: []Section{ + Elements: []PageElement{ { - Name: "masterDetail", - Type: stringPtr("master"), - Elements: []SectionElement{ - { - Section: &Section{ - Name: "userList", - Type: stringPtr("container"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "table", - Entity: stringPtr("User"), - Elements: []ComponentElement{ - { - Attribute: &ComponentAttr{ - Fields: []string{"name", "email"}, + Section: &Section{ + Name: "masterDetail", + Type: stringPtr("master"), + Elements: []SectionElement{ + { + Section: &Section{ + Name: "userList", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "table", + Entity: stringPtr("User"), + Elements: []ComponentElement{ + { + Attribute: &ComponentAttr{ + Fields: []string{"name", "email"}, + }, }, }, }, @@ -238,35 +288,35 @@ func TestParseSectionDefinitions(t *testing.T) { }, }, }, - }, - { - Section: &Section{ - Name: "userDetail", - Type: stringPtr("detail"), - Trigger: stringPtr("user-selected"), - Entity: stringPtr("User"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "form", - Entity: stringPtr("User"), - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "name", - Type: "text", + { + Section: &Section{ + Name: "userDetail", + Type: stringPtr("detail"), + Trigger: stringPtr("user-selected"), + Entity: stringPtr("User"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "form", + Entity: stringPtr("User"), + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "name", + Type: "text", + }, }, - }, - { - Field: &ComponentField{ - Name: "email", - Type: "email", + { + Field: &ComponentField{ + Name: "email", + Type: "email", + }, }, - }, - { - Field: &ComponentField{ - Name: "bio", - Type: "textarea", + { + Field: &ComponentField{ + Name: "bio", + Type: "textarea", + }, }, }, }, @@ -323,27 +373,29 @@ func TestParseSectionDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Sections: []Section{ + Elements: []PageElement{ { - Name: "mainLayout", - Type: stringPtr("container"), - Elements: []SectionElement{ - { - Section: &Section{ - Name: "header", - Type: stringPtr("container"), - Class: stringPtr("header"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "navbar", - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "search", - Type: "text", - Attributes: []ComponentFieldAttribute{ - {Placeholder: stringPtr("Search...")}, + Section: &Section{ + Name: "mainLayout", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + Section: &Section{ + Name: "header", + Type: stringPtr("container"), + Class: stringPtr("header"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "navbar", + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "search", + Type: "text", + Attributes: []ComponentFieldAttribute{ + {Placeholder: stringPtr("Search...")}, + }, }, }, }, @@ -352,26 +404,26 @@ func TestParseSectionDefinitions(t *testing.T) { }, }, }, - }, - { - Section: &Section{ - Name: "content", - Type: stringPtr("container"), - Elements: []SectionElement{ - { - Section: &Section{ - Name: "sidebar", - Type: stringPtr("panel"), - Position: stringPtr("left"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "menu", - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "navigation", - Type: "list", + { + Section: &Section{ + Name: "content", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + Section: &Section{ + Name: "sidebar", + Type: stringPtr("panel"), + Position: stringPtr("left"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "menu", + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "navigation", + Type: "list", + }, }, }, }, @@ -379,31 +431,31 @@ func TestParseSectionDefinitions(t *testing.T) { }, }, }, - }, - { - Section: &Section{ - Name: "main", - Type: stringPtr("container"), - Elements: []SectionElement{ - { - Section: &Section{ - Name: "tabs", - Type: stringPtr("tab"), - Elements: []SectionElement{ - { - Section: &Section{ - Name: "overview", - Label: stringPtr("Overview"), - Active: true, - Elements: []SectionElement{ - { - Component: &Component{ - Type: "dashboard", - Elements: []ComponentElement{ - { - Field: &ComponentField{ - Name: "stats", - Type: "metric", + { + Section: &Section{ + Name: "main", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + Section: &Section{ + Name: "tabs", + Type: stringPtr("tab"), + Elements: []SectionElement{ + { + Section: &Section{ + Name: "overview", + Label: stringPtr("Overview"), + Active: true, + Elements: []SectionElement{ + { + Component: &Component{ + Type: "dashboard", + Elements: []ComponentElement{ + { + Field: &ComponentField{ + Name: "stats", + Type: "metric", + }, }, }, }, @@ -411,16 +463,16 @@ func TestParseSectionDefinitions(t *testing.T) { }, }, }, - }, - { - Section: &Section{ - Name: "reports", - Label: stringPtr("Reports"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "table", - Entity: stringPtr("Report"), + { + Section: &Section{ + Name: "reports", + Label: stringPtr("Reports"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "table", + Entity: stringPtr("Report"), + }, }, }, }, @@ -464,37 +516,39 @@ func TestParseSectionDefinitions(t *testing.T) { Name: "Test", Path: "/test", Layout: "main", - Sections: []Section{ + Elements: []PageElement{ { - Name: "adminPanel", - Type: stringPtr("container"), - Elements: []SectionElement{ - { - When: &WhenCondition{ - Field: "user_role", - Operator: "equals", - Value: "admin", - Sections: []Section{ - { - Name: "userManagement", - Type: stringPtr("container"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "table", - Entity: stringPtr("User"), + Section: &Section{ + Name: "adminPanel", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + When: &WhenCondition{ + Field: "user_role", + Operator: "equals", + Value: "admin", + Sections: []Section{ + { + Name: "userManagement", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "table", + Entity: stringPtr("User"), + }, }, }, }, - }, - { - Name: "systemSettings", - Type: stringPtr("container"), - Elements: []SectionElement{ - { - Component: &Component{ - Type: "form", - Entity: stringPtr("Settings"), + { + Name: "systemSettings", + Type: stringPtr("container"), + Elements: []SectionElement{ + { + Component: &Component{ + Type: "form", + Entity: stringPtr("Settings"), + }, }, }, }, @@ -520,7 +574,7 @@ func TestParseSectionDefinitions(t *testing.T) { return } if !astEqual(got, tt.want) { - t.Errorf("ParseInput() got = %v, want %v", got, tt.want) + t.Errorf("ParseInput() got = %+v, want %+v", got, tt.want) } }) } @@ -549,12 +603,12 @@ func TestParseSectionTypes(t *testing.T) { } page := got.Definitions[0].Page - if len(page.Sections) != 1 { + if len(page.Elements) != 1 || page.Elements[0].Section == nil { t.Errorf("ParseInput() failed to parse section for type %s", sectionType) return } - section := page.Sections[0] + section := page.Elements[0].Section if section.Type == nil || *section.Type != sectionType { t.Errorf("ParseInput() section type mismatch: got %v, want %s", section.Type, sectionType) } diff --git a/lang/test_ui_comparisons.go b/lang/test_ui_comparisons.go index 88a191e..2c5b855 100644 --- a/lang/test_ui_comparisons.go +++ b/lang/test_ui_comparisons.go @@ -30,24 +30,13 @@ func pageEqual(got, want Page) bool { } } - // Compare sections (unified model) - if len(got.Sections) != len(want.Sections) { + // Compare elements (unified model) + if len(got.Elements) != len(want.Elements) { return false } - for i, section := range got.Sections { - if !sectionEqual(section, want.Sections[i]) { - return false - } - } - - // Compare components - if len(got.Components) != len(want.Components) { - return false - } - - for i, component := range got.Components { - if !componentEqual(component, want.Components[i]) { + for i, element := range got.Elements { + if !pageElementEqual(element, want.Elements[i]) { return false } } @@ -55,6 +44,28 @@ func pageEqual(got, want Page) bool { return true } +func pageElementEqual(got, want PageElement) bool { + // Both should have either a Section or Component, but not both + if (got.Section == nil) != (want.Section == nil) { + return false + } + if (got.Component == nil) != (want.Component == nil) { + return false + } + + // Compare sections if present + if got.Section != nil && want.Section != nil { + return sectionEqual(*got.Section, *want.Section) + } + + // Compare components if present + if got.Component != nil && want.Component != nil { + return componentEqual(*got.Component, *want.Component) + } + + return false +} + func metaTagEqual(got, want MetaTag) bool { return got.Name == want.Name && got.Content == want.Content } diff --git a/temp_multi-output-template-plan.md b/temp_multi-output-template-plan.md index 8b5c37e..7343e47 100644 --- a/temp_multi-output-template-plan.md +++ b/temp_multi-output-template-plan.md @@ -583,13 +583,19 @@ export default function {{.Page.Name}}Page() { {/* Protected content */} {{end}} - {{range .Page.Sections}} -
- {{if .Label}}

{{.Label | derefString}}

{{end}} - {{range .Components}} - {/* Component: {{.Type}} */} + {{range .Page.Elements}} + {{if .Section}} +
+ {{if .Section.Label}}

{{.Section.Label | derefString}}

{{end}} + {{range .Section.Elements}} + {{if .Component}} + {/* Component: {{.Component.Type}} */} + {{end}} {{end}}
+ {{else if .Component}} + {/* Component: {{.Component.Type}} */} + {{end}} {{end}}