allow for sections and components in any order on pages

This commit is contained in:
2025-09-09 22:30:00 -06:00
parent 88d757546a
commit b82e22c38d
11 changed files with 1179 additions and 848 deletions

View File

@ -96,7 +96,7 @@ func main() {
for _, def := range ast.Definitions { for _, def := range ast.Definitions {
if def.Page != nil { if def.Page != nil {
pageCount++ 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 { if totalContent > 0 {
fmt.Printf(" ✓ Page '%s' has %d content items (block syntax working)\n", def.Page.Name, totalContent) 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 var totalSections, nestedSections int
for _, def := range ast.Definitions { for _, def := range ast.Definitions {
if def.Page != nil { if def.Page != nil {
totalSections += len(def.Page.Sections) for _, element := range def.Page.Elements {
for _, section := range def.Page.Sections { if element.Section != nil {
nestedSections += countNestedSections(section) totalSections++
nestedSections += countNestedSections(*element.Section)
}
} }
} }
} }

View File

@ -2,9 +2,9 @@ import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
{{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}'; {{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}';
{{end}}{{end}} {{end}}{{end}}
{{range .Page.Sections}}import {{.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Name | title}}Section'; {{range .Page.Elements}}{{if .Section}}import {{.Section.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Section.Name | title}}Section';
{{end}}{{range .Page.Components}}import {{.Type | title}}Component from '{{$relativePrefix}}components/{{.Type | title}}Component'; {{end}}{{if .Component}}import {{.Component.Type | title}}Component from '{{$relativePrefix}}components/{{.Component.Type | title}}Component';
{{end}} {{end}}{{end}}
export default function {{.Page.Name}}Page() { export default function {{.Page.Name}}Page() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -53,12 +53,13 @@ export default function {{.Page.Name}}Page() {
{{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}} {{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}}
</h1> </h1>
{{range .Page.Sections}} {{range .Page.Elements}}
<{{.Name | title}}Section /> {{if .Section}}
<{{.Section.Name | title}}Section />
{{end}}
{{if .Component}}
<{{.Component.Type | title}}Component />
{{end}} {{end}}
{{range .Page.Components}}
<{{.Type | title}}Component />
{{end}} {{end}}
</div> </div>
</main> </main>

View File

@ -2,9 +2,9 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
{{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}'; {{$relativePrefix := relativePrefix .Page.Path}}{{range .AST.Definitions}}{{if .Entity}}import { {{.Entity.Name}} } from '{{$relativePrefix}}types/{{.Entity.Name}}';
{{end}}{{end}} {{end}}{{end}}
{{range .Page.Sections}}import {{.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Name | title}}Section'; {{range .Page.Elements}}{{if .Section}}import {{.Section.Name | title}}Section from '{{$relativePrefix}}components/sections/{{.Section.Name | title}}Section';
{{end}}{{range .Page.Components}}import {{.Type | title}}Component from '{{$relativePrefix}}components/{{.Type | title}}Component'; {{end}}{{if .Component}}import {{.Component.Type | title}}Component from '{{$relativePrefix}}components/{{.Component.Type | title}}Component';
{{end}} {{end}}{{end}}
export default function {{.Page.Name}}Page() { export default function {{.Page.Name}}Page() {
return ( return (
@ -36,12 +36,13 @@ export default function {{.Page.Name}}Page() {
{{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}} {{if .Page.Title}}{{.Page.Title | derefString}}{{else}}{{.Page.Name}}{{end}}
</h1> </h1>
{{range .Page.Sections}} {{range .Page.Elements}}
<{{.Name | title}}Section /> {{if .Section}}
<{{.Section.Name | title}}Section />
{{end}}
{{if .Component}}
<{{.Component.Type | title}}Component />
{{end}} {{end}}
{{range .Page.Components}}
<{{.Type | title}}Component />
{{end}} {{end}}
</div> </div>
</main> </main>

View File

@ -117,22 +117,22 @@ func (hi *HTMLInterpreter) generatePageHTML(page *lang.Page) (string, error) {
html.WriteString(" <div class=\"container\">\n") html.WriteString(" <div class=\"container\">\n")
html.WriteString(fmt.Sprintf(" <h1>%s</h1>\n", hi.escapeHTML(title))) html.WriteString(fmt.Sprintf(" <h1>%s</h1>\n", hi.escapeHTML(title)))
// Generate sections // Generate page elements
for _, section := range page.Sections { for _, element := range page.Elements {
sectionHTML, err := hi.generateSectionHTML(&section, 2) if element.Section != nil {
if err != nil { sectionHTML, err := hi.generateSectionHTML(element.Section, 2)
return "", err if err != nil {
return "", err
}
html.WriteString(sectionHTML)
} }
html.WriteString(sectionHTML) if element.Component != nil {
} componentHTML, err := hi.generateComponentHTML(element.Component, 2)
if err != nil {
// Generate direct components return "", err
for _, component := range page.Components { }
componentHTML, err := hi.generateComponentHTML(&component, 2) html.WriteString(componentHTML)
if err != nil {
return "", err
} }
html.WriteString(componentHTML)
} }
html.WriteString(" </div>\n") html.WriteString(" </div>\n")

View File

@ -122,15 +122,20 @@ type ResponseSpec struct {
// Page Enhanced Page definitions with unified section model // Page Enhanced Page definitions with unified section model
type Page struct { type Page struct {
Name string `parser:"'page' @Ident"` Name string `parser:"'page' @Ident"`
Path string `parser:"'at' @String"` Path string `parser:"'at' @String"`
Layout string `parser:"'layout' @Ident"` Layout string `parser:"'layout' @Ident"`
Title *string `parser:"('title' @String)?"` Title *string `parser:"('title' @String)?"`
Description *string `parser:"('desc' @String)?"` Description *string `parser:"('desc' @String)?"`
Auth bool `parser:"@'auth'?"` Auth bool `parser:"@'auth'?"`
Meta []MetaTag `parser:"('{' @@*"` // Block-delimited content Meta []MetaTag `parser:"('{' @@*"` // Block-delimited content
Sections []Section `parser:"@@*"` // Unified sections replace containers/tabs/panels/modals Elements []PageElement `parser:"@@* '}')?"` // Unified elements allowing any order
Components []Component `parser:"@@* '}')?"` // Direct components within the block }
// 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 // MetaTag Meta tags for SEO

View File

@ -4,13 +4,121 @@ import (
"testing" "testing"
) )
func TestParseAdvancedUIFeatures(t *testing.T) { func TestParseAdvancedUIStructures(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
want AST want AST
wantErr bool 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", name: "complex conditional rendering with multiple operators",
input: `page Test at "/test" layout main { input: `page Test at "/test" layout main {
@ -42,86 +150,88 @@ func TestParseAdvancedUIFeatures(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Components: []Component{ Elements: []PageElement{
{ {
Type: "form", Component: &Component{
Entity: stringPtr("User"), Type: "form",
Elements: []ComponentElement{ Entity: stringPtr("User"),
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "status", Field: &ComponentField{
Type: "select", Name: "status",
Attributes: []ComponentFieldAttribute{ Type: "select",
{Options: []string{"active", "inactive", "pending"}}, Attributes: []ComponentFieldAttribute{
{Options: []string{"active", "inactive", "pending"}},
},
}, },
}, },
}, {
{ When: &WhenCondition{
When: &WhenCondition{ Field: "status",
Field: "status", Operator: "equals",
Operator: "equals", Value: "active",
Value: "active", Fields: []ComponentField{
Fields: []ComponentField{ {Name: "last_login", Type: "datetime"},
{Name: "last_login", Type: "datetime"}, {Name: "permissions", Type: "multiselect"},
{Name: "permissions", Type: "multiselect"}, },
}, Buttons: []ComponentButton{
Buttons: []ComponentButton{ {
{ Name: "deactivate",
Name: "deactivate", Label: "Deactivate User",
Label: "Deactivate User", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "warning"}},
{Style: &ComponentButtonStyle{Value: "warning"}}, },
}, },
}, },
}, },
}, },
}, {
{ When: &WhenCondition{
When: &WhenCondition{ Field: "status",
Field: "status", Operator: "not_equals",
Operator: "not_equals", Value: "active",
Value: "active", Fields: []ComponentField{
Fields: []ComponentField{ {
{ Name: "reason",
Name: "reason", Type: "textarea",
Type: "textarea", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Placeholder: stringPtr("Reason for status")},
{Placeholder: stringPtr("Reason for status")}, },
}, },
}, },
}, Buttons: []ComponentButton{
Buttons: []ComponentButton{ {
{ Name: "activate",
Name: "activate", Label: "Activate User",
Label: "Activate User", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "success"}},
{Style: &ComponentButtonStyle{Value: "success"}}, },
}, },
}, },
}, },
}, },
}, {
{ When: &WhenCondition{
When: &WhenCondition{ Field: "status",
Field: "status", Operator: "contains",
Operator: "contains", Value: "pending",
Value: "pending", Fields: []ComponentField{
Fields: []ComponentField{ {Name: "approval_date", Type: "date"},
{Name: "approval_date", Type: "date"},
},
Buttons: []ComponentButton{
{
Name: "approve",
Label: "Approve",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
},
}, },
{ Buttons: []ComponentButton{
Name: "reject", {
Label: "Reject", Name: "approve",
Attributes: []ComponentButtonAttr{ Label: "Approve",
{Style: &ComponentButtonStyle{Value: "danger"}}, 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", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Components: []Component{ Elements: []PageElement{
{ {
Type: "form", Component: &Component{
Entity: stringPtr("Product"), Type: "form",
Elements: []ComponentElement{ Entity: stringPtr("Product"),
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "name", Field: &ComponentField{
Type: "text", Name: "name",
Attributes: []ComponentFieldAttribute{ Type: "text",
{Label: stringPtr("Product Name")}, Attributes: []ComponentFieldAttribute{
{Placeholder: stringPtr("Enter product name")}, {Label: stringPtr("Product Name")},
{Required: true}, {Placeholder: stringPtr("Enter product name")},
{Default: stringPtr("New Product")}, {Required: true},
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}}, {Default: stringPtr("New Product")},
{Size: stringPtr("large")}, {Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
{Display: stringPtr("block")}, {Size: stringPtr("large")},
{Display: stringPtr("block")},
},
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "price",
Name: "price", Type: "number",
Type: "number", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Label: stringPtr("Price ($)")},
{Label: stringPtr("Price ($)")}, {Format: stringPtr("currency")},
{Format: stringPtr("currency")}, {Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}},
{Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}}, {Validation: &ComponentValidation{Type: "max", Value: stringPtr("10000")}},
{Validation: &ComponentValidation{Type: "max", Value: stringPtr("10000")}}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "category",
Name: "category", Type: "autocomplete",
Type: "autocomplete", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Label: stringPtr("Category")},
{Label: stringPtr("Category")}, {Placeholder: stringPtr("Start typing...")},
{Placeholder: stringPtr("Start typing...")}, {Relates: &FieldRelation{Type: "Category"}},
{Relates: &FieldRelation{Type: "Category"}}, {Searchable: true},
{Searchable: true}, {Source: stringPtr("categories/search")},
{Source: stringPtr("categories/search")}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "tags",
Name: "tags", Type: "multiselect",
Type: "multiselect", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Label: stringPtr("Tags")},
{Label: stringPtr("Tags")}, {Options: []string{"electronics", "clothing", "books", "home"}},
{Options: []string{"electronics", "clothing", "books", "home"}}, {Source: stringPtr("tags/popular")},
{Source: stringPtr("tags/popular")}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "description",
Name: "description", Type: "richtext",
Type: "richtext", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Label: stringPtr("Description")},
{Label: stringPtr("Description")}, {Rows: intPtr(10)},
{Rows: intPtr(10)}, {Placeholder: stringPtr("Describe your product...")},
{Placeholder: stringPtr("Describe your product...")}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "thumbnail",
Name: "thumbnail", Type: "image",
Type: "image", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Label: stringPtr("Product Image")},
{Label: stringPtr("Product Image")}, {Accept: stringPtr("image/jpeg,image/png")},
{Accept: stringPtr("image/jpeg,image/png")}, {Thumbnail: true},
{Thumbnail: true}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "featured",
Name: "featured", Type: "checkbox",
Type: "checkbox", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Label: stringPtr("Featured Product")},
{Label: stringPtr("Featured Product")}, {Default: stringPtr("false")},
{Default: stringPtr("false")}, {Value: stringPtr("true")},
{Value: stringPtr("true")}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "availability",
Name: "availability", Type: "select",
Type: "select", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Label: stringPtr("Availability")},
{Label: stringPtr("Availability")}, {Options: []string{"in_stock", "out_of_stock", "pre_order"}},
{Options: []string{"in_stock", "out_of_stock", "pre_order"}}, {Default: stringPtr("in_stock")},
{Default: stringPtr("in_stock")}, {Sortable: true},
{Sortable: true}, },
}, },
}, },
}, },
@ -332,76 +444,78 @@ func TestParseAdvancedUIFeatures(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Components: []Component{ Elements: []PageElement{
{ {
Type: "form", Component: &Component{
Entity: stringPtr("Order"), Type: "form",
Elements: []ComponentElement{ Entity: stringPtr("Order"),
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "status", Field: &ComponentField{
Type: "select", Name: "status",
Attributes: []ComponentFieldAttribute{ Type: "select",
{Options: []string{"draft", "submitted", "approved"}}, Attributes: []ComponentFieldAttribute{
{Options: []string{"draft", "submitted", "approved"}},
},
}, },
}, },
}, {
{ Button: &ComponentButton{
Button: &ComponentButton{ Name: "save",
Name: "save", Label: "Save Draft",
Label: "Save Draft", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "secondary"}},
{Style: &ComponentButtonStyle{Value: "secondary"}}, {Icon: &ComponentButtonIcon{Value: "save"}},
{Icon: &ComponentButtonIcon{Value: "save"}}, {Position: &ComponentButtonPosition{Value: "left"}},
{Position: &ComponentButtonPosition{Value: "left"}}, },
}, },
}, },
}, {
{ Button: &ComponentButton{
Button: &ComponentButton{ Name: "submit",
Name: "submit", Label: "Submit Order",
Label: "Submit Order", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "primary"}},
{Style: &ComponentButtonStyle{Value: "primary"}}, {Icon: &ComponentButtonIcon{Value: "send"}},
{Icon: &ComponentButtonIcon{Value: "send"}}, {Loading: &ComponentButtonLoading{Value: "Submitting..."}},
{Loading: &ComponentButtonLoading{Value: "Submitting..."}}, {Confirm: &ComponentButtonConfirm{Value: "Submit this order?"}},
{Confirm: &ComponentButtonConfirm{Value: "Submit this order?"}}, },
}, },
}, },
}, {
{ Button: &ComponentButton{
Button: &ComponentButton{ Name: "approve",
Name: "approve", Label: "Approve",
Label: "Approve", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "success"}},
{Style: &ComponentButtonStyle{Value: "success"}}, {Loading: &ComponentButtonLoading{Value: "Approving..."}},
{Loading: &ComponentButtonLoading{Value: "Approving..."}}, {Disabled: &ComponentButtonDisabled{Value: "status"}},
{Disabled: &ComponentButtonDisabled{Value: "status"}}, {Confirm: &ComponentButtonConfirm{Value: "Approve this order?"}},
{Confirm: &ComponentButtonConfirm{Value: "Approve this order?"}}, {Target: &ComponentButtonTarget{Value: "approval_modal"}},
{Target: &ComponentButtonTarget{Value: "approval_modal"}}, {Via: &ComponentButtonVia{Value: "api/orders/approve"}},
{Via: &ComponentButtonVia{Value: "api/orders/approve"}}, },
}, },
}, },
}, {
{ Button: &ComponentButton{
Button: &ComponentButton{ Name: "reject",
Name: "reject", Label: "Reject",
Label: "Reject", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "danger"}},
{Style: &ComponentButtonStyle{Value: "danger"}}, {Icon: &ComponentButtonIcon{Value: "x"}},
{Icon: &ComponentButtonIcon{Value: "x"}}, {Confirm: &ComponentButtonConfirm{Value: "Are you sure you want to reject this order?"}},
{Confirm: &ComponentButtonConfirm{Value: "Are you sure you want to reject this order?"}}, },
}, },
}, },
}, {
{ Button: &ComponentButton{
Button: &ComponentButton{ Name: "print",
Name: "print", Label: "Print",
Label: "Print", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "outline"}},
{Style: &ComponentButtonStyle{Value: "outline"}}, {Icon: &ComponentButtonIcon{Value: "printer"}},
{Icon: &ComponentButtonIcon{Value: "printer"}}, {Position: &ComponentButtonPosition{Value: "right"}},
{Position: &ComponentButtonPosition{Value: "right"}}, },
}, },
}, },
}, },
@ -482,12 +596,12 @@ func TestParseFieldValidationTypes(t *testing.T) {
} }
page := got.Definitions[0].Page 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) t.Errorf("ParseInput() failed to parse component for validation %s", vt.validation)
return return
} }
element := page.Components[0].Elements[0] element := page.Elements[0].Component.Elements[0]
if element.Field == nil || len(element.Field.Attributes) != 1 { if element.Field == nil || len(element.Field.Attributes) != 1 {
t.Errorf("ParseInput() failed to parse field attributes for validation %s", vt.validation) t.Errorf("ParseInput() failed to parse field attributes for validation %s", vt.validation)
return return
@ -527,7 +641,7 @@ func TestParseConditionalOperators(t *testing.T) {
// Verify the when condition was parsed correctly // Verify the when condition was parsed correctly
page := got.Definitions[0].Page page := got.Definitions[0].Page
component := page.Components[0] component := page.Elements[0].Component
whenElement := component.Elements[1].When whenElement := component.Elements[1].When
if whenElement == nil || whenElement.Operator != op { if whenElement == nil || whenElement.Operator != op {

View File

@ -23,10 +23,12 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Components: []Component{ Elements: []PageElement{
{ {
Type: "table", Component: &Component{
Entity: stringPtr("User"), Type: "table",
Entity: stringPtr("User"),
},
}, },
}, },
}, },
@ -52,58 +54,60 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Components: []Component{ Elements: []PageElement{
{ {
Type: "form", Component: &Component{
Entity: stringPtr("User"), Type: "form",
Elements: []ComponentElement{ Entity: stringPtr("User"),
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "name", Field: &ComponentField{
Type: "text", Name: "name",
Attributes: []ComponentFieldAttribute{ Type: "text",
{Label: stringPtr("Full Name")}, Attributes: []ComponentFieldAttribute{
{Placeholder: stringPtr("Enter your name")}, {Label: stringPtr("Full Name")},
{Required: true}, {Placeholder: stringPtr("Enter your name")},
{Required: true},
},
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "email",
Name: "email", Type: "email",
Type: "email", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Label: stringPtr("Email Address")},
{Label: stringPtr("Email Address")}, {Required: true},
{Required: true}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "bio",
Name: "bio", Type: "textarea",
Type: "textarea", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Rows: intPtr(5)},
{Rows: intPtr(5)}, {Placeholder: stringPtr("Tell us about yourself")},
{Placeholder: stringPtr("Tell us about yourself")}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "avatar",
Name: "avatar", Type: "file",
Type: "file", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Accept: stringPtr("image/*")},
{Accept: stringPtr("image/*")}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "role",
Name: "role", Type: "select",
Type: "select", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Options: []string{"admin", "user", "guest"}},
{Options: []string{"admin", "user", "guest"}}, {Default: stringPtr("user")},
{Default: stringPtr("user")}, },
}, },
}, },
}, },
@ -135,70 +139,72 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Components: []Component{ Elements: []PageElement{
{ {
Type: "form", Component: &Component{
Entity: stringPtr("Product"), Type: "form",
Elements: []ComponentElement{ Entity: stringPtr("Product"),
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "name", Field: &ComponentField{
Type: "text", Name: "name",
Attributes: []ComponentFieldAttribute{ Type: "text",
{Required: true}, Attributes: []ComponentFieldAttribute{
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}}, {Required: true},
{Validation: &ComponentValidation{Type: "min_length", Value: stringPtr("3")}},
},
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "price",
Name: "price", Type: "number",
Type: "number", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Format: stringPtr("currency")},
{Format: stringPtr("currency")}, {Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}},
{Validation: &ComponentValidation{Type: "min", Value: stringPtr("0")}}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "category",
Name: "category", Type: "autocomplete",
Type: "autocomplete", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Relates: &FieldRelation{Type: "Category"}},
{Relates: &FieldRelation{Type: "Category"}}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "tags",
Name: "tags", Type: "multiselect",
Type: "multiselect", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Source: stringPtr("tags/popular")},
{Source: stringPtr("tags/popular")}, },
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "description",
Name: "description", Type: "richtext",
Type: "richtext",
},
},
{
Field: &ComponentField{
Name: "featured",
Type: "checkbox",
Attributes: []ComponentFieldAttribute{
{Default: stringPtr("false")},
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "featured",
Name: "thumbnail", Type: "checkbox",
Type: "image", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Default: stringPtr("false")},
{Thumbnail: true}, },
},
},
{
Field: &ComponentField{
Name: "thumbnail",
Type: "image",
Attributes: []ComponentFieldAttribute{
{Thumbnail: true},
},
}, },
}, },
}, },
@ -227,44 +233,46 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Components: []Component{ Elements: []PageElement{
{ {
Type: "form", Component: &Component{
Entity: stringPtr("User"), Type: "form",
Elements: []ComponentElement{ Entity: stringPtr("User"),
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "name", Field: &ComponentField{
Type: "text", Name: "name",
}, Type: "text",
},
{
Button: &ComponentButton{
Name: "save",
Label: "Save User",
Attributes: []ComponentButtonAttr{
{Style: &ComponentButtonStyle{Value: "primary"}},
{Icon: &ComponentButtonIcon{Value: "save"}},
}, },
}, },
}, {
{ Button: &ComponentButton{
Button: &ComponentButton{ Name: "save",
Name: "cancel", Label: "Save User",
Label: "Cancel", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "primary"}},
{Style: &ComponentButtonStyle{Value: "secondary"}}, {Icon: &ComponentButtonIcon{Value: "save"}},
},
}, },
}, },
}, {
{ Button: &ComponentButton{
Button: &ComponentButton{ Name: "cancel",
Name: "delete", Label: "Cancel",
Label: "Delete", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "secondary"}},
{Style: &ComponentButtonStyle{Value: "danger"}}, },
{Confirm: &ComponentButtonConfirm{Value: "Are you sure?"}}, },
{Disabled: &ComponentButtonDisabled{Value: "is_protected"}}, },
{
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", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Components: []Component{ Elements: []PageElement{
{ {
Type: "form", Component: &Component{
Entity: stringPtr("User"), Type: "form",
Elements: []ComponentElement{ Entity: stringPtr("User"),
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "account_type", Field: &ComponentField{
Type: "select", Name: "account_type",
Attributes: []ComponentFieldAttribute{ Type: "select",
{Options: []string{"personal", "business"}}, Attributes: []ComponentFieldAttribute{
{Options: []string{"personal", "business"}},
},
}, },
}, },
}, {
{ When: &WhenCondition{
When: &WhenCondition{ Field: "account_type",
Field: "account_type", Operator: "equals",
Operator: "equals", Value: "business",
Value: "business", Fields: []ComponentField{
Fields: []ComponentField{ {
{ Name: "company_name",
Name: "company_name", Type: "text",
Type: "text", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Required: true},
{Required: true}, },
},
{
Name: "tax_id",
Type: "text",
}, },
}, },
{ Buttons: []ComponentButton{
Name: "tax_id", {
Type: "text", Name: "verify_business",
}, Label: "Verify Business",
}, },
Buttons: []ComponentButton{
{
Name: "verify_business",
Label: "Verify Business",
}, },
}, },
}, },
}, {
{ When: &WhenCondition{
When: &WhenCondition{ Field: "account_type",
Field: "account_type", Operator: "equals",
Operator: "equals", Value: "personal",
Value: "personal", Fields: []ComponentField{
Fields: []ComponentField{ {
{ Name: "date_of_birth",
Name: "date_of_birth", Type: "date",
Type: "date", },
}, },
}, },
}, },
@ -383,36 +393,38 @@ func TestParseComponentDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Components: []Component{ Elements: []PageElement{
{ {
Type: "dashboard", Component: &Component{
Elements: []ComponentElement{ Type: "dashboard",
{ Elements: []ComponentElement{
Section: &Section{ {
Name: "stats", Section: &Section{
Type: stringPtr("container"), Name: "stats",
Class: stringPtr("stats-grid"), Type: stringPtr("container"),
Elements: []SectionElement{ Class: stringPtr("stats-grid"),
{ Elements: []SectionElement{
Component: &Component{ {
Type: "metric", Component: &Component{
Elements: []ComponentElement{ Type: "metric",
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "total_users", Field: &ComponentField{
Type: "display", Name: "total_users",
Attributes: []ComponentFieldAttribute{ Type: "display",
{Value: stringPtr("1,234")}, Attributes: []ComponentFieldAttribute{
{Value: stringPtr("1,234")},
},
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "revenue",
Name: "revenue", Type: "display",
Type: "display", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Format: stringPtr("currency")},
{Format: stringPtr("currency")}, {Value: stringPtr("45,678")},
{Value: stringPtr("45,678")}, },
}, },
}, },
}, },
@ -421,20 +433,20 @@ func TestParseComponentDefinitions(t *testing.T) {
}, },
}, },
}, },
}, {
{ Section: &Section{
Section: &Section{ Name: "charts",
Name: "charts", Type: stringPtr("container"),
Type: stringPtr("container"), Elements: []SectionElement{
Elements: []SectionElement{ {
{ Component: &Component{
Component: &Component{ Type: "chart",
Type: "chart", Entity: stringPtr("Analytics"),
Entity: stringPtr("Analytics"), Elements: []ComponentElement{
Elements: []ComponentElement{ {
{ Attribute: &ComponentAttr{
Attribute: &ComponentAttr{ DataSource: stringPtr("analytics/monthly"),
DataSource: stringPtr("analytics/monthly"), },
}, },
}, },
}, },
@ -461,7 +473,7 @@ func TestParseComponentDefinitions(t *testing.T) {
return return
} }
if !astEqual(got, tt.want) { 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 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) t.Errorf("ParseInput() failed to parse component for field type %s", fieldType)
return return
} }
element := page.Components[0].Elements[0] element := page.Elements[0]
if element.Field == nil || element.Field.Type != fieldType { if element.Component == nil || len(element.Component.Elements) != 1 {
t.Errorf("ParseInput() field type mismatch: got %v, want %s", element.Field, fieldType) 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)
} }
}) })
} }

View File

@ -12,14 +12,14 @@ func TestParsePageDefinitions(t *testing.T) {
wantErr bool wantErr bool
}{ }{
{ {
name: "basic page with minimal fields", name: "basic page definition",
input: `page Dashboard at "/dashboard" layout main`, input: `page Home at "/" layout main`,
want: AST{ want: AST{
Definitions: []Definition{ Definitions: []Definition{
{ {
Page: &Page{ Page: &Page{
Name: "Dashboard", Name: "Home",
Path: "/dashboard", Path: "/",
Layout: "main", Layout: "main",
}, },
}, },
@ -27,17 +27,17 @@ func TestParsePageDefinitions(t *testing.T) {
}, },
}, },
{ {
name: "page with all optional fields", name: "page with optional fields",
input: `page UserProfile at "/profile" layout main title "User Profile" desc "Manage user profile settings" auth`, input: `page Settings at "/settings" layout main title "User Settings" desc "Manage your account settings" auth`,
want: AST{ want: AST{
Definitions: []Definition{ Definitions: []Definition{
{ {
Page: &Page{ Page: &Page{
Name: "UserProfile", Name: "Settings",
Path: "/profile", Path: "/settings",
Layout: "main", Layout: "main",
Title: stringPtr("User Profile"), Title: stringPtr("User Settings"),
Description: stringPtr("Manage user profile settings"), Description: stringPtr("Manage your account settings"),
Auth: true, Auth: true,
}, },
}, },
@ -46,20 +46,22 @@ func TestParsePageDefinitions(t *testing.T) {
}, },
{ {
name: "page with meta tags", name: "page with meta tags",
input: `page HomePage at "/" layout main { input: `page Settings at "/settings" layout main {
meta description "Welcome to our application" meta description "Settings page description"
meta keywords "app, dashboard, management" meta keywords "settings, user, account"
meta author "My App"
}`, }`,
want: AST{ want: AST{
Definitions: []Definition{ Definitions: []Definition{
{ {
Page: &Page{ Page: &Page{
Name: "HomePage", Name: "Settings",
Path: "/", Path: "/settings",
Layout: "main", Layout: "main",
Meta: []MetaTag{ Meta: []MetaTag{
{Name: "description", Content: "Welcome to our application"}, {Name: "description", Content: "Settings page description"},
{Name: "keywords", Content: "app, dashboard, management"}, {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 { input: `page Settings at "/settings" layout main {
section tabs type tab { section tabs type tab {
section profile label "Profile" active { section profile label "Profile" active {
component form for User { component form for User
field name type text
}
} }
section security label "Security" { section security label "Security" {
component form for User { component form for Security
field password type password }
} section notifications label "Notifications" {
component toggle for NotificationSettings
} }
} }
}`, }`,
@ -90,50 +90,50 @@ func TestParsePageDefinitions(t *testing.T) {
Name: "Settings", Name: "Settings",
Path: "/settings", Path: "/settings",
Layout: "main", Layout: "main",
Sections: []Section{ Elements: []PageElement{
{ {
Name: "tabs", Section: &Section{
Type: stringPtr("tab"), Name: "tabs",
Elements: []SectionElement{ Type: stringPtr("tab"),
{ Elements: []SectionElement{
Section: &Section{ {
Name: "profile", Section: &Section{
Label: stringPtr("Profile"), Name: "profile",
Active: true, Label: stringPtr("Profile"),
Elements: []SectionElement{ Active: true,
{ Elements: []SectionElement{
Component: &Component{ {
Type: "form", Component: &Component{
Entity: stringPtr("User"), Type: "form",
Elements: []ComponentElement{ Entity: stringPtr("User"),
{
Field: &ComponentField{
Name: "name",
Type: "text",
},
},
}, },
}, },
}, },
}, },
}, },
}, {
{ Section: &Section{
Section: &Section{ Name: "security",
Name: "security", Label: stringPtr("Security"),
Label: stringPtr("Security"), Elements: []SectionElement{
Elements: []SectionElement{ {
{ Component: &Component{
Component: &Component{ Type: "form",
Type: "form", Entity: stringPtr("Security"),
Entity: stringPtr("User"), },
Elements: []ComponentElement{ },
{ },
Field: &ComponentField{ },
Name: "password", },
Type: "password", {
}, 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", name: "page with components",
input: `page ProductList at "/products" layout main { input: `page Dashboard at "/dashboard" layout main {
section main type container { component stats for Analytics {
component table for Product field total_users type display
field revenue type display format "currency"
} }
component chart for SalesData {
section editModal type modal trigger "edit-product" { data from "analytics/sales"
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
}
} }
}`, }`,
want: AST{ want: AST{
Definitions: []Definition{ Definitions: []Definition{
{ {
Page: &Page{ Page: &Page{
Name: "ProductList", Name: "Dashboard",
Path: "/products", Path: "/dashboard",
Layout: "main", Layout: "main",
Sections: []Section{ Elements: []PageElement{
{ {
Name: "main", Component: &Component{
Type: stringPtr("container"), Type: "stats",
Elements: []SectionElement{ Entity: stringPtr("Analytics"),
{ Elements: []ComponentElement{
Component: &Component{ {
Type: "table", Field: &ComponentField{
Entity: stringPtr("Product"), Name: "total_users",
Type: "display",
},
},
{
Field: &ComponentField{
Name: "revenue",
Type: "display",
Attributes: []ComponentFieldAttribute{
{Format: stringPtr("currency")},
},
},
}, },
}, },
}, },
}, },
{ {
Name: "editModal", Component: &Component{
Type: stringPtr("modal"), Type: "chart",
Trigger: stringPtr("edit-product"), Entity: stringPtr("SalesData"),
Elements: []SectionElement{ Elements: []ComponentElement{
{ {
Component: &Component{ Attribute: &ComponentAttr{
Type: "form", DataSource: stringPtr("analytics/sales"),
Entity: stringPtr("Product"), },
Elements: []ComponentElement{ },
{ },
Field: &ComponentField{ },
Name: "name", },
Type: "text", },
Attributes: []ComponentFieldAttribute{ },
{Required: true}, },
}, },
}, },
}, },
{ {
Button: &ComponentButton{ name: "page with mixed sections and components",
Name: "save", input: `page Home at "/" layout main {
Label: "Save Changes", component hero for Banner {
Attributes: []ComponentButtonAttr{ field title type display
{Style: &ComponentButtonStyle{Value: "primary"}}, 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", Component: &Component{
Type: stringPtr("panel"), Type: "newsletter",
Position: stringPtr("left"), Entity: stringPtr("Subscription"),
Elements: []SectionElement{ Elements: []ComponentElement{
{ {
Component: &Component{ Field: &ComponentField{
Type: "form", Name: "email",
Elements: []ComponentElement{ Type: "email",
{ Attributes: []ComponentFieldAttribute{
Field: &ComponentField{ {Required: true},
Name: "category",
Type: "select",
},
},
{
Field: &ComponentField{
Name: "price_range",
Type: "range",
},
}, },
}, },
}, },
{
Button: &ComponentButton{
Name: "subscribe",
Label: "Subscribe",
},
},
}, },
}, },
}, },
@ -264,7 +314,66 @@ func TestParsePageDefinitions(t *testing.T) {
return return
} }
if !astEqual(got, tt.want) { 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 input string
}{ }{
{ {
name: "missing layout", name: "missing page name",
input: `page Dashboard at "/dashboard"`, input: `page at "/" layout main`,
}, },
{ {
name: "missing path", name: "missing path",
input: `page Dashboard layout main`, input: `page Test layout main`,
},
{
name: "missing layout",
input: `page Test at "/"`,
}, },
{ {
name: "invalid path format", 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
`,
}, },
} }

View File

@ -23,10 +23,12 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Sections: []Section{ Elements: []PageElement{
{ {
Name: "main", Section: &Section{
Type: stringPtr("container"), Name: "main",
Type: stringPtr("container"),
},
}, },
}, },
}, },
@ -46,15 +48,57 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Sections: []Section{ Elements: []PageElement{
{ {
Name: "sidebar", Section: &Section{
Type: stringPtr("panel"), Name: "sidebar",
Position: stringPtr("left"), Type: stringPtr("panel"),
Class: stringPtr("sidebar-nav"), Class: stringPtr("sidebar-nav"),
Label: stringPtr("Navigation"), Label: stringPtr("Navigation"),
Trigger: stringPtr("toggle-sidebar"), Trigger: stringPtr("toggle-sidebar"),
Entity: stringPtr("User"), 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", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Sections: []Section{ Elements: []PageElement{
{ {
Name: "tabs", Section: &Section{
Type: stringPtr("tab"), Name: "tabs",
Elements: []SectionElement{ Type: stringPtr("tab"),
{ Elements: []SectionElement{
Section: &Section{ {
Name: "overview", Section: &Section{
Label: stringPtr("Overview"), Name: "overview",
Active: true, Label: stringPtr("Overview"),
Active: true,
},
}, },
}, {
{ Section: &Section{
Section: &Section{ Name: "details",
Name: "details", Label: stringPtr("Details"),
Label: stringPtr("Details"), },
}, },
}, {
{ Section: &Section{
Section: &Section{ Name: "settings",
Name: "settings", Label: stringPtr("Settings"),
Label: stringPtr("Settings"), },
}, },
}, },
}, },
@ -129,50 +175,52 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Sections: []Section{ Elements: []PageElement{
{ {
Name: "userModal", Section: &Section{
Type: stringPtr("modal"), Name: "userModal",
Trigger: stringPtr("edit-user"), Type: stringPtr("modal"),
Elements: []SectionElement{ Trigger: stringPtr("edit-user"),
{ Elements: []SectionElement{
Component: &Component{ {
Type: "form", Component: &Component{
Entity: stringPtr("User"), Type: "form",
Elements: []ComponentElement{ Entity: stringPtr("User"),
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "name", Field: &ComponentField{
Type: "text", Name: "name",
Attributes: []ComponentFieldAttribute{ Type: "text",
{Required: true}, Attributes: []ComponentFieldAttribute{
{Required: true},
},
}, },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "email",
Name: "email", Type: "email",
Type: "email", Attributes: []ComponentFieldAttribute{
Attributes: []ComponentFieldAttribute{ {Required: true},
{Required: true}, },
}, },
}, },
}, {
{ Button: &ComponentButton{
Button: &ComponentButton{ Name: "save",
Name: "save", Label: "Save Changes",
Label: "Save Changes", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "primary"}},
{Style: &ComponentButtonStyle{Value: "primary"}}, },
}, },
}, },
}, {
{ Button: &ComponentButton{
Button: &ComponentButton{ Name: "cancel",
Name: "cancel", Label: "Cancel",
Label: "Cancel", Attributes: []ComponentButtonAttr{
Attributes: []ComponentButtonAttr{ {Style: &ComponentButtonStyle{Value: "secondary"}},
{Style: &ComponentButtonStyle{Value: "secondary"}}, },
}, },
}, },
}, },
@ -213,24 +261,26 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Sections: []Section{ Elements: []PageElement{
{ {
Name: "masterDetail", Section: &Section{
Type: stringPtr("master"), Name: "masterDetail",
Elements: []SectionElement{ Type: stringPtr("master"),
{ Elements: []SectionElement{
Section: &Section{ {
Name: "userList", Section: &Section{
Type: stringPtr("container"), Name: "userList",
Elements: []SectionElement{ Type: stringPtr("container"),
{ Elements: []SectionElement{
Component: &Component{ {
Type: "table", Component: &Component{
Entity: stringPtr("User"), Type: "table",
Elements: []ComponentElement{ Entity: stringPtr("User"),
{ Elements: []ComponentElement{
Attribute: &ComponentAttr{ {
Fields: []string{"name", "email"}, Attribute: &ComponentAttr{
Fields: []string{"name", "email"},
},
}, },
}, },
}, },
@ -238,35 +288,35 @@ func TestParseSectionDefinitions(t *testing.T) {
}, },
}, },
}, },
}, {
{ Section: &Section{
Section: &Section{ Name: "userDetail",
Name: "userDetail", Type: stringPtr("detail"),
Type: stringPtr("detail"), Trigger: stringPtr("user-selected"),
Trigger: stringPtr("user-selected"), Entity: stringPtr("User"),
Entity: stringPtr("User"), Elements: []SectionElement{
Elements: []SectionElement{ {
{ Component: &Component{
Component: &Component{ Type: "form",
Type: "form", Entity: stringPtr("User"),
Entity: stringPtr("User"), Elements: []ComponentElement{
Elements: []ComponentElement{ {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "name",
Name: "name", Type: "text",
Type: "text", },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "email",
Name: "email", Type: "email",
Type: "email", },
}, },
}, {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "bio",
Name: "bio", Type: "textarea",
Type: "textarea", },
}, },
}, },
}, },
@ -323,27 +373,29 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Sections: []Section{ Elements: []PageElement{
{ {
Name: "mainLayout", Section: &Section{
Type: stringPtr("container"), Name: "mainLayout",
Elements: []SectionElement{ Type: stringPtr("container"),
{ Elements: []SectionElement{
Section: &Section{ {
Name: "header", Section: &Section{
Type: stringPtr("container"), Name: "header",
Class: stringPtr("header"), Type: stringPtr("container"),
Elements: []SectionElement{ Class: stringPtr("header"),
{ Elements: []SectionElement{
Component: &Component{ {
Type: "navbar", Component: &Component{
Elements: []ComponentElement{ Type: "navbar",
{ Elements: []ComponentElement{
Field: &ComponentField{ {
Name: "search", Field: &ComponentField{
Type: "text", Name: "search",
Attributes: []ComponentFieldAttribute{ Type: "text",
{Placeholder: stringPtr("Search...")}, Attributes: []ComponentFieldAttribute{
{Placeholder: stringPtr("Search...")},
},
}, },
}, },
}, },
@ -352,26 +404,26 @@ func TestParseSectionDefinitions(t *testing.T) {
}, },
}, },
}, },
}, {
{ Section: &Section{
Section: &Section{ Name: "content",
Name: "content", Type: stringPtr("container"),
Type: stringPtr("container"), Elements: []SectionElement{
Elements: []SectionElement{ {
{ Section: &Section{
Section: &Section{ Name: "sidebar",
Name: "sidebar", Type: stringPtr("panel"),
Type: stringPtr("panel"), Position: stringPtr("left"),
Position: stringPtr("left"), Elements: []SectionElement{
Elements: []SectionElement{ {
{ Component: &Component{
Component: &Component{ Type: "menu",
Type: "menu", Elements: []ComponentElement{
Elements: []ComponentElement{ {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "navigation",
Name: "navigation", Type: "list",
Type: "list", },
}, },
}, },
}, },
@ -379,31 +431,31 @@ func TestParseSectionDefinitions(t *testing.T) {
}, },
}, },
}, },
}, {
{ Section: &Section{
Section: &Section{ Name: "main",
Name: "main", Type: stringPtr("container"),
Type: stringPtr("container"), Elements: []SectionElement{
Elements: []SectionElement{ {
{ Section: &Section{
Section: &Section{ Name: "tabs",
Name: "tabs", Type: stringPtr("tab"),
Type: stringPtr("tab"), Elements: []SectionElement{
Elements: []SectionElement{ {
{ Section: &Section{
Section: &Section{ Name: "overview",
Name: "overview", Label: stringPtr("Overview"),
Label: stringPtr("Overview"), Active: true,
Active: true, Elements: []SectionElement{
Elements: []SectionElement{ {
{ Component: &Component{
Component: &Component{ Type: "dashboard",
Type: "dashboard", Elements: []ComponentElement{
Elements: []ComponentElement{ {
{ Field: &ComponentField{
Field: &ComponentField{ Name: "stats",
Name: "stats", Type: "metric",
Type: "metric", },
}, },
}, },
}, },
@ -411,16 +463,16 @@ func TestParseSectionDefinitions(t *testing.T) {
}, },
}, },
}, },
}, {
{ Section: &Section{
Section: &Section{ Name: "reports",
Name: "reports", Label: stringPtr("Reports"),
Label: stringPtr("Reports"), Elements: []SectionElement{
Elements: []SectionElement{ {
{ Component: &Component{
Component: &Component{ Type: "table",
Type: "table", Entity: stringPtr("Report"),
Entity: stringPtr("Report"), },
}, },
}, },
}, },
@ -464,37 +516,39 @@ func TestParseSectionDefinitions(t *testing.T) {
Name: "Test", Name: "Test",
Path: "/test", Path: "/test",
Layout: "main", Layout: "main",
Sections: []Section{ Elements: []PageElement{
{ {
Name: "adminPanel", Section: &Section{
Type: stringPtr("container"), Name: "adminPanel",
Elements: []SectionElement{ Type: stringPtr("container"),
{ Elements: []SectionElement{
When: &WhenCondition{ {
Field: "user_role", When: &WhenCondition{
Operator: "equals", Field: "user_role",
Value: "admin", Operator: "equals",
Sections: []Section{ Value: "admin",
{ Sections: []Section{
Name: "userManagement", {
Type: stringPtr("container"), Name: "userManagement",
Elements: []SectionElement{ Type: stringPtr("container"),
{ Elements: []SectionElement{
Component: &Component{ {
Type: "table", Component: &Component{
Entity: stringPtr("User"), Type: "table",
Entity: stringPtr("User"),
},
}, },
}, },
}, },
}, {
{ Name: "systemSettings",
Name: "systemSettings", Type: stringPtr("container"),
Type: stringPtr("container"), Elements: []SectionElement{
Elements: []SectionElement{ {
{ Component: &Component{
Component: &Component{ Type: "form",
Type: "form", Entity: stringPtr("Settings"),
Entity: stringPtr("Settings"), },
}, },
}, },
}, },
@ -520,7 +574,7 @@ func TestParseSectionDefinitions(t *testing.T) {
return return
} }
if !astEqual(got, tt.want) { 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 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) t.Errorf("ParseInput() failed to parse section for type %s", sectionType)
return return
} }
section := page.Sections[0] section := page.Elements[0].Section
if section.Type == nil || *section.Type != sectionType { if section.Type == nil || *section.Type != sectionType {
t.Errorf("ParseInput() section type mismatch: got %v, want %s", section.Type, sectionType) t.Errorf("ParseInput() section type mismatch: got %v, want %s", section.Type, sectionType)
} }

View File

@ -30,24 +30,13 @@ func pageEqual(got, want Page) bool {
} }
} }
// Compare sections (unified model) // Compare elements (unified model)
if len(got.Sections) != len(want.Sections) { if len(got.Elements) != len(want.Elements) {
return false return false
} }
for i, section := range got.Sections { for i, element := range got.Elements {
if !sectionEqual(section, want.Sections[i]) { if !pageElementEqual(element, want.Elements[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]) {
return false return false
} }
} }
@ -55,6 +44,28 @@ func pageEqual(got, want Page) bool {
return true 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 { func metaTagEqual(got, want MetaTag) bool {
return got.Name == want.Name && got.Content == want.Content return got.Name == want.Name && got.Content == want.Content
} }

View File

@ -583,13 +583,19 @@ export default function {{.Page.Name}}Page() {
{/* Protected content */} {/* Protected content */}
{{end}} {{end}}
{{range .Page.Sections}} {{range .Page.Elements}}
<section className="{{.Class | derefString}}"> {{if .Section}}
{{if .Label}}<h2>{{.Label | derefString}}</h2>{{end}} <section className="{{.Section.Class | derefString}}">
{{range .Components}} {{if .Section.Label}}<h2>{{.Section.Label | derefString}}</h2>{{end}}
{/* Component: {{.Type}} */} {{range .Section.Elements}}
{{if .Component}}
{/* Component: {{.Component.Type}} */}
{{end}}
{{end}} {{end}}
</section> </section>
{{else if .Component}}
{/* Component: {{.Component.Type}} */}
{{end}}
{{end}} {{end}}
</div> </div>
</Layout> </Layout>