Skip to content

Commit bd536bf

Browse files
adnaanclaude
andauthored
fix(generator): improve form element sync and simplify sort options (#41)
* fix(generator): improve form element sync and simplify sort options Two related fixes for better UX: 1. Fix morphdom form element sync issues: - Add data-expected-value attribute to select and input elements - Add syncFormValues script to sync values after morphdom updates - Add server-side reversion protection for sort (PrevSortBy, LastSortTime) - Prevents select dropdowns from reverting to previous values - Prevents search input from not clearing when X button is clicked 2. Simplify sort dropdown options: - Only show sort options for the first string field (primary display field) - Removes confusing Content (A-Z) / Content (Z-A) options when content isn't visible in the list view - Users now see: Newest First, Title (A-Z), Title (Z-A), Oldest First Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address Copilot review feedback - Fix range variable access: use $f.Name instead of .Name when iterating with `range $i, $f := .Fields` (the field variable is $f, not .) - Add data-expected-value attribute to sort select in component files for consistency with morphdom sync fix - Rename syncSelectValues to syncFormValues and add input element sync to match PR description Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: update golden file for template changes Update golden file to reflect: - Added data-expected-value attribute to sort select - Using $.SortBy for accessing parent scope in range Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add syncFormValues script to kit layout components The syncFormValues script was only in template.tmpl.tmpl but the actual generated output uses layout.tmpl from kit components. Added the script to all kit layout.tmpl files (multi, single, simple) to properly fix morphdom form element sync issues. Also updates livetemplate to v0.8.0. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 025cb88 commit bd536bf

19 files changed

Lines changed: 310 additions & 70 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ module github.com/livetemplate/lvt
22

33
go 1.25
44

5-
require github.com/livetemplate/livetemplate v0.7.12
5+
require github.com/livetemplate/livetemplate v0.8.0
66

77
require (
88
github.com/brianvoe/gofakeit/v7 v7.8.2

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
146146
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
147147
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
148148
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
149-
github.com/livetemplate/livetemplate v0.7.12 h1:2JTEBbaa4G0dl+v4BTg8mVU0FZYjgAQhx98q0u5qDlA=
150-
github.com/livetemplate/livetemplate v0.7.12/go.mod h1:mTI76skBGEx4jD9pO52L9xBY4/ZDW4muAKWwXnupvtc=
149+
github.com/livetemplate/livetemplate v0.8.0 h1:eWUhB6jwkTj4rfgRAZG+Eap0wGeaovmwXO3hGdQpf5M=
150+
github.com/livetemplate/livetemplate v0.8.0/go.mod h1:0jD5ccG/VQ/BmjbsZdOamAeFh+aO/f1yJeMQqhxPa68=
151151
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
152152
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
153153
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
@@ -307,3 +307,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
307307
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
308308
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
309309
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
310+
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
311+
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

internal/generator/templates/components/sort.tmpl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
[[- if ne (selectWrapperClass .CSSFramework) ""]]
1313
<div class="[[selectWrapperClass .CSSFramework]]">
1414
[[- end]]
15-
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort">
15+
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort" data-expected-value="{{.SortBy}}">
1616
<option value="" {{if eq .SortBy ""}}selected{{end}}>Newest First</option>
17-
[[- range .Fields]]
18-
[[- if eq .GoType "string"]]
19-
<option value="[[.Name]]_asc" {{if eq .SortBy "[[.Name]]_asc"}}selected{{end}}>[[.Name | title]] (A-Z)</option>
20-
<option value="[[.Name]]_desc" {{if eq .SortBy "[[.Name]]_desc"}}selected{{end}}>[[.Name | title]] (Z-A)</option>
17+
[[- range $i, $f := .Fields]]
18+
[[- if and (eq $i 0) (eq $f.GoType "string")]]
19+
<option value="[[$f.Name]]_asc" {{if eq $.SortBy "[[$f.Name]]_asc"}}selected{{end}}>[[$f.Name | title]] (A-Z)</option>
20+
<option value="[[$f.Name]]_desc" {{if eq $.SortBy "[[$f.Name]]_desc"}}selected{{end}}>[[$f.Name | title]] (Z-A)</option>
2121
[[- end]]
2222
[[- end]]
2323
<option value="oldest_first" {{if eq .SortBy "oldest_first"}}selected{{end}}>Oldest First</option>

internal/generator/templates/components/toolbar.tmpl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@
2020
[[- if ne (selectWrapperClass .CSSFramework) ""]]
2121
<div class="[[selectWrapperClass .CSSFramework]]">
2222
[[- end]]
23-
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort">
23+
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort" data-expected-value="{{.SortBy}}">
2424
<option value="" {{if eq .SortBy ""}}selected{{end}}>Newest First</option>
25-
[[- range .Fields]]
26-
[[- if eq .GoType "string"]]
27-
<option value="[[.Name]]_asc" {{if eq .SortBy "[[.Name]]_asc"}}selected{{end}}>[[.Name | title]] (A-Z)</option>
28-
<option value="[[.Name]]_desc" {{if eq .SortBy "[[.Name]]_desc"}}selected{{end}}>[[.Name | title]] (Z-A)</option>
25+
[[- range $i, $f := .Fields]]
26+
[[- if and (eq $i 0) (eq $f.GoType "string")]]
27+
<option value="[[$f.Name]]_asc" {{if eq $.SortBy "[[$f.Name]]_asc"}}selected{{end}}>[[$f.Name | title]] (A-Z)</option>
28+
<option value="[[$f.Name]]_desc" {{if eq $.SortBy "[[$f.Name]]_desc"}}selected{{end}}>[[$f.Name | title]] (Z-A)</option>
2929
[[- end]]
3030
[[- end]]
3131
<option value="oldest_first" {{if eq .SortBy "oldest_first"}}selected{{end}}>Oldest First</option>

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ type [[.ResourceName]]State struct {
8585
HasMore bool `json:"has_more"` // Whether more items available
8686
IsLoading bool `json:"is_loading"` // Loading indicator
8787
CSSFramework string `json:"-"` // CSS framework for templates
88+
// Sort reversion protection: morphdom can trigger spurious change events
89+
PrevSortBy string `json:"prev_sort_by" lvt:"transient"` // Previous sort value before last change
90+
LastSortTime int64 `json:"last_sort_time" lvt:"transient"` // Unix nano of last sort action
8891
}
8992

9093
// Add handles the "add" action to create a new resource
@@ -295,12 +298,27 @@ func (c *[[.ResourceName]]Controller) Sort(state [[.ResourceName]]State, ctx *li
295298
if err := ctx.BindAndValidate(&input, validate); err != nil {
296299
return state, err
297300
}
298-
state.SortBy = input.SortBy
299-
// Reset infinite scroll when sorting
300-
if state.PaginationMode == "infinite" || state.PaginationMode == "load-more" {
301-
state.LoadedCount = state.PageSize
301+
302+
now := time.Now().UnixNano()
303+
304+
// Detect and ignore spurious morphdom-triggered reversions:
305+
// If we receive a value that equals the previous value, and it's within 500ms of the last change,
306+
// this is likely a spurious event from morphdom updating the select element
307+
if state.LastSortTime > 0 {
308+
elapsed := now - state.LastSortTime
309+
elapsedMs := elapsed / 1_000_000
310+
if input.SortBy == state.PrevSortBy && elapsedMs < 500 {
311+
return state, nil
312+
}
302313
}
303314

315+
// Track previous value and update
316+
state.PrevSortBy = state.SortBy
317+
state.SortBy = input.SortBy
318+
state.LastSortTime = now
319+
// Note: Don't reset LoadedCount when sorting - keep all loaded items visible
320+
// Just re-sort the existing items for better UX
321+
304322
state, err := c.load[[.ResourceName]]s(state, dbCtx)
305323
if err != nil {
306324
return state, err

internal/generator/templates/resource/template.tmpl.tmpl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@
3333
[[- if ne (selectWrapperClass .CSSFramework) ""]]
3434
<div class="[[selectWrapperClass .CSSFramework]]">
3535
[[- end]]
36-
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort">
36+
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort" data-expected-value="{{.SortBy}}">
3737
<option value="" {{if eq .SortBy ""}}selected{{end}}>Newest First</option>
38-
[[- range .Fields]]
39-
[[- if eq .GoType "string"]]
40-
<option value="[[.Name]]_asc" {{if eq .SortBy "[[.Name]]_asc"}}selected{{end}}>[[.Name | title]] (A-Z)</option>
41-
<option value="[[.Name]]_desc" {{if eq .SortBy "[[.Name]]_desc"}}selected{{end}}>[[.Name | title]] (Z-A)</option>
38+
[[- range $i, $f := .Fields]]
39+
[[- if and (eq $i 0) (eq $f.GoType "string")]]
40+
<option value="[[$f.Name]]_asc" {{if eq $.SortBy "[[$f.Name]]_asc"}}selected{{end}}>[[$f.Name | title]] (A-Z)</option>
41+
<option value="[[$f.Name]]_desc" {{if eq $.SortBy "[[$f.Name]]_desc"}}selected{{end}}>[[$f.Name | title]] (Z-A)</option>
4242
[[- end]]
4343
[[- end]]
4444
<option value="oldest_first" {{if eq .SortBy "oldest_first"}}selected{{end}}>Oldest First</option>

internal/kits/system/multi/components/layout.tmpl

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,34 @@
2828
{{else}}
2929
<script src="https://unpkg.com/@livetemplate/client@latest/dist/livetemplate-client.browser.js"></script>
3030
{{end}}
31+
32+
<!-- Fix for morphdom not properly syncing form element values -->
33+
<script>
34+
(function() {
35+
function syncFormValues() {
36+
// Sync select elements
37+
document.querySelectorAll('select[data-expected-value]').forEach(function(select) {
38+
var expected = select.getAttribute('data-expected-value');
39+
if (select.value !== expected) {
40+
select.value = expected;
41+
}
42+
});
43+
// Sync input elements
44+
document.querySelectorAll('input[data-expected-value]').forEach(function(input) {
45+
var expected = input.getAttribute('data-expected-value');
46+
if (input.value !== expected) {
47+
input.value = expected;
48+
}
49+
});
50+
}
51+
syncFormValues();
52+
var observer = new MutationObserver(function(mutations) {
53+
syncFormValues();
54+
});
55+
observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['data-expected-value'] });
56+
})();
57+
</script>
58+
3159
{{template "pageRouting" .}}
3260
{{end}}
3361
</body>

internal/kits/system/multi/components/sort.tmpl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
[[- if ne (selectWrapperClass .CSSFramework) ""]]
1313
<div class="[[selectWrapperClass .CSSFramework]]">
1414
[[- end]]
15-
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort">
15+
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort" data-expected-value="{{.SortBy}}">
1616
<option value="" {{if eq .SortBy ""}}selected{{end}}>Newest First</option>
17-
[[- range .Fields]]
18-
[[- if eq .GoType "string"]]
19-
<option value="[[.Name]]_asc" {{if eq .SortBy "[[.Name]]_asc"}}selected{{end}}>[[.Name | title]] (A-Z)</option>
20-
<option value="[[.Name]]_desc" {{if eq .SortBy "[[.Name]]_desc"}}selected{{end}}>[[.Name | title]] (Z-A)</option>
17+
[[- range $i, $f := .Fields]]
18+
[[- if and (eq $i 0) (eq $f.GoType "string")]]
19+
<option value="[[$f.Name]]_asc" {{if eq $.SortBy "[[$f.Name]]_asc"}}selected{{end}}>[[$f.Name | title]] (A-Z)</option>
20+
<option value="[[$f.Name]]_desc" {{if eq $.SortBy "[[$f.Name]]_desc"}}selected{{end}}>[[$f.Name | title]] (Z-A)</option>
2121
[[- end]]
2222
[[- end]]
2323
<option value="oldest_first" {{if eq .SortBy "oldest_first"}}selected{{end}}>Oldest First</option>

internal/kits/system/multi/components/toolbar.tmpl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@
2020
[[- if ne (selectWrapperClass .CSSFramework) ""]]
2121
<div class="[[selectWrapperClass .CSSFramework]]">
2222
[[- end]]
23-
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort">
23+
<select[[if ne (selectClass .CSSFramework) ""]] class="[[selectClass .CSSFramework]]"[[end]] name="sort_by" lvt-change="sort" data-expected-value="{{.SortBy}}">
2424
<option value="" {{if eq .SortBy ""}}selected{{end}}>Newest First</option>
25-
[[- range .Fields]]
26-
[[- if eq .GoType "string"]]
27-
<option value="[[.Name]]_asc" {{if eq .SortBy "[[.Name]]_asc"}}selected{{end}}>[[.Name | title]] (A-Z)</option>
28-
<option value="[[.Name]]_desc" {{if eq .SortBy "[[.Name]]_desc"}}selected{{end}}>[[.Name | title]] (Z-A)</option>
25+
[[- range $i, $f := .Fields]]
26+
[[- if and (eq $i 0) (eq $f.GoType "string")]]
27+
<option value="[[$f.Name]]_asc" {{if eq $.SortBy "[[$f.Name]]_asc"}}selected{{end}}>[[$f.Name | title]] (A-Z)</option>
28+
<option value="[[$f.Name]]_desc" {{if eq $.SortBy "[[$f.Name]]_desc"}}selected{{end}}>[[$f.Name | title]] (Z-A)</option>
2929
[[- end]]
3030
[[- end]]
3131
<option value="oldest_first" {{if eq .SortBy "oldest_first"}}selected{{end}}>Oldest First</option>

internal/kits/system/multi/templates/resource/handler.go.tmpl

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ type [[.ResourceName]]State struct {
8585
HasMore bool `json:"has_more"` // Whether more items available
8686
IsLoading bool `json:"is_loading"` // Loading indicator
8787
CSSFramework string `json:"-"` // CSS framework for templates
88+
// Sort reversion protection: morphdom can trigger spurious change events
89+
PrevSortBy string `json:"prev_sort_by" lvt:"transient"` // Previous sort value before last change
90+
LastSortTime int64 `json:"last_sort_time" lvt:"transient"` // Unix nano of last sort action
8891
}
8992

9093
// Add handles the "add" action to create a new resource
@@ -287,12 +290,27 @@ func (c *[[.ResourceName]]Controller) Sort(state [[.ResourceName]]State, ctx *li
287290
if err := ctx.BindAndValidate(&input, validate); err != nil {
288291
return state, err
289292
}
290-
state.SortBy = input.SortBy
291-
// Reset infinite scroll when sorting
292-
if state.PaginationMode == "infinite" || state.PaginationMode == "load-more" {
293-
state.LoadedCount = state.PageSize
293+
294+
now := time.Now().UnixNano()
295+
296+
// Detect and ignore spurious morphdom-triggered reversions:
297+
// If we receive a value that equals the previous value, and it's within 500ms of the last change,
298+
// this is likely a spurious event from morphdom updating the select element
299+
if state.LastSortTime > 0 {
300+
elapsed := now - state.LastSortTime
301+
elapsedMs := elapsed / 1_000_000
302+
if input.SortBy == state.PrevSortBy && elapsedMs < 500 {
303+
return state, nil
304+
}
294305
}
295306

307+
// Track previous value and update
308+
state.PrevSortBy = state.SortBy
309+
state.SortBy = input.SortBy
310+
state.LastSortTime = now
311+
// Note: Don't reset LoadedCount when sorting - keep all loaded items visible
312+
// Just re-sort the existing items for better UX
313+
296314
state, err := c.load[[.ResourceName]]s(state, dbCtx)
297315
if err != nil {
298316
return state, err

0 commit comments

Comments
 (0)