Skip to content

Commit a110d6c

Browse files
adnaanclaude
andauthored
feat: smart form validation with field types and HTML5 attrs (#255)
* feat: add smart form validation with field types, HTML5 attrs, and password confirmation Add specialized field types (email, url, phone/tel, password) that generate appropriate Go validation tags and HTML5 input attributes. Replace template-level GoType branching with metadata-driven ValidateTag, eliminating duplicated logic across 5 handler templates. Add password confirmation with eqfield cross-validation. Enhance ValidationToMultiError in the livetemplate framework with formatFieldName for human-readable error messages and toSnakeCase for correct multi-word form field name matching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address bot review comments - Remove unused goType param from GetFieldMetadata (Claude) - Add required validate tag for reference field IDs (Copilot) - Add password confirmation to embedded template edit forms (Claude) - Add maxlength to embedded template password edit inputs (consistency) - Derive supported-types list via supportedTypes() helper (Copilot) - Add unknown_type fallback test case for GetFieldMetadata (Claude) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: populate field metadata in parseFieldsWithInference The CLI's parseFieldsWithInference constructed parser.Field without Metadata, causing generated handlers to omit validation tags entirely. This broke E2E validation tests since server-side validation was no longer enforced. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fe46094 commit a110d6c

17 files changed

Lines changed: 443 additions & 128 deletions

File tree

commands/gen.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,7 @@ func parseFieldsWithInference(fieldArgs []string) ([]parser.Field, error) {
587587
GoType: goType,
588588
SQLType: sqlType,
589589
IsTextarea: isTextarea,
590+
Metadata: parser.GetFieldMetadata(typ),
590591
}
591592

592593
// Parse reference metadata if it's a reference type
@@ -599,6 +600,7 @@ func parseFieldsWithInference(fieldArgs []string) ([]parser.Field, error) {
599600
field.IsReference = true
600601
field.ReferencedTable = parts[1]
601602
field.OnDelete = "CASCADE" // Default
603+
field.Metadata = parser.FieldMetadata{ValidateTag: "required", HTMLInputType: "text"}
602604

603605
// Check for custom on_delete action
604606
if len(parts) > 2 {

golden_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ func TestResourceHandlerGolden(t *testing.T) {
2121
}
2222

2323
fields := []parser.Field{
24-
{Name: "name", Type: "string", GoType: "string", SQLType: "TEXT"},
25-
{Name: "age", Type: "int", GoType: "int64", SQLType: "INTEGER"},
24+
{Name: "name", Type: "string", GoType: "string", SQLType: "TEXT", Metadata: parser.GetFieldMetadata("string")},
25+
{Name: "age", Type: "int", GoType: "int64", SQLType: "INTEGER", Metadata: parser.GetFieldMetadata("int")},
2626
}
2727

2828
if err := generator.GenerateResource(tmpDir, "testmodule", "User", fields, "multi", "tailwind", "tailwind", "infinite", 20, "modal", ""); err != nil {
@@ -91,7 +91,7 @@ func TestResourceHandlerUnstyledImport(t *testing.T) {
9191
}
9292

9393
fields := []parser.Field{
94-
{Name: "name", Type: "string", GoType: "string", SQLType: "TEXT"},
94+
{Name: "name", Type: "string", GoType: "string", SQLType: "TEXT", Metadata: parser.GetFieldMetadata("string")},
9595
}
9696

9797
if err := generator.GenerateResource(tmpDir, "testmodule", "Item", fields, "multi", "tailwind", "unstyled", "infinite", 20, "modal", ""); err != nil {
@@ -123,7 +123,7 @@ func TestResourceHandlerInvalidStyles(t *testing.T) {
123123
}
124124

125125
fields := []parser.Field{
126-
{Name: "name", Type: "string", GoType: "string", SQLType: "TEXT"},
126+
{Name: "name", Type: "string", GoType: "string", SQLType: "TEXT", Metadata: parser.GetFieldMetadata("string")},
127127
}
128128

129129
err := generator.GenerateResource(tmpDir, "testmodule", "Item", fields, "multi", "tailwind", "bootstrap", "infinite", 20, "modal", "")
@@ -192,8 +192,8 @@ func TestResourceTemplateGolden(t *testing.T) {
192192
}
193193

194194
fields := []parser.Field{
195-
{Name: "title", Type: "string", GoType: "string", SQLType: "TEXT"},
196-
{Name: "published", Type: "bool", GoType: "bool", SQLType: "BOOLEAN"},
195+
{Name: "title", Type: "string", GoType: "string", SQLType: "TEXT", Metadata: parser.GetFieldMetadata("string")},
196+
{Name: "published", Type: "bool", GoType: "bool", SQLType: "BOOLEAN", Metadata: parser.GetFieldMetadata("bool")},
197197
}
198198

199199
if err := generator.GenerateResource(tmpDir, "testmodule", "Post", fields, "multi", "tailwind", "tailwind", "prev-next", 10, "modal", ""); err != nil {

internal/generator/templates/components/form.tmpl

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
[[- end]]
3535
</select>
3636
[[- else if eq .GoType "string"]]
37-
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="text" name="[[.Name]]" placeholder="Enter [[.Name]]" required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
37+
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="[[.HTMLInputType]]" name="[[.Name]]" placeholder="Enter [[.Name]]"[[if gt .HTMLMinLength 0]] minlength="[[.HTMLMinLength]]"[[end]][[if gt .HTMLMaxLength 0]] maxlength="[[.HTMLMaxLength]]"[[end]] required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
3838
[[- else if eq .GoType "int64"]]
3939
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="number" name="[[.Name]]" placeholder="Enter [[.Name]]" required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
4040
[[- else if eq .GoType "bool"]]
@@ -43,12 +43,21 @@
4343
[[.Name | title]]
4444
</label>
4545
[[- else if eq .GoType "float64"]]
46-
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="number" step="0.01" name="[[.Name]]" placeholder="Enter [[.Name]]" required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
46+
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="number"[[if .HTMLStep]] step="[[.HTMLStep]]"[[end]] name="[[.Name]]" placeholder="Enter [[.Name]]" required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
4747
[[- end]]
4848
{{if .lvt.HasError "[[.Name]]"}}
4949
<small style="color: #c00; font-size: 0.875rem;">{{.lvt.Error "[[.Name]]"}}</small>
5050
{{end}}
5151
</div>
52+
[[- if .IsPassword]]
53+
<div[[if ne (fieldClass $.CSSFramework) ""]] class="[[fieldClass $.CSSFramework]]"[[end]]>
54+
<label[[if ne (labelClass $.CSSFramework) ""]] class="[[labelClass $.CSSFramework]]"[[end]]>Confirm [[.Name | title]]</label>
55+
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="password" name="[[.Name]]_confirmation" placeholder="Confirm [[.Name]]" required[[if gt .HTMLMinLength 0]] minlength="[[.HTMLMinLength]]"[[end]] {{if .lvt.HasError "[[.Name]]_confirmation"}}aria-invalid="true"{{end}}>
56+
{{if .lvt.HasError "[[.Name]]_confirmation"}}
57+
<small style="color: #c00; font-size: 0.875rem;">{{.lvt.Error "[[.Name]]_confirmation"}}</small>
58+
{{end}}
59+
</div>
60+
[[- end]]
5261
[[- end]]
5362
<div[[if ne (fieldClass .CSSFramework) ""]] class="[[fieldClass .CSSFramework]]"[[end]]>
5463
<button[[if ne (buttonClass .CSSFramework "primary") ""]] class="[[buttonClass .CSSFramework "primary"]]"[[end]] style="margin-right: 8px; padding: 0.5rem 1rem; font-size: 1rem; min-width: 100px;" type="submit" lvt-disable-with="Adding...">Add [[.ResourceName]]</button>
@@ -84,7 +93,11 @@
8493
[[- end]]
8594
</select>
8695
[[- else if eq .GoType "string"]]
87-
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="text" name="[[.Name]]" placeholder="Enter [[.Name]]" value="{{.Editing[[$.ResourceName]].[[.Name | camelCase]]}}" required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
96+
[[- if .IsPassword]]
97+
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="password" name="[[.Name]]" placeholder="Enter new [[.Name]]"[[if gt .HTMLMinLength 0]] minlength="[[.HTMLMinLength]]"[[end]][[if gt .HTMLMaxLength 0]] maxlength="[[.HTMLMaxLength]]"[[end]] required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
98+
[[- else]]
99+
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="[[.HTMLInputType]]" name="[[.Name]]" placeholder="Enter [[.Name]]" value="{{.Editing[[$.ResourceName]].[[.Name | camelCase]]}}"[[if gt .HTMLMinLength 0]] minlength="[[.HTMLMinLength]]"[[end]][[if gt .HTMLMaxLength 0]] maxlength="[[.HTMLMaxLength]]"[[end]] required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
100+
[[- end]]
88101
[[- else if eq .GoType "int64"]]
89102
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="number" name="[[.Name]]" placeholder="Enter [[.Name]]" value="{{.Editing[[$.ResourceName]].[[.Name | camelCase]]}}" required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
90103
[[- else if eq .GoType "bool"]]
@@ -93,12 +106,21 @@
93106
[[.Name | title]]
94107
</label>
95108
[[- else if eq .GoType "float64"]]
96-
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="number" step="0.01" name="[[.Name]]" placeholder="Enter [[.Name]]" value="{{.Editing[[$.ResourceName]].[[.Name | camelCase]]}}" required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
109+
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="number"[[if .HTMLStep]] step="[[.HTMLStep]]"[[end]] name="[[.Name]]" placeholder="Enter [[.Name]]" value="{{.Editing[[$.ResourceName]].[[.Name | camelCase]]}}" required {{if .lvt.HasError "[[.Name]]"}}aria-invalid="true"{{end}}>
97110
[[- end]]
98111
{{if .lvt.HasError "[[.Name]]"}}
99112
<small style="color: #c00; font-size: 0.875rem;">{{.lvt.Error "[[.Name]]"}}</small>
100113
{{end}}
101114
</div>
115+
[[- if .IsPassword]]
116+
<div[[if ne (fieldClass $.CSSFramework) ""]] class="[[fieldClass $.CSSFramework]]"[[end]]>
117+
<label[[if ne (labelClass $.CSSFramework) ""]] class="[[labelClass $.CSSFramework]]"[[end]]>Confirm [[.Name | title]]</label>
118+
<input[[if ne (inputClass $.CSSFramework) ""]] class="[[inputClass $.CSSFramework]]"[[end]] type="password" name="[[.Name]]_confirmation" placeholder="Confirm [[.Name]]" required[[if gt .HTMLMinLength 0]] minlength="[[.HTMLMinLength]]"[[end]] {{if .lvt.HasError "[[.Name]]_confirmation"}}aria-invalid="true"{{end}}>
119+
{{if .lvt.HasError "[[.Name]]_confirmation"}}
120+
<small style="color: #c00; font-size: 0.875rem;">{{.lvt.Error "[[.Name]]_confirmation"}}</small>
121+
{{end}}
122+
</div>
123+
[[- end]]
102124
[[- end]]
103125
<div[[if ne (fieldClass .CSSFramework) ""]] class="[[fieldClass .CSSFramework]]"[[end]] style="display: flex; gap: 8px; margin-top: 1.5rem;">
104126
<button[[if ne (buttonClass .CSSFramework "primary") ""]] class="[[buttonClass .CSSFramework "primary"]]"[[end]] type="submit" lvt-disable-with="Updating...">Save</button>

internal/generator/templates/resource/handler.go.tmpl

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,31 +30,29 @@ type [[.ResourceName]]Item = models.[[.ResourceNameSingular]]
3030

3131
type AddInput struct {
3232
[[- range .Fields]]
33-
[[- if eq .GoType "bool"]]
34-
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]"`
35-
[[- else if .IsSelect]]
36-
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]" validate:"required"`
37-
[[- else if eq .GoType "string"]]
38-
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]" validate:"required,min=3"`
33+
[[- if .ValidateTag]]
34+
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]" validate:"[[.ValidateTag]]"`
3935
[[- else]]
40-
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]" validate:"required"`
36+
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]"`
4137
[[- end]]
4238
[[- end]]
39+
[[- range .Fields]][[- if .IsPassword]]
40+
[[.Name | camelCase]]Confirmation string `json:"[[.Name]]_confirmation" validate:"required,eqfield=[[.Name | camelCase]]"`
41+
[[- end]][[- end]]
4342
}
4443

4544
type UpdateInput struct {
4645
ID string `json:"id" validate:"required"`
4746
[[- range .Fields]]
48-
[[- if eq .GoType "bool"]]
49-
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]"`
50-
[[- else if .IsSelect]]
51-
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]" validate:"required"`
52-
[[- else if eq .GoType "string"]]
53-
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]" validate:"required,min=3"`
47+
[[- if .ValidateTag]]
48+
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]" validate:"[[.ValidateTag]]"`
5449
[[- else]]
55-
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]" validate:"required"`
50+
[[.Name | camelCase]] [[.GoType]] `json:"[[.Name]]"`
5651
[[- end]]
5752
[[- end]]
53+
[[- range .Fields]][[- if .IsPassword]]
54+
[[.Name | camelCase]]Confirmation string `json:"[[.Name]]_confirmation" validate:"required,eqfield=[[.Name | camelCase]]"`
55+
[[- end]][[- end]]
5856
}
5957

6058
type IDInput struct {

internal/generator/types.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func FieldDataFromFields(fields []parser.Field) []FieldData {
2424
IsTextarea: f.IsTextarea,
2525
IsSelect: f.IsSelect,
2626
SelectOptions: f.SelectOptions,
27+
FieldMetadata: f.Metadata,
2728
}
2829
}
2930
return fd
@@ -72,15 +73,16 @@ func (d ResourceData) NonReferenceFields() []FieldData {
7273
}
7374

7475
type FieldData struct {
75-
Name string
76-
GoType string
77-
SQLType string
78-
IsReference bool
79-
ReferencedTable string
80-
OnDelete string
81-
IsTextarea bool // true if field should render as textarea
82-
IsSelect bool // true if field should render as <select>
83-
SelectOptions []string // options for select fields
76+
Name string
77+
GoType string
78+
SQLType string
79+
IsReference bool
80+
ReferencedTable string
81+
OnDelete string
82+
IsTextarea bool // true if field should render as textarea
83+
IsSelect bool // true if field should render as <select>
84+
SelectOptions []string // options for select fields
85+
parser.FieldMetadata // validation + HTML rendering metadata (embedded)
8486
}
8587

8688
type AppData struct {

internal/generator/types_test.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package generator
22

3-
import "testing"
3+
import (
4+
"testing"
5+
6+
"github.com/livetemplate/lvt/internal/parser"
7+
)
48

59
func TestGetDisplayField(t *testing.T) {
610
tests := []struct {
@@ -74,3 +78,69 @@ func TestGetDisplayField(t *testing.T) {
7478
})
7579
}
7680
}
81+
82+
func TestFieldDataFromFieldsCopiesMetadata(t *testing.T) {
83+
fields := []parser.Field{
84+
{
85+
Name: "email",
86+
GoType: "string",
87+
Metadata: parser.FieldMetadata{
88+
ValidateTag: "required,email",
89+
HTMLInputType: "email",
90+
HTMLMinLength: 3,
91+
},
92+
},
93+
{
94+
Name: "secret",
95+
GoType: "string",
96+
Metadata: parser.FieldMetadata{
97+
ValidateTag: "required,min=8",
98+
HTMLInputType: "password",
99+
HTMLMinLength: 8,
100+
IsPassword: true,
101+
},
102+
},
103+
{
104+
Name: "price",
105+
GoType: "float64",
106+
Metadata: parser.FieldMetadata{
107+
ValidateTag: "required",
108+
HTMLInputType: "number",
109+
HTMLStep: "0.01",
110+
},
111+
},
112+
}
113+
114+
fd := FieldDataFromFields(fields)
115+
116+
if len(fd) != 3 {
117+
t.Fatalf("expected 3 FieldData, got %d", len(fd))
118+
}
119+
120+
// email
121+
if fd[0].ValidateTag != "required,email" {
122+
t.Errorf("email ValidateTag = %q, want %q", fd[0].ValidateTag, "required,email")
123+
}
124+
if fd[0].HTMLInputType != "email" {
125+
t.Errorf("email HTMLInputType = %q, want %q", fd[0].HTMLInputType, "email")
126+
}
127+
if fd[0].HTMLMinLength != 3 {
128+
t.Errorf("email HTMLMinLength = %d, want 3", fd[0].HTMLMinLength)
129+
}
130+
131+
// password
132+
if fd[1].ValidateTag != "required,min=8" {
133+
t.Errorf("password ValidateTag = %q, want %q", fd[1].ValidateTag, "required,min=8")
134+
}
135+
if !fd[1].IsPassword {
136+
t.Error("password IsPassword should be true")
137+
}
138+
if fd[1].HTMLMinLength != 8 {
139+
t.Errorf("password HTMLMinLength = %d, want 8", fd[1].HTMLMinLength)
140+
}
141+
142+
// float
143+
if fd[2].HTMLStep != "0.01" {
144+
t.Errorf("float HTMLStep = %q, want %q", fd[2].HTMLStep, "0.01")
145+
}
146+
}

0 commit comments

Comments
 (0)