Compare commits
5 Commits
dsl-lib
...
e71b1c3a23
Author | SHA1 | Date | |
---|---|---|---|
e71b1c3a23 | |||
4ac93ee924 | |||
1ee8de23da | |||
da43647b54 | |||
e28b6c89ef |
60
.idea/copilotDiffState.xml
generated
60
.idea/copilotDiffState.xml
generated
File diff suppressed because one or more lines are too long
157
debug.go
157
debug.go
@ -1,157 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"masonry/lang"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Read the example.masonry file
|
|
||||||
content, err := ioutil.ReadFile("example.masonry")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error reading example.masonry: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input := string(content)
|
|
||||||
|
|
||||||
ast, err := lang.ParseInput(input)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("🎉 Successfully parsed complete DSL with pages!\n\n")
|
|
||||||
|
|
||||||
for _, def := range ast.Definitions {
|
|
||||||
if def.Server != nil {
|
|
||||||
fmt.Printf("📡 Server: %s\n", def.Server.Name)
|
|
||||||
for _, setting := range def.Server.Settings {
|
|
||||||
if setting.Host != nil {
|
|
||||||
fmt.Printf(" host: %s\n", *setting.Host)
|
|
||||||
}
|
|
||||||
if setting.Port != nil {
|
|
||||||
fmt.Printf(" port: %d\n", *setting.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if def.Entity != nil {
|
|
||||||
entity := def.Entity
|
|
||||||
fmt.Printf("🏗️ Entity: %s", entity.Name)
|
|
||||||
if entity.Description != nil {
|
|
||||||
fmt.Printf(" - %s", *entity.Description)
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
|
|
||||||
for _, field := range entity.Fields {
|
|
||||||
fmt.Printf(" %s: %s", field.Name, field.Type)
|
|
||||||
if field.Required {
|
|
||||||
fmt.Printf(" (required)")
|
|
||||||
}
|
|
||||||
if field.Unique {
|
|
||||||
fmt.Printf(" (unique)")
|
|
||||||
}
|
|
||||||
if field.Default != nil {
|
|
||||||
fmt.Printf(" default=%s", *field.Default)
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if def.Endpoint != nil {
|
|
||||||
endpoint := def.Endpoint
|
|
||||||
fmt.Printf("🚀 Endpoint: %s %s", endpoint.Method, endpoint.Path)
|
|
||||||
if endpoint.Entity != nil {
|
|
||||||
fmt.Printf(" (for %s)", *endpoint.Entity)
|
|
||||||
}
|
|
||||||
if endpoint.Description != nil {
|
|
||||||
fmt.Printf(" - %s", *endpoint.Description)
|
|
||||||
}
|
|
||||||
if endpoint.Auth {
|
|
||||||
fmt.Printf(" [AUTH]")
|
|
||||||
}
|
|
||||||
fmt.Printf("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if def.Page != nil {
|
|
||||||
page := def.Page
|
|
||||||
fmt.Printf("🎨 Page: %s at %s", page.Name, page.Path)
|
|
||||||
if page.Title != nil {
|
|
||||||
fmt.Printf(" - %s", *page.Title)
|
|
||||||
}
|
|
||||||
if page.Auth {
|
|
||||||
fmt.Printf(" [AUTH]")
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
fmt.Printf(" Layout: %s\n", page.Layout)
|
|
||||||
|
|
||||||
for _, meta := range page.Meta {
|
|
||||||
fmt.Printf(" Meta %s: %s\n", meta.Name, meta.Content)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, comp := range page.Components {
|
|
||||||
fmt.Printf(" 📦 Component: %s", comp.Type)
|
|
||||||
if comp.Entity != nil {
|
|
||||||
fmt.Printf(" for %s", *comp.Entity)
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
|
|
||||||
for _, attr := range comp.Config {
|
|
||||||
if attr.Fields != nil {
|
|
||||||
fmt.Printf(" fields: %v\n", attr.Fields.Fields)
|
|
||||||
}
|
|
||||||
if attr.Actions != nil {
|
|
||||||
fmt.Printf(" actions: ")
|
|
||||||
for i, action := range attr.Actions.Actions {
|
|
||||||
if i > 0 {
|
|
||||||
fmt.Printf(", ")
|
|
||||||
}
|
|
||||||
fmt.Printf("%s", action.Name)
|
|
||||||
if action.Endpoint != nil {
|
|
||||||
fmt.Printf(" via %s", *action.Endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
if attr.DataSource != nil {
|
|
||||||
fmt.Printf(" data from: %s\n", attr.DataSource.Endpoint)
|
|
||||||
}
|
|
||||||
if attr.Style != nil {
|
|
||||||
fmt.Printf(" style: %s", *attr.Style.Theme)
|
|
||||||
if len(attr.Style.Classes) > 0 {
|
|
||||||
fmt.Printf(" classes: %v", attr.Style.Classes)
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
if attr.Pagination != nil {
|
|
||||||
fmt.Printf(" pagination: enabled")
|
|
||||||
if attr.Pagination.PageSize != nil {
|
|
||||||
fmt.Printf(" size %d", *attr.Pagination.PageSize)
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
if attr.Filters != nil {
|
|
||||||
fmt.Printf(" filters: ")
|
|
||||||
for i, filter := range attr.Filters.Filters {
|
|
||||||
if i > 0 {
|
|
||||||
fmt.Printf(", ")
|
|
||||||
}
|
|
||||||
fmt.Printf("%s as %s", filter.Field, filter.Type)
|
|
||||||
if filter.Label != nil {
|
|
||||||
fmt.Printf(" (%s)", *filter.Label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
if attr.Validation {
|
|
||||||
fmt.Printf(" validation: enabled\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
125
example.masonry
125
example.masonry
@ -1,125 +0,0 @@
|
|||||||
// Example Masonry DSL definition
|
|
||||||
// This demonstrates the comprehensive language structure
|
|
||||||
|
|
||||||
// Server configuration
|
|
||||||
server MyApp host "localhost" port 8080
|
|
||||||
|
|
||||||
// Entity definitions with various field types and relationships
|
|
||||||
entity User desc "User account management"
|
|
||||||
id: uuid required unique
|
|
||||||
email: string required validate email validate min_length "5"
|
|
||||||
name: string default "Anonymous"
|
|
||||||
created_at: timestamp default "now()"
|
|
||||||
profile_id: uuid relates to Profile as one via "user_id"
|
|
||||||
|
|
||||||
entity Profile desc "User profile information"
|
|
||||||
id: uuid required unique
|
|
||||||
user_id: uuid required relates to User as one
|
|
||||||
bio: text validate max_length "500"
|
|
||||||
avatar_url: string validate url
|
|
||||||
updated_at: timestamp
|
|
||||||
posts: uuid relates to Post as many
|
|
||||||
|
|
||||||
entity Post desc "Blog posts"
|
|
||||||
id: uuid required unique
|
|
||||||
title: string required validate min_length "1" validate max_length "200"
|
|
||||||
content: text required
|
|
||||||
author_id: uuid required relates to User as one
|
|
||||||
published: boolean default "false"
|
|
||||||
created_at: timestamp default "now()"
|
|
||||||
tags: uuid relates to Tag as many through "post_tags"
|
|
||||||
|
|
||||||
entity Tag desc "Content tags"
|
|
||||||
id: uuid required unique
|
|
||||||
name: string required unique validate min_length "1" validate max_length "50"
|
|
||||||
slug: string required unique indexed
|
|
||||||
created_at: timestamp default "now()"
|
|
||||||
|
|
||||||
// API Endpoints with different HTTP methods and parameter sources
|
|
||||||
endpoint GET "/users" for User desc "List users" auth
|
|
||||||
param page: int from query
|
|
||||||
param limit: int required from query
|
|
||||||
returns list as "json" fields [id, email, name]
|
|
||||||
|
|
||||||
endpoint POST "/users" for User desc "Create user"
|
|
||||||
param user_data: object required from body
|
|
||||||
returns object as "json" fields [id, email, name]
|
|
||||||
|
|
||||||
endpoint PUT "/users/{id}" for User desc "Update user"
|
|
||||||
param id: uuid required from path
|
|
||||||
param user_data: object required from body
|
|
||||||
returns object
|
|
||||||
custom "update_user_logic"
|
|
||||||
|
|
||||||
endpoint DELETE "/users/{id}" for User desc "Delete user" auth
|
|
||||||
param id: uuid required from path
|
|
||||||
returns object
|
|
||||||
|
|
||||||
endpoint GET "/posts" for Post desc "List posts"
|
|
||||||
param author_id: uuid from query
|
|
||||||
param published: boolean from query
|
|
||||||
param page: int from query
|
|
||||||
returns list as "json" fields [id, title, author_id, published]
|
|
||||||
|
|
||||||
endpoint POST "/posts" for Post desc "Create post" auth
|
|
||||||
param post_data: object required from body
|
|
||||||
returns object fields [id, title, content, author_id]
|
|
||||||
|
|
||||||
// Frontend pages with components
|
|
||||||
page UserManagement at "/admin/users" layout AdminLayout title "User Management" auth
|
|
||||||
meta description "Manage system users"
|
|
||||||
meta keywords "users, admin, management"
|
|
||||||
|
|
||||||
component Table for User
|
|
||||||
fields [email, name, id]
|
|
||||||
actions [edit via "/users/{id}", delete via "/users/{id}", create via "/users"]
|
|
||||||
data from "/users"
|
|
||||||
style modern classes ["table-striped", "table-hover"]
|
|
||||||
pagination size 20
|
|
||||||
filters [email as text label "Search email", name as text label "Search name"]
|
|
||||||
validate
|
|
||||||
|
|
||||||
component Form for User
|
|
||||||
fields [email, name]
|
|
||||||
actions [save via "/users", cancel]
|
|
||||||
style clean
|
|
||||||
validate
|
|
||||||
|
|
||||||
page UserList at "/users" layout MainLayout title "Users"
|
|
||||||
meta description "Browse all users"
|
|
||||||
|
|
||||||
component Table for User
|
|
||||||
fields [email, name]
|
|
||||||
data from "/users"
|
|
||||||
pagination size 10
|
|
||||||
filters [name as text label "Search by name"]
|
|
||||||
|
|
||||||
page PostManagement at "/admin/posts" layout AdminLayout title "Post Management" auth
|
|
||||||
meta description "Manage blog posts"
|
|
||||||
meta keywords "posts, blog, content"
|
|
||||||
|
|
||||||
component Table for Post
|
|
||||||
fields [title, author_id, published, created_at]
|
|
||||||
actions [edit via "/posts/{id}", delete via "/posts/{id}", create via "/posts"]
|
|
||||||
data from "/posts"
|
|
||||||
style modern
|
|
||||||
pagination size 15
|
|
||||||
filters [title as text label "Search title", published as select label "Published status"]
|
|
||||||
validate
|
|
||||||
|
|
||||||
page CreatePost at "/posts/new" layout MainLayout title "Create Post" auth
|
|
||||||
component Form for Post
|
|
||||||
fields [title, content]
|
|
||||||
actions [save via "/posts", cancel]
|
|
||||||
style clean
|
|
||||||
validate
|
|
||||||
|
|
||||||
page BlogList at "/blog" layout PublicLayout title "Blog Posts"
|
|
||||||
meta description "Read our latest blog posts"
|
|
||||||
meta keywords "blog, articles, content"
|
|
||||||
|
|
||||||
component Table for Post
|
|
||||||
fields [title, created_at]
|
|
||||||
data from "/posts"
|
|
||||||
pagination size 5
|
|
||||||
filters [title as text label "Search posts"]
|
|
148
examples/lang/debug.go
Normal file
148
examples/lang/debug.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"masonry/lang"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Read the example.masonry file from the correct path
|
||||||
|
content, err := os.ReadFile("examples/lang/example.masonry")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading example.masonry: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input := string(content)
|
||||||
|
|
||||||
|
// Try to parse the DSL
|
||||||
|
ast, err := lang.ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("❌ Parse Error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, parsing was successful!
|
||||||
|
fmt.Printf("🎉 Successfully parsed DSL with block delimiters!\n\n")
|
||||||
|
|
||||||
|
// Count what we parsed
|
||||||
|
var servers, entities, endpoints, pages int
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Server != nil {
|
||||||
|
servers++
|
||||||
|
}
|
||||||
|
if def.Entity != nil {
|
||||||
|
entities++
|
||||||
|
}
|
||||||
|
if def.Endpoint != nil {
|
||||||
|
endpoints++
|
||||||
|
}
|
||||||
|
if def.Page != nil {
|
||||||
|
pages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📊 Parsing Summary:\n")
|
||||||
|
fmt.Printf(" Servers: %d\n", servers)
|
||||||
|
fmt.Printf(" Entities: %d\n", entities)
|
||||||
|
fmt.Printf(" Endpoints: %d\n", endpoints)
|
||||||
|
fmt.Printf(" Pages: %d\n", pages)
|
||||||
|
fmt.Printf(" Total Definitions: %d\n", len(ast.Definitions))
|
||||||
|
|
||||||
|
// Verify key structures parsed correctly
|
||||||
|
fmt.Printf("\n✅ Validation Results:\n")
|
||||||
|
|
||||||
|
// Check server has settings in block
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Server != nil {
|
||||||
|
if len(def.Server.Settings) > 0 {
|
||||||
|
fmt.Printf(" ✓ Server '%s' has %d settings (block syntax working)\n", def.Server.Name, len(def.Server.Settings))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check entities have fields in blocks
|
||||||
|
entityCount := 0
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Entity != nil {
|
||||||
|
entityCount++
|
||||||
|
if len(def.Entity.Fields) > 0 {
|
||||||
|
fmt.Printf(" ✓ Entity '%s' has %d fields (block syntax working)\n", def.Entity.Name, len(def.Entity.Fields))
|
||||||
|
}
|
||||||
|
if entityCount >= 2 { // Just show first couple
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check endpoints have params in blocks
|
||||||
|
endpointCount := 0
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Endpoint != nil {
|
||||||
|
endpointCount++
|
||||||
|
if len(def.Endpoint.Params) > 0 {
|
||||||
|
fmt.Printf(" ✓ Endpoint '%s %s' has %d params (block syntax working)\n", def.Endpoint.Method, def.Endpoint.Path, len(def.Endpoint.Params))
|
||||||
|
}
|
||||||
|
if endpointCount >= 2 { // Just show first couple
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pages have content in blocks
|
||||||
|
pageCount := 0
|
||||||
|
for _, def := range ast.Definitions {
|
||||||
|
if def.Page != nil {
|
||||||
|
pageCount++
|
||||||
|
totalContent := len(def.Page.Meta) + len(def.Page.Sections) + len(def.Page.Components)
|
||||||
|
if totalContent > 0 {
|
||||||
|
fmt.Printf(" ✓ Page '%s' has %d content items (block syntax working)\n", def.Page.Name, totalContent)
|
||||||
|
}
|
||||||
|
if pageCount >= 2 { // Just show first couple
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for nested sections (complex structures)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalSections > 0 {
|
||||||
|
fmt.Printf(" ✓ Found %d sections with %d nested levels (recursive parsing working)\n", totalSections, nestedSections)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n🎯 Block delimiter syntax is working correctly!\n")
|
||||||
|
fmt.Printf(" All constructs (server, entity, endpoint, page, section, component) now use { } blocks\n")
|
||||||
|
fmt.Printf(" No more ambiguous whitespace-dependent parsing\n")
|
||||||
|
fmt.Printf(" Language is now unambiguous and consistent\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to count nested sections recursively
|
||||||
|
func countNestedSections(section lang.Section) int {
|
||||||
|
count := 0
|
||||||
|
for _, element := range section.Elements {
|
||||||
|
if element.Section != nil {
|
||||||
|
count++
|
||||||
|
count += countNestedSections(*element.Section)
|
||||||
|
}
|
||||||
|
if element.Component != nil {
|
||||||
|
for _, compElement := range element.Component.Elements {
|
||||||
|
if compElement.Section != nil {
|
||||||
|
count++
|
||||||
|
count += countNestedSections(*compElement.Section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
295
examples/lang/example.masonry
Normal file
295
examples/lang/example.masonry
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
// Enhanced Masonry DSL example demonstrating simplified unified structure
|
||||||
|
// This shows how containers, tabs, panels, modals, and master-detail are now unified as sections
|
||||||
|
|
||||||
|
// Server configuration
|
||||||
|
server MyApp {
|
||||||
|
host "localhost"
|
||||||
|
port 8080
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity definitions with various field types and relationships
|
||||||
|
entity User desc "User account management" {
|
||||||
|
id: uuid required unique
|
||||||
|
email: string required validate email validate min_length "5"
|
||||||
|
name: string default "Anonymous"
|
||||||
|
created_at: timestamp default "now()"
|
||||||
|
profile_id: uuid relates to Profile as one via "user_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
entity Profile desc "User profile information" {
|
||||||
|
id: uuid required unique
|
||||||
|
user_id: uuid required relates to User as one
|
||||||
|
bio: text validate max_length "500"
|
||||||
|
avatar_url: string validate url
|
||||||
|
updated_at: timestamp
|
||||||
|
posts: uuid relates to Post as many
|
||||||
|
}
|
||||||
|
|
||||||
|
entity Post desc "Blog posts" {
|
||||||
|
id: uuid required unique
|
||||||
|
title: string required validate min_length "1" validate max_length "200"
|
||||||
|
content: text required
|
||||||
|
author_id: uuid required relates to User as one
|
||||||
|
published: boolean default "false"
|
||||||
|
created_at: timestamp default "now()"
|
||||||
|
tags: uuid relates to Tag as many through "post_tags"
|
||||||
|
}
|
||||||
|
|
||||||
|
entity Tag desc "Content tags" {
|
||||||
|
id: uuid required unique
|
||||||
|
name: string required unique validate min_length "1" validate max_length "50"
|
||||||
|
slug: string required unique indexed
|
||||||
|
created_at: timestamp default "now()"
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Endpoints with different HTTP methods and parameter sources
|
||||||
|
endpoint GET "/users" for User desc "List users" auth {
|
||||||
|
param page: int from query
|
||||||
|
param limit: int required from query
|
||||||
|
returns list as "json" fields [id, email, name]
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint POST "/users" for User desc "Create user" {
|
||||||
|
param user_data: object required from body
|
||||||
|
returns object as "json" fields [id, email, name]
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint PUT "/users/{id}" for User desc "Update user" {
|
||||||
|
param id: uuid required from path
|
||||||
|
param user_data: object required from body
|
||||||
|
returns object
|
||||||
|
custom "update_user_logic"
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint DELETE "/users/{id}" for User desc "Delete user" auth {
|
||||||
|
param id: uuid required from path
|
||||||
|
returns object
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint GET "/posts" for Post desc "List posts" {
|
||||||
|
param author_id: uuid from query
|
||||||
|
param published: boolean from query
|
||||||
|
param page: int from query
|
||||||
|
returns list as "json" fields [id, title, author_id, published]
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint POST "/posts" for Post desc "Create post" auth {
|
||||||
|
param post_data: object required from body
|
||||||
|
returns object fields [id, title, content, author_id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced User Management page with unified section layout
|
||||||
|
page UserManagement at "/admin/users" layout AdminLayout title "User Management" auth {
|
||||||
|
meta description "Manage system users"
|
||||||
|
meta keywords "users, admin, management"
|
||||||
|
|
||||||
|
section main type container class "grid grid-cols-3 gap-4" {
|
||||||
|
section sidebar class "col-span-1" {
|
||||||
|
component UserStats for User {
|
||||||
|
data from "/users/stats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section content class "col-span-2" {
|
||||||
|
component UserTable for User {
|
||||||
|
fields [email, name, role, created_at]
|
||||||
|
actions [edit, delete, view]
|
||||||
|
data from "/users"
|
||||||
|
}
|
||||||
|
|
||||||
|
section editPanel type panel trigger "edit" position "slide-right" for User {
|
||||||
|
component UserForm for User {
|
||||||
|
field email type text label "Email" required
|
||||||
|
field name type text label "Name" required
|
||||||
|
field role type select options ["admin", "user"]
|
||||||
|
button save label "Save User" style "primary" via "/users/{id}"
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced Form component with detailed field configurations
|
||||||
|
page UserFormPage at "/admin/users/new" layout AdminLayout title "Create User" auth {
|
||||||
|
component Form for User {
|
||||||
|
field email type text label "Email Address" placeholder "Enter your email" required validate email
|
||||||
|
field name type text label "Full Name" placeholder "Enter your full name" required
|
||||||
|
field role type select label "User Role" options ["admin", "user", "moderator"] default "user"
|
||||||
|
field avatar type file label "Profile Picture" accept "image/*"
|
||||||
|
field bio type textarea label "Biography" placeholder "Tell us about yourself" rows 4
|
||||||
|
|
||||||
|
when role equals "admin" {
|
||||||
|
component AdminPermissions {
|
||||||
|
field permissions type multiselect label "Permissions" {
|
||||||
|
options ["users.manage", "posts.manage", "system.config"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section actions {
|
||||||
|
component ActionButtons {
|
||||||
|
button save label "Save User" style "primary" loading "Saving..." via "/users"
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard with tabbed interface using unified sections
|
||||||
|
page Dashboard at "/dashboard" layout MainLayout title "Dashboard" {
|
||||||
|
section tabs type container {
|
||||||
|
section overview type tab label "Overview" active {
|
||||||
|
component StatsCards
|
||||||
|
component RecentActivity
|
||||||
|
}
|
||||||
|
|
||||||
|
section users type tab label "Users" {
|
||||||
|
component UserTable for User {
|
||||||
|
data from "/users"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section posts type tab label "Posts" {
|
||||||
|
component PostTable for Post {
|
||||||
|
data from "/posts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section createUserModal type modal trigger "create-user" {
|
||||||
|
component UserForm for User {
|
||||||
|
field email type text label "Email" required
|
||||||
|
field name type text label "Name" required
|
||||||
|
button save label "Create" via "/users"
|
||||||
|
button cancel label "Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post Management with master-detail layout using unified sections
|
||||||
|
page PostManagement at "/admin/posts" layout AdminLayout title "Post Management" auth {
|
||||||
|
section master type master {
|
||||||
|
component PostTable for Post {
|
||||||
|
field title type text label "Title" sortable
|
||||||
|
field author type relation label "Author" display "name" relates to User
|
||||||
|
field status type badge label "Status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section detail type detail trigger "edit" {
|
||||||
|
component PostForm for Post {
|
||||||
|
section basic class "mb-4" {
|
||||||
|
component BasicFields {
|
||||||
|
field title type text label "Post Title" required
|
||||||
|
field content type richtext label "Content" required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section metadata class "grid grid-cols-2 gap-4" {
|
||||||
|
component MetadataFields {
|
||||||
|
field author_id type autocomplete label "Author" {
|
||||||
|
source "/users" display "name" value "id"
|
||||||
|
}
|
||||||
|
field published type toggle label "Published" default "false"
|
||||||
|
field tags type multiselect label "Tags" {
|
||||||
|
source "/tags" display "name" value "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple table component with smart defaults
|
||||||
|
page SimpleUserList at "/users" layout MainLayout title "Users" {
|
||||||
|
component SimpleTable for User {
|
||||||
|
fields [email, name, created_at]
|
||||||
|
actions [edit, delete]
|
||||||
|
data from "/users"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detailed table with simplified component attributes
|
||||||
|
page DetailedUserList at "/admin/users/detailed" layout AdminLayout title "Detailed User Management" auth {
|
||||||
|
component DetailedTable for User {
|
||||||
|
data from "/users"
|
||||||
|
pagination size 20
|
||||||
|
field email type text label "Email Address"
|
||||||
|
field name type text label "Full Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex nested sections example
|
||||||
|
page ComplexLayout at "/complex" layout MainLayout title "Complex Layout" {
|
||||||
|
section mainContainer type container class "flex h-screen" {
|
||||||
|
section sidebar type container class "w-64 bg-gray-100" {
|
||||||
|
section navigation {
|
||||||
|
component NavMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
section userInfo type panel trigger "profile" position "bottom" {
|
||||||
|
component UserProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section content type container class "flex-1" {
|
||||||
|
section header class "h-16 border-b" {
|
||||||
|
component PageHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
section body class "flex-1 p-4" {
|
||||||
|
section tabs type container {
|
||||||
|
section overview type tab label "Overview" active {
|
||||||
|
section metrics class "grid grid-cols-3 gap-4" {
|
||||||
|
component MetricCard
|
||||||
|
component MetricCard
|
||||||
|
component MetricCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section details type tab label "Details" {
|
||||||
|
component DetailView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional rendering with sections and components
|
||||||
|
page ConditionalForm at "/conditional" layout MainLayout title "Conditional Form" {
|
||||||
|
component UserForm for User {
|
||||||
|
field email type text label "Email" required
|
||||||
|
field role type select options ["admin", "user", "moderator"]
|
||||||
|
|
||||||
|
when role equals "admin" {
|
||||||
|
section adminSection class "border-l-4 border-red-500 pl-4" {
|
||||||
|
component AdminPermissions {
|
||||||
|
field permissions type multiselect label "Admin Permissions" {
|
||||||
|
options ["users.manage", "posts.manage", "system.config"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component AdminSettings {
|
||||||
|
field max_users type number label "Max Users"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when role equals "moderator" {
|
||||||
|
component ModeratorSettings {
|
||||||
|
field moderation_level type select label "Moderation Level" {
|
||||||
|
options ["basic", "advanced", "full"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section actions {
|
||||||
|
component ActionButtons {
|
||||||
|
button save label "Save User" style "primary" loading "Saving..."
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
go.mod
2
go.mod
@ -3,12 +3,12 @@ module masonry
|
|||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alecthomas/participle/v2 v2.1.4
|
||||||
github.com/urfave/cli/v2 v2.27.5
|
github.com/urfave/cli/v2 v2.27.5
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/text v0.22.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/participle/v2 v2.1.4 // indirect
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
|
6
go.sum
6
go.sum
@ -1,7 +1,13 @@
|
|||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
|
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
|
||||||
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
|
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
|
||||||
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||||
|
186
lang/lang.go
186
lang/lang.go
@ -20,7 +20,7 @@ type Definition struct {
|
|||||||
// Clean server syntax
|
// Clean server syntax
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Name string `parser:"'server' @Ident"`
|
Name string `parser:"'server' @Ident"`
|
||||||
Settings []ServerSetting `parser:"@@*"`
|
Settings []ServerSetting `parser:"('{' @@* '}')?"` // Block-delimited settings for consistency
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSetting struct {
|
type ServerSetting struct {
|
||||||
@ -32,7 +32,7 @@ type ServerSetting struct {
|
|||||||
type Entity struct {
|
type Entity struct {
|
||||||
Name string `parser:"'entity' @Ident"`
|
Name string `parser:"'entity' @Ident"`
|
||||||
Description *string `parser:"('desc' @String)?"`
|
Description *string `parser:"('desc' @String)?"`
|
||||||
Fields []Field `parser:"@@*"`
|
Fields []Field `parser:"('{' @@* '}')?"` // Block-delimited fields for consistency
|
||||||
}
|
}
|
||||||
|
|
||||||
// Much cleaner field syntax
|
// Much cleaner field syntax
|
||||||
@ -68,9 +68,9 @@ type Endpoint struct {
|
|||||||
Entity *string `parser:"('for' @Ident)?"`
|
Entity *string `parser:"('for' @Ident)?"`
|
||||||
Description *string `parser:"('desc' @String)?"`
|
Description *string `parser:"('desc' @String)?"`
|
||||||
Auth bool `parser:"@'auth'?"`
|
Auth bool `parser:"@'auth'?"`
|
||||||
Params []EndpointParam `parser:"@@*"`
|
Params []EndpointParam `parser:"('{' @@*"` // Block-delimited parameters
|
||||||
Response *ResponseSpec `parser:"@@?"`
|
Response *ResponseSpec `parser:"@@?"`
|
||||||
CustomLogic *string `parser:"('custom' @String)?"`
|
CustomLogic *string `parser:"('custom' @String)? '}')?"` // Close block after all content
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean parameter syntax
|
// Clean parameter syntax
|
||||||
@ -88,7 +88,7 @@ type ResponseSpec struct {
|
|||||||
Fields []string `parser:"('fields' '[' @Ident (',' @Ident)* ']')?"`
|
Fields []string `parser:"('fields' '[' @Ident (',' @Ident)* ']')?"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page definitions for frontend with clean syntax
|
// 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"`
|
||||||
@ -96,8 +96,9 @@ type Page struct {
|
|||||||
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:"@@*"`
|
Meta []MetaTag `parser:"('{' @@*"` // Block-delimited content
|
||||||
Components []Component `parser:"@@*"`
|
Sections []Section `parser:"@@*"` // Unified sections replace containers/tabs/panels/modals
|
||||||
|
Components []Component `parser:"@@* '}')?"` // Direct components within the block
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meta tags for SEO
|
// Meta tags for SEO
|
||||||
@ -106,64 +107,163 @@ type MetaTag struct {
|
|||||||
Content string `parser:"@String"`
|
Content string `parser:"@String"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component definitions with endpoint references
|
// Unified Section type that replaces Container, Tab, Panel, Modal, MasterDetail
|
||||||
|
type Section struct {
|
||||||
|
Name string `parser:"'section' @Ident"`
|
||||||
|
Type *string `parser:"('type' @('container' | 'tab' | 'panel' | 'modal' | 'master' | 'detail'))?"`
|
||||||
|
Class *string `parser:"('class' @String)?"`
|
||||||
|
Label *string `parser:"('label' @String)?"` // for tabs
|
||||||
|
Active bool `parser:"@'active'?"` // for tabs
|
||||||
|
Trigger *string `parser:"('trigger' @String)?"` // for panels/modals/detail
|
||||||
|
Position *string `parser:"('position' @String)?"` // for panels
|
||||||
|
Entity *string `parser:"('for' @Ident)?"` // for panels
|
||||||
|
Elements []SectionElement `parser:"('{' @@* '}')?"` // Block-delimited elements for unambiguous nesting
|
||||||
|
}
|
||||||
|
|
||||||
|
// New unified element type for sections
|
||||||
|
type SectionElement struct {
|
||||||
|
Attribute *SectionAttribute `parser:"@@"`
|
||||||
|
Component *Component `parser:"| @@"`
|
||||||
|
Section *Section `parser:"| @@"`
|
||||||
|
When *WhenCondition `parser:"| @@"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flexible section attributes (replaces complex config types)
|
||||||
|
type SectionAttribute struct {
|
||||||
|
DataSource *string `parser:"('data' 'from' @String)"`
|
||||||
|
Style *string `parser:"| ('style' @String)"`
|
||||||
|
Classes *string `parser:"| ('classes' @String)"`
|
||||||
|
Size *int `parser:"| ('size' @Int)"` // for pagination, etc.
|
||||||
|
Theme *string `parser:"| ('theme' @String)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified Component with unified attributes - reordered for better parsing
|
||||||
type Component struct {
|
type Component struct {
|
||||||
Type string `parser:"'component' @Ident"`
|
Type string `parser:"'component' @Ident"`
|
||||||
Entity *string `parser:"('for' @Ident)?"`
|
Entity *string `parser:"('for' @Ident)?"`
|
||||||
Config []ComponentAttr `parser:"@@*"`
|
Elements []ComponentElement `parser:"('{' @@* '}')?"` // Parse everything inside the block
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component attributes and configurations
|
// Enhanced ComponentElement with recursive section support - now includes attributes
|
||||||
|
type ComponentElement struct {
|
||||||
|
Attribute *ComponentAttr `parser:"@@"` // Component attributes can be inside the block
|
||||||
|
Field *ComponentField `parser:"| @@"`
|
||||||
|
Section *Section `parser:"| @@"` // Sections can be nested in components
|
||||||
|
Button *ComponentButton `parser:"| @@"`
|
||||||
|
When *WhenCondition `parser:"| @@"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified component attributes using key-value pattern - reordered for precedence
|
||||||
type ComponentAttr struct {
|
type ComponentAttr struct {
|
||||||
Fields *ComponentFields `parser:"@@"`
|
DataSource *string `parser:"('data' 'from' @String)"`
|
||||||
Actions *ComponentActions `parser:"| @@"`
|
Fields []string `parser:"| ('fields' '[' @Ident (',' @Ident)* ']')"`
|
||||||
DataSource *ComponentDataSource `parser:"| @@"`
|
Actions []string `parser:"| ('actions' '[' @Ident (',' @Ident)* ']')"`
|
||||||
Style *ComponentStyle `parser:"| @@"`
|
Style *string `parser:"| ('style' @String)"`
|
||||||
Pagination *ComponentPagination `parser:"| @@"`
|
Classes *string `parser:"| ('classes' @String)"`
|
||||||
Filters *ComponentFilters `parser:"| @@"`
|
PageSize *int `parser:"| ('pagination' 'size' @Int)"`
|
||||||
Validation bool `parser:"| @'validate'"`
|
Validate bool `parser:"| @'validate'"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component field specification
|
// Enhanced component field with detailed configuration using flexible attributes
|
||||||
type ComponentFields struct {
|
type ComponentField struct {
|
||||||
Fields []string `parser:"'fields' '[' @Ident (',' @Ident)* ']'"`
|
Name string `parser:"'field' @Ident"`
|
||||||
|
Type string `parser:"'type' @Ident"`
|
||||||
|
Attributes []ComponentFieldAttribute `parser:"@@* ('{' @@* '}')?"` // Support both inline and block attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component actions (can reference endpoints)
|
// Flexible field attribute system
|
||||||
type ComponentActions struct {
|
type ComponentFieldAttribute struct {
|
||||||
Actions []ComponentAction `parser:"'actions' '[' @@ (',' @@)* ']'"`
|
Label *string `parser:"('label' @String)"`
|
||||||
|
Placeholder *string `parser:"| ('placeholder' @String)"`
|
||||||
|
Required bool `parser:"| @'required'"`
|
||||||
|
Sortable bool `parser:"| @'sortable'"`
|
||||||
|
Searchable bool `parser:"| @'searchable'"`
|
||||||
|
Thumbnail bool `parser:"| @'thumbnail'"`
|
||||||
|
Default *string `parser:"| ('default' @String)"`
|
||||||
|
Options []string `parser:"| ('options' '[' @String (',' @String)* ']')"`
|
||||||
|
Accept *string `parser:"| ('accept' @String)"`
|
||||||
|
Rows *int `parser:"| ('rows' @Int)"`
|
||||||
|
Format *string `parser:"| ('format' @String)"`
|
||||||
|
Size *string `parser:"| ('size' @String)"`
|
||||||
|
Display *string `parser:"| ('display' @String)"`
|
||||||
|
Value *string `parser:"| ('value' @String)"`
|
||||||
|
Source *string `parser:"| ('source' @String)"`
|
||||||
|
Relates *FieldRelation `parser:"| @@"`
|
||||||
|
Validation *ComponentValidation `parser:"| @@"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComponentAction struct {
|
// Field relationship for autocomplete and select fields
|
||||||
Name string `parser:"@Ident"`
|
type FieldRelation struct {
|
||||||
Endpoint *string `parser:"('via' @String)?"`
|
Type string `parser:"'relates' 'to' @Ident"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data source configuration (can reference endpoints)
|
// Component validation
|
||||||
type ComponentDataSource struct {
|
type ComponentValidation struct {
|
||||||
Endpoint string `parser:"'data' 'from' @String"`
|
Type string `parser:"'validate' @Ident"`
|
||||||
|
Value *string `parser:"@String?"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component styling
|
// Enhanced WhenCondition with recursive support for both sections and components
|
||||||
type ComponentStyle struct {
|
type WhenCondition struct {
|
||||||
Theme *string `parser:"'style' @Ident"`
|
Field string `parser:"'when' @Ident"`
|
||||||
Classes []string `parser:"('classes' '[' @String (',' @String)* ']')?"`
|
Operator string `parser:"@('equals' | 'not_equals' | 'contains')"`
|
||||||
|
Value string `parser:"@String"`
|
||||||
|
Fields []ComponentField `parser:"('{' @@*"`
|
||||||
|
Sections []Section `parser:"@@*"` // Can contain sections
|
||||||
|
Components []Component `parser:"@@*"` // Can contain components
|
||||||
|
Buttons []ComponentButton `parser:"@@* '}')?"` // Block-delimited for unambiguous nesting
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination configuration
|
// Simplified button with flexible attribute ordering
|
||||||
type ComponentPagination struct {
|
type ComponentButton struct {
|
||||||
PageSize *int `parser:"'pagination' ('size' @Int)?"`
|
Name string `parser:"'button' @Ident"`
|
||||||
|
Label string `parser:"'label' @String"`
|
||||||
|
Attributes []ComponentButtonAttr `parser:"@@*"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter specifications
|
// Flexible button attribute system - each attribute is a separate alternative
|
||||||
type ComponentFilters struct {
|
type ComponentButtonAttr struct {
|
||||||
Filters []ComponentFilter `parser:"'filters' '[' @@ (',' @@)* ']'"`
|
Style *ComponentButtonStyle `parser:"@@"`
|
||||||
|
Icon *ComponentButtonIcon `parser:"| @@"`
|
||||||
|
Loading *ComponentButtonLoading `parser:"| @@"`
|
||||||
|
Disabled *ComponentButtonDisabled `parser:"| @@"`
|
||||||
|
Confirm *ComponentButtonConfirm `parser:"| @@"`
|
||||||
|
Target *ComponentButtonTarget `parser:"| @@"`
|
||||||
|
Position *ComponentButtonPosition `parser:"| @@"`
|
||||||
|
Via *ComponentButtonVia `parser:"| @@"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComponentFilter struct {
|
// Individual button attribute types
|
||||||
Field string `parser:"@Ident"`
|
type ComponentButtonStyle struct {
|
||||||
Type string `parser:"'as' @('text' | 'select' | 'date' | 'number')"`
|
Value string `parser:"'style' @String"`
|
||||||
Label *string `parser:"('label' @String)?"`
|
}
|
||||||
|
|
||||||
|
type ComponentButtonIcon struct {
|
||||||
|
Value string `parser:"'icon' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonLoading struct {
|
||||||
|
Value string `parser:"'loading' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonDisabled struct {
|
||||||
|
Value string `parser:"'disabled' 'when' @Ident"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonConfirm struct {
|
||||||
|
Value string `parser:"'confirm' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonTarget struct {
|
||||||
|
Value string `parser:"'target' @Ident"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonPosition struct {
|
||||||
|
Value string `parser:"'position' @String"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentButtonVia struct {
|
||||||
|
Value string `parser:"'via' @String"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseInput(input string) (AST, error) {
|
func ParseInput(input string) (AST, error) {
|
||||||
|
1113
lang/lang_test.go
1113
lang/lang_test.go
File diff suppressed because it is too large
Load Diff
131
lang/parser_entity_test.go
Normal file
131
lang/parser_entity_test.go
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseEntityDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "entity with enhanced fields and relationships",
|
||||||
|
input: `entity User desc "User management" {
|
||||||
|
id: uuid required unique
|
||||||
|
email: string required validate email validate min_length "5"
|
||||||
|
name: string default "Anonymous"
|
||||||
|
profile_id: uuid relates to Profile as one via "user_id"
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Entity: &Entity{
|
||||||
|
Name: "User",
|
||||||
|
Description: stringPtr("User management"),
|
||||||
|
Fields: []Field{
|
||||||
|
{
|
||||||
|
Name: "id",
|
||||||
|
Type: "uuid",
|
||||||
|
Required: true,
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "email",
|
||||||
|
Type: "string",
|
||||||
|
Required: true,
|
||||||
|
Validations: []Validation{
|
||||||
|
{Type: "email"},
|
||||||
|
{Type: "min_length", Value: stringPtr("5")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "name",
|
||||||
|
Type: "string",
|
||||||
|
Default: stringPtr("Anonymous"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "profile_id",
|
||||||
|
Type: "uuid",
|
||||||
|
Relationship: &Relationship{
|
||||||
|
Type: "Profile",
|
||||||
|
Cardinality: "one",
|
||||||
|
ForeignKey: stringPtr("user_id"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple entity with basic fields",
|
||||||
|
input: `entity Product {
|
||||||
|
id: uuid required unique
|
||||||
|
name: string required
|
||||||
|
price: decimal default "0.00"
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Entity: &Entity{
|
||||||
|
Name: "Product",
|
||||||
|
Fields: []Field{
|
||||||
|
{
|
||||||
|
Name: "id",
|
||||||
|
Type: "uuid",
|
||||||
|
Required: true,
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "name",
|
||||||
|
Type: "string",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "price",
|
||||||
|
Type: "decimal",
|
||||||
|
Default: stringPtr("0.00"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "entity without fields block",
|
||||||
|
input: `entity SimpleEntity`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Entity: &Entity{
|
||||||
|
Name: "SimpleEntity",
|
||||||
|
Fields: []Field{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantErr && !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() mismatch.\nGot: %+v\nWant: %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
103
lang/parser_server_test.go
Normal file
103
lang/parser_server_test.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseServerDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple server definition with block delimiters",
|
||||||
|
input: `server MyApp {
|
||||||
|
host "localhost"
|
||||||
|
port 8080
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Server: &Server{
|
||||||
|
Name: "MyApp",
|
||||||
|
Settings: []ServerSetting{
|
||||||
|
{Host: stringPtr("localhost")},
|
||||||
|
{Port: intPtr(8080)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server with only host setting",
|
||||||
|
input: `server WebApp {
|
||||||
|
host "0.0.0.0"
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Server: &Server{
|
||||||
|
Name: "WebApp",
|
||||||
|
Settings: []ServerSetting{
|
||||||
|
{Host: stringPtr("0.0.0.0")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server with only port setting",
|
||||||
|
input: `server APIServer {
|
||||||
|
port 3000
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Server: &Server{
|
||||||
|
Name: "APIServer",
|
||||||
|
Settings: []ServerSetting{
|
||||||
|
{Port: intPtr(3000)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server without settings block",
|
||||||
|
input: `server SimpleServer`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Server: &Server{
|
||||||
|
Name: "SimpleServer",
|
||||||
|
Settings: []ServerSetting{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantErr && !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() mismatch.\nGot: %+v\nWant: %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
575
lang/parser_ui_advanced_test.go
Normal file
575
lang/parser_ui_advanced_test.go
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAdvancedUIFeatures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "complex conditional rendering with multiple operators",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for User {
|
||||||
|
field status type select options ["active", "inactive", "pending"]
|
||||||
|
|
||||||
|
when status equals "active" {
|
||||||
|
field last_login type datetime
|
||||||
|
field permissions type multiselect
|
||||||
|
button deactivate label "Deactivate User" style "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
when status not_equals "active" {
|
||||||
|
field reason type textarea placeholder "Reason for status"
|
||||||
|
button activate label "Activate User" style "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
when status contains "pending" {
|
||||||
|
field approval_date type date
|
||||||
|
button approve label "Approve" style "primary"
|
||||||
|
button reject label "Reject" style "danger"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []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: "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"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "reject",
|
||||||
|
Label: "Reject",
|
||||||
|
Attributes: []ComponentButtonAttr{
|
||||||
|
{Style: &ComponentButtonStyle{Value: "danger"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "field attributes with all possible options",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for Product {
|
||||||
|
field name type text {
|
||||||
|
label "Product Name"
|
||||||
|
placeholder "Enter product name"
|
||||||
|
required
|
||||||
|
default "New Product"
|
||||||
|
validate min_length "3"
|
||||||
|
size "large"
|
||||||
|
display "block"
|
||||||
|
}
|
||||||
|
|
||||||
|
field price type number {
|
||||||
|
label "Price ($)"
|
||||||
|
format "currency"
|
||||||
|
validate min "0"
|
||||||
|
validate max "10000"
|
||||||
|
}
|
||||||
|
|
||||||
|
field category type autocomplete {
|
||||||
|
label "Category"
|
||||||
|
placeholder "Start typing..."
|
||||||
|
relates to Category
|
||||||
|
searchable
|
||||||
|
source "categories/search"
|
||||||
|
}
|
||||||
|
|
||||||
|
field tags type multiselect {
|
||||||
|
label "Tags"
|
||||||
|
options ["electronics", "clothing", "books", "home"]
|
||||||
|
source "tags/popular"
|
||||||
|
}
|
||||||
|
|
||||||
|
field description type richtext {
|
||||||
|
label "Description"
|
||||||
|
rows 10
|
||||||
|
placeholder "Describe your product..."
|
||||||
|
}
|
||||||
|
|
||||||
|
field thumbnail type image {
|
||||||
|
label "Product Image"
|
||||||
|
accept "image/jpeg,image/png"
|
||||||
|
thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
field featured type checkbox {
|
||||||
|
label "Featured Product"
|
||||||
|
default "false"
|
||||||
|
value "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
field availability type select {
|
||||||
|
label "Availability"
|
||||||
|
options ["in_stock", "out_of_stock", "pre_order"]
|
||||||
|
default "in_stock"
|
||||||
|
sortable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []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: "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: "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: "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},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex button configurations",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for Order {
|
||||||
|
field status type select options ["draft", "submitted", "approved"]
|
||||||
|
|
||||||
|
button save label "Save Draft" style "secondary" icon "save" position "left"
|
||||||
|
button submit label "Submit Order" style "primary" icon "send" loading "Submitting..." confirm "Submit this order?"
|
||||||
|
button approve label "Approve" style "success" loading "Approving..." disabled when status confirm "Approve this order?" target approval_modal via "api/orders/approve"
|
||||||
|
button reject label "Reject" style "danger" icon "x" confirm "Are you sure you want to reject this order?"
|
||||||
|
button print label "Print" style "outline" icon "printer" position "right"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []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: "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: "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"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() got = %v, want = %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFieldValidationTypes(t *testing.T) {
|
||||||
|
validationTypes := []struct {
|
||||||
|
validation string
|
||||||
|
hasValue bool
|
||||||
|
}{
|
||||||
|
{"email", false},
|
||||||
|
{"required", false},
|
||||||
|
{"min_length", true},
|
||||||
|
{"max_length", true},
|
||||||
|
{"min", true},
|
||||||
|
{"max", true},
|
||||||
|
{"pattern", true},
|
||||||
|
{"numeric", false},
|
||||||
|
{"alpha", false},
|
||||||
|
{"alphanumeric", false},
|
||||||
|
{"url", false},
|
||||||
|
{"date", false},
|
||||||
|
{"datetime", false},
|
||||||
|
{"time", false},
|
||||||
|
{"phone", false},
|
||||||
|
{"postal_code", false},
|
||||||
|
{"credit_card", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, vt := range validationTypes {
|
||||||
|
t.Run("validation_"+vt.validation, func(t *testing.T) {
|
||||||
|
var input string
|
||||||
|
if vt.hasValue {
|
||||||
|
input = `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test_field type text validate ` + vt.validation + ` "test_value"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
} else {
|
||||||
|
input = `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test_field type text validate ` + vt.validation + `
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseInput() failed for validation %s: %v", vt.validation, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
|
||||||
|
t.Errorf("ParseInput() failed to parse page for validation %s", vt.validation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := got.Definitions[0].Page
|
||||||
|
if len(page.Components) != 1 || len(page.Components[0].Elements) != 1 {
|
||||||
|
t.Errorf("ParseInput() failed to parse component for validation %s", vt.validation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
element := page.Components[0].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
|
||||||
|
}
|
||||||
|
|
||||||
|
attr := element.Field.Attributes[0]
|
||||||
|
if attr.Validation == nil || attr.Validation.Type != vt.validation {
|
||||||
|
t.Errorf("ParseInput() validation type mismatch: got %v, want %s", attr.Validation, vt.validation)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vt.hasValue && (attr.Validation.Value == nil || *attr.Validation.Value != "test_value") {
|
||||||
|
t.Errorf("ParseInput() validation value mismatch for %s", vt.validation)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConditionalOperators(t *testing.T) {
|
||||||
|
operators := []string{"equals", "not_equals", "contains"}
|
||||||
|
|
||||||
|
for _, op := range operators {
|
||||||
|
t.Run("operator_"+op, func(t *testing.T) {
|
||||||
|
input := `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test_field type text
|
||||||
|
when test_field ` + op + ` "test_value" {
|
||||||
|
field conditional_field type text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
got, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseInput() failed for operator %s: %v", op, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the when condition was parsed correctly
|
||||||
|
page := got.Definitions[0].Page
|
||||||
|
component := page.Components[0]
|
||||||
|
whenElement := component.Elements[1].When
|
||||||
|
|
||||||
|
if whenElement == nil || whenElement.Operator != op {
|
||||||
|
t.Errorf("ParseInput() operator mismatch: got %v, want %s", whenElement, op)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAdvancedUIErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid conditional operator",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
when field invalid_operator "value" {
|
||||||
|
field test type text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing field attribute block closure",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test type text {
|
||||||
|
label "Test"
|
||||||
|
required
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseInput(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
548
lang/parser_ui_component_test.go
Normal file
548
lang/parser_ui_component_test.go
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseComponentDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic component with entity",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component table for User
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
Type: "table",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "form component with fields",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for User {
|
||||||
|
field name type text label "Full Name" placeholder "Enter your name" required
|
||||||
|
field email type email label "Email Address" required
|
||||||
|
field bio type textarea rows 5 placeholder "Tell us about yourself"
|
||||||
|
field avatar type file accept "image/*"
|
||||||
|
field role type select options ["admin", "user", "guest"] default "user"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []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: "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: "role",
|
||||||
|
Type: "select",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Options: []string{"admin", "user", "guest"}},
|
||||||
|
{Default: stringPtr("user")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "component with field attributes and validation",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for Product {
|
||||||
|
field name type text required validate min_length "3"
|
||||||
|
field price type number format "currency" validate min "0"
|
||||||
|
field category type autocomplete relates to Category
|
||||||
|
field tags type multiselect source "tags/popular"
|
||||||
|
field description type richtext
|
||||||
|
field featured type checkbox default "false"
|
||||||
|
field thumbnail type image thumbnail
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []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: "category",
|
||||||
|
Type: "autocomplete",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Relates: &FieldRelation{Type: "Category"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "thumbnail",
|
||||||
|
Type: "image",
|
||||||
|
Attributes: []ComponentFieldAttribute{
|
||||||
|
{Thumbnail: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "component with buttons",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for User {
|
||||||
|
field name type text
|
||||||
|
button save label "Save User" style "primary" icon "save"
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
button delete label "Delete" style "danger" confirm "Are you sure?" disabled when is_protected
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []Component{
|
||||||
|
{
|
||||||
|
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"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "component with conditional fields",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form for User {
|
||||||
|
field account_type type select options ["personal", "business"]
|
||||||
|
when account_type equals "business" {
|
||||||
|
field company_name type text required
|
||||||
|
field tax_id type text
|
||||||
|
button verify_business label "Verify Business"
|
||||||
|
}
|
||||||
|
when account_type equals "personal" {
|
||||||
|
field date_of_birth type date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []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},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tax_id",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "component with nested sections",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component dashboard {
|
||||||
|
section stats type container class "stats-grid" {
|
||||||
|
component metric {
|
||||||
|
field total_users type display value "1,234"
|
||||||
|
field revenue type display format "currency" value "45,678"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section charts type container {
|
||||||
|
component chart for Analytics {
|
||||||
|
data from "analytics/monthly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Components: []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")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "charts",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "chart",
|
||||||
|
Entity: stringPtr("Analytics"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Attribute: &ComponentAttr{
|
||||||
|
DataSource: stringPtr("analytics/monthly"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseComponentFieldTypes(t *testing.T) {
|
||||||
|
fieldTypes := []string{
|
||||||
|
"text", "email", "password", "number", "date", "datetime", "time",
|
||||||
|
"textarea", "richtext", "select", "multiselect", "checkbox", "radio",
|
||||||
|
"file", "image", "autocomplete", "range", "color", "url", "tel",
|
||||||
|
"hidden", "display", "json", "code",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fieldType := range fieldTypes {
|
||||||
|
t.Run("field_type_"+fieldType, func(t *testing.T) {
|
||||||
|
input := `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field test_field type ` + fieldType + `
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
got, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseInput() failed for field type %s: %v", fieldType, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
|
||||||
|
t.Errorf("ParseInput() failed to parse page for field type %s", fieldType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := got.Definitions[0].Page
|
||||||
|
if len(page.Components) != 1 || len(page.Components[0].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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseComponentErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing component type",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid field syntax",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
field name
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid button syntax",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
component form {
|
||||||
|
button
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseInput(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
300
lang/parser_ui_page_test.go
Normal file
300
lang/parser_ui_page_test.go
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePageDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic page with minimal fields",
|
||||||
|
input: `page Dashboard at "/dashboard" layout main`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Dashboard",
|
||||||
|
Path: "/dashboard",
|
||||||
|
Layout: "main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page with all optional fields",
|
||||||
|
input: `page UserProfile at "/profile" layout main title "User Profile" desc "Manage user profile settings" auth`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "UserProfile",
|
||||||
|
Path: "/profile",
|
||||||
|
Layout: "main",
|
||||||
|
Title: stringPtr("User Profile"),
|
||||||
|
Description: stringPtr("Manage user profile settings"),
|
||||||
|
Auth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page with meta tags",
|
||||||
|
input: `page HomePage at "/" layout main {
|
||||||
|
meta description "Welcome to our application"
|
||||||
|
meta keywords "app, dashboard, management"
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "HomePage",
|
||||||
|
Path: "/",
|
||||||
|
Layout: "main",
|
||||||
|
Meta: []MetaTag{
|
||||||
|
{Name: "description", Content: "Welcome to our application"},
|
||||||
|
{Name: "keywords", Content: "app, dashboard, management"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page with nested 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section security label "Security" {
|
||||||
|
component form for User {
|
||||||
|
field password type password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Settings",
|
||||||
|
Path: "/settings",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []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"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "name",
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Section: &Section{
|
||||||
|
Name: "security",
|
||||||
|
Label: stringPtr("Security"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "form",
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
Elements: []ComponentElement{
|
||||||
|
{
|
||||||
|
Field: &ComponentField{
|
||||||
|
Name: "password",
|
||||||
|
Type: "password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page with modal and panel sections",
|
||||||
|
input: `page ProductList at "/products" layout main {
|
||||||
|
section main type container {
|
||||||
|
component table for Product
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "ProductList",
|
||||||
|
Path: "/products",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "table",
|
||||||
|
Entity: stringPtr("Product"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePageErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing layout",
|
||||||
|
input: `page Dashboard at "/dashboard"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing path",
|
||||||
|
input: `page Dashboard layout main`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid path format",
|
||||||
|
input: `page Dashboard at dashboard layout main`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseInput(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
599
lang/parser_ui_section_test.go
Normal file
599
lang/parser_ui_section_test.go
Normal file
@ -0,0 +1,599 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSectionDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want AST
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic container section",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section main type container
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Type: stringPtr("container"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "section with all attributes",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section sidebar type panel class "sidebar-nav" label "Navigation" trigger "toggle-sidebar" position "left" for User
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []Section{
|
||||||
|
{
|
||||||
|
Name: "sidebar",
|
||||||
|
Type: stringPtr("panel"),
|
||||||
|
Position: stringPtr("left"),
|
||||||
|
Class: stringPtr("sidebar-nav"),
|
||||||
|
Label: stringPtr("Navigation"),
|
||||||
|
Trigger: stringPtr("toggle-sidebar"),
|
||||||
|
Entity: stringPtr("User"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tab sections with active state",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section tabs type tab {
|
||||||
|
section overview label "Overview" active
|
||||||
|
section details label "Details"
|
||||||
|
section settings label "Settings"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []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: "settings",
|
||||||
|
Label: stringPtr("Settings"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modal section with content",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section userModal type modal trigger "edit-user" {
|
||||||
|
component form for User {
|
||||||
|
field name type text required
|
||||||
|
field email type email required
|
||||||
|
button save label "Save Changes" style "primary"
|
||||||
|
button cancel label "Cancel" style "secondary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []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},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "master-detail sections",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section masterDetail type master {
|
||||||
|
section userList type container {
|
||||||
|
component table for User {
|
||||||
|
fields [name, email]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section userDetail type detail trigger "user-selected" for User {
|
||||||
|
component form for User {
|
||||||
|
field name type text
|
||||||
|
field email type email
|
||||||
|
field bio type textarea
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []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"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "bio",
|
||||||
|
Type: "textarea",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deeply nested sections",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section mainLayout type container {
|
||||||
|
section header type container class "header" {
|
||||||
|
component navbar {
|
||||||
|
field search type text placeholder "Search..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section content type container {
|
||||||
|
section sidebar type panel position "left" {
|
||||||
|
component menu {
|
||||||
|
field navigation type list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section main type container {
|
||||||
|
section tabs type tab {
|
||||||
|
section overview label "Overview" active {
|
||||||
|
component dashboard {
|
||||||
|
field stats type metric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section reports label "Reports" {
|
||||||
|
component table for Report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []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...")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "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: "reports",
|
||||||
|
Label: stringPtr("Reports"),
|
||||||
|
Elements: []SectionElement{
|
||||||
|
{
|
||||||
|
Component: &Component{
|
||||||
|
Type: "table",
|
||||||
|
Entity: stringPtr("Report"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "section with conditional content",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section adminPanel type container {
|
||||||
|
when user_role equals "admin" {
|
||||||
|
section userManagement type container {
|
||||||
|
component table for User
|
||||||
|
}
|
||||||
|
section systemSettings type container {
|
||||||
|
component form for Settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: AST{
|
||||||
|
Definitions: []Definition{
|
||||||
|
{
|
||||||
|
Page: &Page{
|
||||||
|
Name: "Test",
|
||||||
|
Path: "/test",
|
||||||
|
Layout: "main",
|
||||||
|
Sections: []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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseInput() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !astEqual(got, tt.want) {
|
||||||
|
t.Errorf("ParseInput() got = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSectionTypes(t *testing.T) {
|
||||||
|
sectionTypes := []string{
|
||||||
|
"container", "tab", "panel", "modal", "master", "detail",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sectionType := range sectionTypes {
|
||||||
|
t.Run("section_type_"+sectionType, func(t *testing.T) {
|
||||||
|
input := `page Test at "/test" layout main {
|
||||||
|
section test_section type ` + sectionType + `
|
||||||
|
}`
|
||||||
|
|
||||||
|
got, err := ParseInput(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseInput() failed for section type %s: %v", sectionType, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Definitions) != 1 || got.Definitions[0].Page == nil {
|
||||||
|
t.Errorf("ParseInput() failed to parse page for section type %s", sectionType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := got.Definitions[0].Page
|
||||||
|
if len(page.Sections) != 1 {
|
||||||
|
t.Errorf("ParseInput() failed to parse section for type %s", sectionType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
section := page.Sections[0]
|
||||||
|
if section.Type == nil || *section.Type != sectionType {
|
||||||
|
t.Errorf("ParseInput() section type mismatch: got %v, want %s", section.Type, sectionType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSectionErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing section name",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section type container
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid section type",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section test type invalid_type
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unclosed section block",
|
||||||
|
input: `page Test at "/test" layout main {
|
||||||
|
section test type container {
|
||||||
|
component form
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseInput(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseInput() expected error for invalid syntax, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
59
lang/test_ast_comparisons.go
Normal file
59
lang/test_ast_comparisons.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// AST and definition comparison functions for parser tests
|
||||||
|
|
||||||
|
// Custom comparison functions (simplified for the new structure)
|
||||||
|
func astEqual(got, want AST) bool {
|
||||||
|
if len(got.Definitions) != len(want.Definitions) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range got.Definitions {
|
||||||
|
if !definitionEqual(got.Definitions[i], want.Definitions[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func definitionEqual(got, want Definition) bool {
|
||||||
|
// Server comparison
|
||||||
|
if (got.Server == nil) != (want.Server == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Server != nil && want.Server != nil {
|
||||||
|
if !serverEqual(*got.Server, *want.Server) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity comparison
|
||||||
|
if (got.Entity == nil) != (want.Entity == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Entity != nil && want.Entity != nil {
|
||||||
|
if !entityEqual(*got.Entity, *want.Entity) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint comparison
|
||||||
|
if (got.Endpoint == nil) != (want.Endpoint == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Endpoint != nil && want.Endpoint != nil {
|
||||||
|
if !endpointEqual(*got.Endpoint, *want.Endpoint) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page comparison (enhanced)
|
||||||
|
if (got.Page == nil) != (want.Page == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Page != nil && want.Page != nil {
|
||||||
|
return pageEqual(*got.Page, *want.Page)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
46
lang/test_comparison_utils.go
Normal file
46
lang/test_comparison_utils.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// Basic comparison utilities for parser tests
|
||||||
|
|
||||||
|
// Helper functions for creating pointers
|
||||||
|
func stringPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(i int) *int {
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer comparison functions
|
||||||
|
func stringPtrEqual(got, want *string) bool {
|
||||||
|
if (got == nil) != (want == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got != nil && want != nil {
|
||||||
|
return *got == *want
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtrEqual(got, want *int) bool {
|
||||||
|
if (got == nil) != (want == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got != nil && want != nil {
|
||||||
|
return *got == *want
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice comparison functions
|
||||||
|
func stringSliceEqual(got, want []string) bool {
|
||||||
|
if len(got) != len(want) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, s := range got {
|
||||||
|
if s != want[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
69
lang/test_field_comparisons.go
Normal file
69
lang/test_field_comparisons.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// Field and validation comparison functions for parser tests
|
||||||
|
|
||||||
|
func fieldEqual(got, want Field) bool {
|
||||||
|
if got.Name != want.Name || got.Type != want.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Required != want.Required || got.Unique != want.Unique || got.Index != want.Index {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Default, want.Default) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Validations) != len(want.Validations) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, validation := range got.Validations {
|
||||||
|
if !validationEqual(validation, want.Validations[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (got.Relationship == nil) != (want.Relationship == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Relationship != nil && want.Relationship != nil {
|
||||||
|
if !relationshipEqual(*got.Relationship, *want.Relationship) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validationEqual(got, want Validation) bool {
|
||||||
|
return got.Type == want.Type && stringPtrEqual(got.Value, want.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func relationshipEqual(got, want Relationship) bool {
|
||||||
|
return got.Type == want.Type &&
|
||||||
|
got.Cardinality == want.Cardinality &&
|
||||||
|
stringPtrEqual(got.ForeignKey, want.ForeignKey) &&
|
||||||
|
stringPtrEqual(got.Through, want.Through)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldRelationEqual(got, want *FieldRelation) bool {
|
||||||
|
if (got == nil) != (want == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got != nil && want != nil {
|
||||||
|
return got.Type == want.Type
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentValidationEqual(got, want *ComponentValidation) bool {
|
||||||
|
if (got == nil) != (want == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got != nil && want != nil {
|
||||||
|
return got.Type == want.Type && stringPtrEqual(got.Value, want.Value)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
57
lang/test_server_entity_comparisons.go
Normal file
57
lang/test_server_entity_comparisons.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// Server and entity comparison functions for parser tests
|
||||||
|
|
||||||
|
func serverEqual(got, want Server) bool {
|
||||||
|
if got.Name != want.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Settings) != len(want.Settings) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, setting := range got.Settings {
|
||||||
|
if !serverSettingEqual(setting, want.Settings[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverSettingEqual(got, want ServerSetting) bool {
|
||||||
|
return stringPtrEqual(got.Host, want.Host) && intPtrEqual(got.Port, want.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func entityEqual(got, want Entity) bool {
|
||||||
|
if got.Name != want.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Description, want.Description) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Fields) != len(want.Fields) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, field := range got.Fields {
|
||||||
|
if !fieldEqual(field, want.Fields[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpointEqual(got, want Endpoint) bool {
|
||||||
|
return got.Method == want.Method &&
|
||||||
|
got.Path == want.Path &&
|
||||||
|
stringPtrEqual(got.Entity, want.Entity) &&
|
||||||
|
stringPtrEqual(got.Description, want.Description) &&
|
||||||
|
got.Auth == want.Auth &&
|
||||||
|
stringPtrEqual(got.CustomLogic, want.CustomLogic)
|
||||||
|
// TODO: Add params and response comparison if needed
|
||||||
|
}
|
421
lang/test_ui_comparisons.go
Normal file
421
lang/test_ui_comparisons.go
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
// Page and UI component comparison functions for parser tests
|
||||||
|
|
||||||
|
func pageEqual(got, want Page) bool {
|
||||||
|
if got.Name != want.Name || got.Path != want.Path || got.Layout != want.Layout {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Title, want.Title) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Description, want.Description) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Auth != want.Auth {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare meta tags
|
||||||
|
if len(got.Meta) != len(want.Meta) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, meta := range got.Meta {
|
||||||
|
if !metaTagEqual(meta, want.Meta[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare sections (unified model)
|
||||||
|
if len(got.Sections) != len(want.Sections) {
|
||||||
|
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]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func metaTagEqual(got, want MetaTag) bool {
|
||||||
|
return got.Name == want.Name && got.Content == want.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionEqual(got, want Section) bool {
|
||||||
|
if got.Name != want.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Type, want.Type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Class, want.Class) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Label, want.Label) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Active != want.Active {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Trigger, want.Trigger) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Position, want.Position) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Entity, want.Entity) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract different element types from the unified elements
|
||||||
|
gotAttributes := extractSectionAttributes(got.Elements)
|
||||||
|
gotComponents := extractSectionComponents(got.Elements)
|
||||||
|
gotSections := extractSectionSections(got.Elements)
|
||||||
|
gotWhen := extractSectionWhen(got.Elements)
|
||||||
|
|
||||||
|
wantAttributes := extractSectionAttributes(want.Elements)
|
||||||
|
wantComponents := extractSectionComponents(want.Elements)
|
||||||
|
wantSections := extractSectionSections(want.Elements)
|
||||||
|
wantWhen := extractSectionWhen(want.Elements)
|
||||||
|
|
||||||
|
// Compare attributes
|
||||||
|
if len(gotAttributes) != len(wantAttributes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, attr := range gotAttributes {
|
||||||
|
if !sectionAttributeEqual(attr, wantAttributes[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare components
|
||||||
|
if len(gotComponents) != len(wantComponents) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, comp := range gotComponents {
|
||||||
|
if !componentEqual(comp, wantComponents[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare nested sections
|
||||||
|
if len(gotSections) != len(wantSections) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, sect := range gotSections {
|
||||||
|
if !sectionEqual(sect, wantSections[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare when conditions
|
||||||
|
if len(gotWhen) != len(wantWhen) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, when := range gotWhen {
|
||||||
|
if !whenConditionEqual(when, wantWhen[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions to extract different element types from unified elements
|
||||||
|
func extractSectionAttributes(elements []SectionElement) []SectionAttribute {
|
||||||
|
var attrs []SectionAttribute
|
||||||
|
for _, elem := range elements {
|
||||||
|
if elem.Attribute != nil {
|
||||||
|
attrs = append(attrs, *elem.Attribute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSectionComponents(elements []SectionElement) []Component {
|
||||||
|
var comps []Component
|
||||||
|
for _, elem := range elements {
|
||||||
|
if elem.Component != nil {
|
||||||
|
comps = append(comps, *elem.Component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return comps
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSectionSections(elements []SectionElement) []Section {
|
||||||
|
var sects []Section
|
||||||
|
for _, elem := range elements {
|
||||||
|
if elem.Section != nil {
|
||||||
|
sects = append(sects, *elem.Section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sects
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSectionWhen(elements []SectionElement) []WhenCondition {
|
||||||
|
var whens []WhenCondition
|
||||||
|
for _, elem := range elements {
|
||||||
|
if elem.When != nil {
|
||||||
|
whens = append(whens, *elem.When)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return whens
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionAttributeEqual(got, want SectionAttribute) bool {
|
||||||
|
return stringPtrEqual(got.DataSource, want.DataSource) &&
|
||||||
|
stringPtrEqual(got.Style, want.Style) &&
|
||||||
|
stringPtrEqual(got.Classes, want.Classes) &&
|
||||||
|
intPtrEqual(got.Size, want.Size) &&
|
||||||
|
stringPtrEqual(got.Theme, want.Theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentEqual(got, want Component) bool {
|
||||||
|
if got.Type != want.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stringPtrEqual(got.Entity, want.Entity) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Elements) != len(want.Elements) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, elem := range got.Elements {
|
||||||
|
if !componentElementEqual(elem, want.Elements[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentElementEqual(got, want ComponentElement) bool {
|
||||||
|
// Compare attributes
|
||||||
|
if (got.Attribute == nil) != (want.Attribute == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Attribute != nil && want.Attribute != nil {
|
||||||
|
if !componentAttrEqual(*got.Attribute, *want.Attribute) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare fields
|
||||||
|
if (got.Field == nil) != (want.Field == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Field != nil && want.Field != nil {
|
||||||
|
if !componentFieldEqual(*got.Field, *want.Field) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare sections
|
||||||
|
if (got.Section == nil) != (want.Section == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Section != nil && want.Section != nil {
|
||||||
|
if !sectionEqual(*got.Section, *want.Section) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare buttons
|
||||||
|
if (got.Button == nil) != (want.Button == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.Button != nil && want.Button != nil {
|
||||||
|
if !componentButtonEqual(*got.Button, *want.Button) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare when conditions
|
||||||
|
if (got.When == nil) != (want.When == nil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if got.When != nil && want.When != nil {
|
||||||
|
if !whenConditionEqual(*got.When, *want.When) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentAttrEqual(got, want ComponentAttr) bool {
|
||||||
|
return stringPtrEqual(got.DataSource, want.DataSource) &&
|
||||||
|
stringSliceEqual(got.Fields, want.Fields) &&
|
||||||
|
stringSliceEqual(got.Actions, want.Actions) &&
|
||||||
|
stringPtrEqual(got.Style, want.Style) &&
|
||||||
|
stringPtrEqual(got.Classes, want.Classes) &&
|
||||||
|
intPtrEqual(got.PageSize, want.PageSize) &&
|
||||||
|
got.Validate == want.Validate
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentFieldEqual(got, want ComponentField) bool {
|
||||||
|
if got.Name != want.Name || got.Type != want.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Attributes) != len(want.Attributes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, attr := range got.Attributes {
|
||||||
|
if !componentFieldAttributeEqual(attr, want.Attributes[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentFieldAttributeEqual(got, want ComponentFieldAttribute) bool {
|
||||||
|
return stringPtrEqual(got.Label, want.Label) &&
|
||||||
|
stringPtrEqual(got.Placeholder, want.Placeholder) &&
|
||||||
|
got.Required == want.Required &&
|
||||||
|
got.Sortable == want.Sortable &&
|
||||||
|
got.Searchable == want.Searchable &&
|
||||||
|
got.Thumbnail == want.Thumbnail &&
|
||||||
|
stringPtrEqual(got.Default, want.Default) &&
|
||||||
|
stringSliceEqual(got.Options, want.Options) &&
|
||||||
|
stringPtrEqual(got.Accept, want.Accept) &&
|
||||||
|
intPtrEqual(got.Rows, want.Rows) &&
|
||||||
|
stringPtrEqual(got.Format, want.Format) &&
|
||||||
|
stringPtrEqual(got.Size, want.Size) &&
|
||||||
|
stringPtrEqual(got.Display, want.Display) &&
|
||||||
|
stringPtrEqual(got.Value, want.Value) &&
|
||||||
|
stringPtrEqual(got.Source, want.Source) &&
|
||||||
|
fieldRelationEqual(got.Relates, want.Relates) &&
|
||||||
|
componentValidationEqual(got.Validation, want.Validation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func componentButtonEqual(got, want ComponentButton) bool {
|
||||||
|
if got.Name != want.Name || got.Label != want.Label {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract attributes from both buttons for comparison
|
||||||
|
gotStyle, gotIcon, gotLoading, gotDisabled, gotConfirm, gotTarget, gotPosition, gotVia := extractButtonAttributesNew(got.Attributes)
|
||||||
|
wantStyle, wantIcon, wantLoading, wantDisabled, wantConfirm, wantTarget, wantPosition, wantVia := extractButtonAttributesNew(want.Attributes)
|
||||||
|
|
||||||
|
return stringPtrEqual(gotStyle, wantStyle) &&
|
||||||
|
stringPtrEqual(gotIcon, wantIcon) &&
|
||||||
|
stringPtrEqual(gotLoading, wantLoading) &&
|
||||||
|
stringPtrEqual(gotDisabled, wantDisabled) &&
|
||||||
|
stringPtrEqual(gotConfirm, wantConfirm) &&
|
||||||
|
stringPtrEqual(gotTarget, wantTarget) &&
|
||||||
|
stringPtrEqual(gotPosition, wantPosition) &&
|
||||||
|
stringPtrEqual(gotVia, wantVia)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to extract button attributes from the new structure
|
||||||
|
func extractButtonAttributesNew(attrs []ComponentButtonAttr) (*string, *string, *string, *string, *string, *string, *string, *string) {
|
||||||
|
var style, icon, loading, disabled, confirm, target, position, via *string
|
||||||
|
|
||||||
|
for _, attr := range attrs {
|
||||||
|
if attr.Style != nil {
|
||||||
|
style = &attr.Style.Value
|
||||||
|
}
|
||||||
|
if attr.Icon != nil {
|
||||||
|
icon = &attr.Icon.Value
|
||||||
|
}
|
||||||
|
if attr.Loading != nil {
|
||||||
|
loading = &attr.Loading.Value
|
||||||
|
}
|
||||||
|
if attr.Disabled != nil {
|
||||||
|
disabled = &attr.Disabled.Value
|
||||||
|
}
|
||||||
|
if attr.Confirm != nil {
|
||||||
|
confirm = &attr.Confirm.Value
|
||||||
|
}
|
||||||
|
if attr.Target != nil {
|
||||||
|
target = &attr.Target.Value
|
||||||
|
}
|
||||||
|
if attr.Position != nil {
|
||||||
|
position = &attr.Position.Value
|
||||||
|
}
|
||||||
|
if attr.Via != nil {
|
||||||
|
via = &attr.Via.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return style, icon, loading, disabled, confirm, target, position, via
|
||||||
|
}
|
||||||
|
|
||||||
|
func whenConditionEqual(got, want WhenCondition) bool {
|
||||||
|
if got.Field != want.Field || got.Operator != want.Operator || got.Value != want.Value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare fields
|
||||||
|
if len(got.Fields) != len(want.Fields) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, field := range got.Fields {
|
||||||
|
if !componentFieldEqual(field, want.Fields[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare sections
|
||||||
|
if len(got.Sections) != len(want.Sections) {
|
||||||
|
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]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare buttons
|
||||||
|
if len(got.Buttons) != len(want.Buttons) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, button := range got.Buttons {
|
||||||
|
if !componentButtonEqual(button, want.Buttons[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
Reference in New Issue
Block a user