fix(master): keep the scroll position when toggling auto mode on the overview

The mode switch was a plain form POST answered with 303 to /, so the browser
navigated to a fresh page at the top. The form now also posts via htmx and
the handler returns the services fragment for htmx requests, so the list is
swapped in place; search and machine filter stay applied. A refused auto
answers 409 with HX-Redirect to the service page, since htmx does not swap
4xx responses. Without JS the form still posts and redirects as before.

#services gets overflow-anchor:none. htmx inserts the new list before
removing the old one, and Chrome anchored scrolling on the focused switch,
so on narrow screens the page jumped to the bottom after a click, and after
any SSE refresh while a switch had focus.

Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
This commit is contained in:
2026-10-13 12:59:10 +02:00
parent f6446be711
commit 96caf4a8a7
4 changed files with 59 additions and 1 deletions
+41
View File
@@ -707,6 +707,47 @@ func TestSetMode_ReturnToOverview(t *testing.T) {
assert.Equal(t, "auto", store.modeOf(1))
}
func postModeHTMX(t *testing.T, srv *httptest.Server, id string, form url.Values) (*http.Response, string) {
t.Helper()
form.Set("csrf_token", "csrf")
req, err := http.NewRequest(http.MethodPost, srv.URL+"/services/"+id+"/mode", strings.NewReader(form.Encode()))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
resp, err := noRedirectClient().Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp, string(body)
}
func TestSetMode_HTMXFromOverviewReturnsServicesFragment(t *testing.T) {
srv, store, _, broker, _ := newTestEnv(t)
sub, cancel := broker.Subscribe()
defer cancel()
resp, body := postModeHTMX(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}})
assert.Equal(t, http.StatusOK, resp.StatusCode, "no redirect, so the page keeps its scroll position")
assert.Empty(t, resp.Header.Get("Location"))
assert.NotContains(t, body, "<html")
assert.Contains(t, body, `id="machines" hx-swap-oob`)
assert.Contains(t, body, `action="/services/1/mode"`)
assert.Equal(t, "auto", store.modeOf(1))
assert.Equal(t, events.Services, <-sub)
}
func TestSetMode_HTMXRefusedRedirectsToService(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
resp, _ := postModeHTMX(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}})
assert.Equal(t, http.StatusConflict, resp.StatusCode)
assert.Equal(t, "/services/1", resp.Header.Get("HX-Redirect"), "htmx does not swap a 409, so send the user to the page that shows why")
assert.Equal(t, "manual", store.modeOf(1))
}
func TestSetMode_AutoRefusedShows409(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}