Ревью: oob-обновление панели действий при свопе источника
Кнопка «Применить» (завязана на HasLinks) лежит вне #source-block и не обновлялась при htmx-свопе выбора источника — в краевом случае (пустой предпросмотр из-за коллизии) рассинхронивалась с превью до полной перезагрузки. Выделил панель в партиал review_action_bar с id=action-bar; reviewBlockAction теперь рендерит review_source_swap — свежий #source-block плюс oob-копию панели (hx-swap-oob), так кнопка синхронно отражает HasLinks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
@@ -272,6 +274,70 @@ func TestActionErrorDownloadSurface(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// reviewDataWithSource собирает ReviewData ревью с одним нейронка-источником;
|
||||
// withLinks управляет наличием превью раскладки (HasLinks).
|
||||
func reviewDataWithSource(withLinks bool) *worker.ReviewData {
|
||||
dl := dlState(store.StateReview)
|
||||
plan := recognize.Plan{Type: "movie", Title: "Fargo", Year: 1996,
|
||||
Files: []recognize.PlanFile{{Src: "fargo.mkv"}}}
|
||||
rd := &worker.ReviewData{
|
||||
Download: dl,
|
||||
Recognition: &store.Recognition{},
|
||||
Plan: plan,
|
||||
Sources: []worker.SourceOption{
|
||||
{Kind: worker.SourceNeural, Provider: "none", Title: "Fargo", Year: 1996, Active: true},
|
||||
},
|
||||
}
|
||||
if withLinks {
|
||||
rd.Preview = []layout.Link{{Src: "fargo.mkv", Dst: "/movies/Fargo (1996)/fargo.mkv"}}
|
||||
}
|
||||
return rd
|
||||
}
|
||||
|
||||
// TestSourceSwapUpdatesActionBarOOB: своп выбора источника отдаёт свежий
|
||||
// #source-block и oob-обновление панели действий (#action-bar с hx-swap-oob),
|
||||
// чтобы кнопка «Применить» синхронно отражала актуальный HasLinks.
|
||||
func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
|
||||
t.Run("с превью → есть кнопка Применить", func(t *testing.T) {
|
||||
rd := reviewDataWithSource(true)
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
|
||||
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="source-block"`) {
|
||||
t.Errorf("ответ без свежего #source-block: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
|
||||
t.Errorf("ответ без oob-панели действий: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "/apply") {
|
||||
t.Errorf("HasLinks=true, но в панели нет кнопки «Применить»: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("без превью → кнопки Применить нет", func(t *testing.T) {
|
||||
rd := reviewDataWithSource(false)
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
|
||||
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
|
||||
t.Errorf("ответ без oob-панели действий: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "/apply") {
|
||||
t.Errorf("HasLinks=false, но в панели осталась кнопка «Применить»: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRetryListShowsProgress: retry из списка → карточка downloading с
|
||||
// прогресс-поллером.
|
||||
func TestRetryListShowsProgress(t *testing.T) {
|
||||
|
||||
@@ -59,6 +59,7 @@ type reviewView struct {
|
||||
NoBase bool // выбрано «без базы»
|
||||
Sources []sourceView // единый список источников совпадения
|
||||
BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=)
|
||||
ActionBarOOB bool // рендерить панель действий как oob-фрагмент (своп источника)
|
||||
}
|
||||
|
||||
// sourceView — строка единого списка источников на экране ревью: нейронка или
|
||||
@@ -455,9 +456,10 @@ func isHTMX(r *http.Request) bool {
|
||||
|
||||
// reviewBlockAction — помощник для действий выбора источника: выполнить
|
||||
// операцию и вернуть свежий блок источника. На htmx-запрос перечитывает
|
||||
// состояние и рендерит партиал `review_source_block` (ошибку кладёт в
|
||||
// BlockError, активный источник не меняется); без htmx деградирует до
|
||||
// PRG-редиректа, как reviewAction.
|
||||
// состояние и рендерит `review_source_swap` — партиал `review_source_block`
|
||||
// плюс oob-обновление панели действий `review_action_bar` (кнопка «Применить»
|
||||
// зависит от HasLinks, а лежит вне блока), ошибку кладёт в BlockError, активный
|
||||
// источник не меняется; без htmx деградирует до PRG-редиректа, как reviewAction.
|
||||
func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
@@ -487,7 +489,11 @@ func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn fu
|
||||
if actionErr != nil {
|
||||
view.BlockError = userErr(r, actionErr, id)
|
||||
}
|
||||
s.render(w, "review_source_block", view)
|
||||
// Панель действий («Применить» завязана на HasLinks) лежит вне #source-block,
|
||||
// поэтому обновляем её тем же ответом через hx-swap-oob — иначе кнопка
|
||||
// рассинхронилась бы с превью до полной перезагрузки.
|
||||
view.ActionBarOOB = true
|
||||
s.render(w, "review_source_swap", view)
|
||||
}
|
||||
|
||||
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
|
||||
|
||||
@@ -61,12 +61,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Панель действий (выходы из ревью — обычные формы, полная навигация) -->
|
||||
<div class="action-bar">
|
||||
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
|
||||
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
|
||||
</div>
|
||||
{{template "review_action_bar" .}}
|
||||
|
||||
</main>
|
||||
{{end}}
|
||||
|
||||
{{/* Панель действий ревью. Живёт вне #source-block, но зависит от HasLinks
|
||||
(есть ли превью раскладки), поэтому при свопе источника обновляется oob
|
||||
из того же ответа (ActionBarOOB → hx-swap-oob). Выходы из ревью — обычные
|
||||
POST-формы без hx-* (полная навигация, см. web-ui.md). */}}
|
||||
{{define "review_action_bar"}}
|
||||
<div class="action-bar" id="action-bar"{{if .ActionBarOOB}} hx-swap-oob="true"{{end}}>
|
||||
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
|
||||
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -72,3 +72,9 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Ответ на своп выбора источника: свежий #source-block + oob-обновление
|
||||
панели действий (#action-bar), чтобы кнопка «Применить» синхронно отражала
|
||||
актуальный HasLinks. Рендерится с ActionBarOOB=true (reviewBlockAction). */}}
|
||||
{{define "review_source_swap"}}{{template "review_source_block" .}}
|
||||
{{template "review_action_bar" .}}{{end}}
|
||||
|
||||
Reference in New Issue
Block a user