Веб-UI: htmx-своп действий на месте вместо PRG-редиректа
Мутирующие действия больше не уводят со страницы: undo/relink/retry/cancel
в списке свопят карточку (#card-{id}), на /download/{id} — #download-main;
петля ревью (refine/rerecognize) свопит #review-main и допалливает
recognizing до готового плана (fragment /fragments/downloads/{id}/review,
every 2s). Выходы ревью (apply/defer/cancel) остаются навигацией. Без htmx —
прежний PRG-редирект (деградация). Ошибка действия на htmx-пути — HTTP 200
с сообщением в фрагменте (ActionError), иначе htmx не свопит DOM.
Разметка вынесена в партиалы card/download_main/review_main (корень = элемент
с целевым id), различение поверхности — скрытым полем surface=list|download.
Извлечены buildCardView/buildDownloadView. handleSetProvider переведён на
reviewBlockAction (консистентность source-действий).
Реализация change htmx-action-swap (OpenSpec).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// stubCommander — Commander-заглушка с инъекцией ошибок cancel/retry.
|
||||
type stubCommander struct{ cancelErr, retryErr error }
|
||||
|
||||
func (s stubCommander) Cancel(context.Context, string) error { return s.cancelErr }
|
||||
func (s stubCommander) Retry(context.Context, string) error { return s.retryErr }
|
||||
|
||||
// actionReviewer — Reviewer-заглушка с инъекцией ошибок петлевых/своп-действий
|
||||
// и захватом подсказки refine.
|
||||
type actionReviewer struct {
|
||||
stubReviewer
|
||||
undoErr error
|
||||
relinkErr error
|
||||
rerecognizeErr error
|
||||
refineErr error
|
||||
gotHint *string // если не nil — сюда пишется hint из Refine
|
||||
}
|
||||
|
||||
func (a actionReviewer) Undo(context.Context, string) error { return a.undoErr }
|
||||
func (a actionReviewer) Relink(context.Context, string) error { return a.relinkErr }
|
||||
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
|
||||
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
|
||||
if a.gotHint != nil {
|
||||
*a.gotHint = hint
|
||||
}
|
||||
return a.refineErr
|
||||
}
|
||||
|
||||
func testRouterAction(t *testing.T, r stubReader, rv Reviewer, cmd Commander, lv stubLive) http.Handler {
|
||||
t.Helper()
|
||||
h, err := NewRouter(Deps{
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Reader: r,
|
||||
Reviewer: rv,
|
||||
Commander: cmd,
|
||||
Live: lv,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// post отправляет POST-форму; htmx=true добавляет заголовок HX-Request.
|
||||
func post(t *testing.T, h http.Handler, path string, form url.Values, htmx bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if htmx {
|
||||
req.Header.Set("HX-Request", "true")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func dlState(s store.State) store.Download {
|
||||
return store.Download{
|
||||
ID: testULID,
|
||||
SourceRef: "Fargo.S02",
|
||||
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ihswap", Kind: store.HashV1}},
|
||||
State: s,
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoListHTMXSwapsCard: откат из списка (surface=list) при htmx → 200 и
|
||||
// фрагмент карточки с новым состоянием, не редирект.
|
||||
func TestUndoListHTMXSwapsCard(t *testing.T) {
|
||||
dl := dlState(store.StateReverted) // GetDownload после действия отдаёт новое состояние
|
||||
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"list"}}, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("undo (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="card-`+testULID+`"`) {
|
||||
t.Errorf("ответ не содержит фрагмент карточки: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "st-reverted") {
|
||||
t.Errorf("карточка без нового состояния (st-reverted): %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoNoHTMXRedirects: без htmx откат деградирует до PRG-редиректа на список.
|
||||
func TestUndoNoHTMXRedirects(t *testing.T) {
|
||||
dl := dlState(store.StateReverted)
|
||||
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", nil, false)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("undo (no htmx) = %d, want 303", rr.Code)
|
||||
}
|
||||
if loc := rr.Header().Get("Location"); loc != "/" {
|
||||
t.Errorf("redirect на %q, want /", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadSurfaceHTMXSwapsMain: действие со страницы загрузки (surface=download)
|
||||
// при htmx → 200 и фрагмент download_main.
|
||||
func TestDownloadSurfaceHTMXSwapsMain(t *testing.T) {
|
||||
dl := dlState(store.StateReverted)
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
|
||||
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("undo download (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `id="download-main"`) {
|
||||
t.Errorf("ответ не содержит фрагмент download_main: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerecognizeHTMXSwapsReviewMain: перераспознавание при htmx → 200 и тело
|
||||
// ревью review_main в состоянии recognizing с поллером.
|
||||
func TestRerecognizeHTMXSwapsReviewMain(t *testing.T) {
|
||||
dl := dlState(store.StateRecognizing)
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
|
||||
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/rerecognize", nil, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("rerecognize (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="review-main"`) {
|
||||
t.Errorf("ответ не содержит фрагмент review_main: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `hx-trigger="every 2s"`) {
|
||||
t.Errorf("recognizing без поллера (hx-trigger every 2s): %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerecognizeNoHTMXRedirects: без htmx перераспознавание → редирект на ревью.
|
||||
func TestRerecognizeNoHTMXRedirects(t *testing.T) {
|
||||
dl := dlState(store.StateRecognizing)
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
|
||||
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/rerecognize", nil, false)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("rerecognize (no htmx) = %d, want 303", rr.Code)
|
||||
}
|
||||
if loc := rr.Header().Get("Location"); loc != "/review/"+testULID {
|
||||
t.Errorf("redirect на %q, want /review/{id}", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragReviewPollerStops: фрагмент тела ревью несёт поллер в recognizing и не
|
||||
// несёт его в review (опрос сам прекращается).
|
||||
func TestFragReviewPollerStops(t *testing.T) {
|
||||
recDL := dlState(store.StateRecognizing)
|
||||
hRec := testRouterAction(t, stubReader{one: &recDL},
|
||||
actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: recDL}}}, stubCommander{}, stubLive{})
|
||||
if rr := get(t, hRec, "/fragments/downloads/"+testULID+"/review"); !strings.Contains(rr.Body.String(), `hx-trigger="every 2s"`) {
|
||||
t.Errorf("recognizing: фрагмент без поллера")
|
||||
}
|
||||
|
||||
revDL := dlState(store.StateReview)
|
||||
hRev := testRouterAction(t, stubReader{one: &revDL},
|
||||
actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: revDL}}}, stubCommander{}, stubLive{})
|
||||
if rr := get(t, hRev, "/fragments/downloads/"+testULID+"/review"); strings.Contains(rr.Body.String(), `hx-trigger="every 2s"`) {
|
||||
t.Errorf("review: поллер не прекратился")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionErrorHTMX200: ошибка действия на htmx-пути → 200 с сообщением в
|
||||
// фрагменте (иначе htmx не свопит DOM).
|
||||
func TestActionErrorHTMX200(t *testing.T) {
|
||||
dl := dlState(store.StateReview)
|
||||
cmd := stubCommander{cancelErr: worker.ErrConflict}
|
||||
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, cmd, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", url.Values{"surface": {"list"}}, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("cancel с ошибкой (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="card-`+testULID+`"`) {
|
||||
t.Errorf("ответ не содержит фрагмент карточки")
|
||||
}
|
||||
if !strings.Contains(body, "действие недоступно") {
|
||||
t.Errorf("нет сообщения об ошибке в фрагменте: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefineHTMXSwapsReviewMain: уточнить (refine) при htmx → 200 + review_main,
|
||||
// подсказка из формы доходит до доменного вызова.
|
||||
func TestRefineHTMXSwapsReviewMain(t *testing.T) {
|
||||
dl := dlState(store.StateRecognizing)
|
||||
var hint string
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}, gotHint: &hint}
|
||||
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/refine", url.Values{"hint": {"это Fargo 2014"}}, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("refine (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `id="review-main"`) {
|
||||
t.Errorf("refine не отдал review_main")
|
||||
}
|
||||
if hint != "это Fargo 2014" {
|
||||
t.Errorf("hint = %q, want «это Fargo 2014» (форма не разобрана)", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadSurfaceNoHTMXRedirects: действие со страницы загрузки без htmx →
|
||||
// 303 (деградация на редирект, как и для списка).
|
||||
func TestDownloadSurfaceNoHTMXRedirects(t *testing.T) {
|
||||
dl := dlState(store.StateReverted)
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
|
||||
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, false)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("undo download (no htmx) = %d, want 303", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewCancelNoHTMXNavigates: отклонение из ревью (форма без hx-*/surface) —
|
||||
// навигация на список (выход из ревью), а не своп.
|
||||
func TestReviewCancelNoHTMXNavigates(t *testing.T) {
|
||||
dl := dlState(store.StateReview)
|
||||
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", nil, false)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("cancel из ревью (no htmx) = %d, want 303", rr.Code)
|
||||
}
|
||||
if loc := rr.Header().Get("Location"); loc != "/" {
|
||||
t.Errorf("redirect на %q, want / (выход из ревью)", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionErrorDownloadSurface: ошибка действия на странице загрузки (htmx) →
|
||||
// 200 + фрагмент download_main с сообщением.
|
||||
func TestActionErrorDownloadSurface(t *testing.T) {
|
||||
dl := dlState(store.StateDone)
|
||||
rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}, undoErr: worker.ErrConflict}
|
||||
h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("undo download с ошибкой (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="download-main"`) {
|
||||
t.Errorf("ответ не содержит download_main")
|
||||
}
|
||||
if !strings.Contains(body, "действие недоступно") {
|
||||
t.Errorf("нет сообщения об ошибке в download_main: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryListShowsProgress: retry из списка → карточка downloading с
|
||||
// прогресс-поллером.
|
||||
func TestRetryListShowsProgress(t *testing.T) {
|
||||
dl := dlState(store.StateDownloading)
|
||||
lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
|
||||
h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, lv)
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/retry", url.Values{"surface": {"list"}}, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("retry (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "/fragments/downloads/"+testULID+"/progress") {
|
||||
t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -13,19 +13,20 @@ import (
|
||||
// --- Страница просмотра одной загрузки ---
|
||||
|
||||
type downloadDetailView struct {
|
||||
ID string
|
||||
Title string
|
||||
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
||||
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
|
||||
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||
Context string
|
||||
State string
|
||||
Error string
|
||||
Note string
|
||||
Added string // дата добавления (source_added_at → created_at), как в списке
|
||||
AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
ID string
|
||||
Title string
|
||||
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
||||
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
|
||||
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||
Context string
|
||||
State string
|
||||
Error string
|
||||
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
|
||||
Note string
|
||||
Added string // дата добавления (source_added_at → created_at), как в списке
|
||||
AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
|
||||
// Распознавание (если есть план).
|
||||
HasPlan bool
|
||||
@@ -82,7 +83,13 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "download.html", s.buildDownloadView(id, rd))
|
||||
}
|
||||
|
||||
// buildDownloadView собирает представление страницы загрузки из доменных данных
|
||||
// и живого снимка. Общий для полной страницы (handleDownload) и htmx-свопа
|
||||
// главной области после действия (renderDownloadFragment).
|
||||
func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDetailView {
|
||||
d := rd.Download
|
||||
view := downloadDetailView{
|
||||
ID: id,
|
||||
@@ -141,5 +148,5 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
l, ok := s.liveFor(d)
|
||||
view.Seeding = buildSeeding(id, l, ok)
|
||||
|
||||
s.render(w, "download.html", view)
|
||||
return view
|
||||
}
|
||||
|
||||
+82
-20
@@ -113,6 +113,8 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
|
||||
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
|
||||
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
|
||||
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
|
||||
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
|
||||
r.Post("/ui/downloads", s.handleUIAdd)
|
||||
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
||||
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
||||
@@ -199,6 +201,7 @@ type downloadView struct {
|
||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||
Retriable bool // failed/stuck — можно повторить попытку
|
||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||
ActionError string // ошибка действия на htmx-пути (своп карточки), не error_msg
|
||||
|
||||
// Обзор жизненного цикла (мета-строка карточки).
|
||||
Added string // абсолютная дата добавления (TZ сервера), «2006-01-02»
|
||||
@@ -290,22 +293,31 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
now := time.Now()
|
||||
for _, d := range downloads {
|
||||
v := s.toView(d, now)
|
||||
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
|
||||
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
|
||||
l, ok := s.liveFor(d)
|
||||
v.Ratio = ratioText(l, ok)
|
||||
v.Size = sizeText(l, ok, layoutSizes[d.ID])
|
||||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
|
||||
if v.IsDownloading {
|
||||
v.Progress = buildProgress(d.ID, true, l, ok)
|
||||
}
|
||||
view.Downloads = append(view.Downloads, v)
|
||||
view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
|
||||
}
|
||||
s.render(w, "index.html", view)
|
||||
}
|
||||
|
||||
// buildCardView собирает представление карточки списка из доменных данных и
|
||||
// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки
|
||||
// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку
|
||||
// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести
|
||||
// прогресс-поллер — поэтому Progress заполняется здесь.
|
||||
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
|
||||
v := s.toView(d, now)
|
||||
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
|
||||
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
|
||||
l, ok := s.liveFor(d)
|
||||
v.Ratio = ratioText(l, ok)
|
||||
v.Size = sizeText(l, ok, layoutSize)
|
||||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
|
||||
if v.IsDownloading {
|
||||
v.Progress = buildProgress(d.ID, true, l, ok)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
|
||||
func parseGroup(s string) store.StateGroup {
|
||||
switch store.StateGroup(s) {
|
||||
@@ -400,11 +412,7 @@ func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Commander.Cancel(r.Context(), id); err != nil {
|
||||
redirectErr(w, r, userErr(r, err, id))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
s.surfaceAction(w, r, id, s.deps.Commander.Cancel(r.Context(), id))
|
||||
}
|
||||
|
||||
func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -413,11 +421,65 @@ func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Commander.Retry(r.Context(), id); err != nil {
|
||||
redirectErr(w, r, userErr(r, err, id))
|
||||
s.surfaceAction(w, r, id, s.deps.Commander.Retry(r.Context(), id))
|
||||
}
|
||||
|
||||
// surfaceAction завершает мутирующее действие, доступное и в списке, и на
|
||||
// странице загрузки (undo/relink/retry/cancel). На htmx свопит фрагмент той
|
||||
// поверхности, откуда пришло действие (скрытое поле surface=list|download), на
|
||||
// ошибке — тот же фрагмент с сообщением и HTTP 200 (иначе htmx не подменит DOM).
|
||||
// Без htmx — прежний PRG-редирект на список (форма выхода из ревью тоже сюда:
|
||||
// нет htmx → навигация). actionErr — результат доменного вызова.
|
||||
func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
|
||||
if !isHTMX(r) {
|
||||
if actionErr != nil {
|
||||
redirectErr(w, r, userErr(r, actionErr, id))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
if r.PostFormValue("surface") == "download" {
|
||||
s.renderDownloadFragment(w, r, id, actionErr)
|
||||
return
|
||||
}
|
||||
s.renderCardFragment(w, r, id, actionErr)
|
||||
}
|
||||
|
||||
// renderCardFragment перечитывает загрузку и рендерит партиал карточки списка
|
||||
// (htmx-своп). На ошибке действия кладёт сообщение в ActionError и всё равно
|
||||
// отвечает 200 — htmx не свопит DOM на 4xx/5xx.
|
||||
func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
return
|
||||
}
|
||||
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("layout sizes", "download_id", id, "error", err)
|
||||
sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает
|
||||
}
|
||||
v := s.buildCardView(*d, time.Now(), sizes[id])
|
||||
if actionErr != nil {
|
||||
v.ActionError = userErr(r, actionErr, id)
|
||||
}
|
||||
s.render(w, "card", v)
|
||||
}
|
||||
|
||||
// renderDownloadFragment перечитывает загрузку и рендерит главную область
|
||||
// страницы загрузки (htmx-своп). Ошибка — в ActionError, ответ 200.
|
||||
func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
return
|
||||
}
|
||||
v := s.buildDownloadView(id, rd)
|
||||
if actionErr != nil {
|
||||
v.ActionError = userErr(r, actionErr, id)
|
||||
}
|
||||
s.render(w, "download_main", v)
|
||||
}
|
||||
|
||||
// --- REST API ---
|
||||
|
||||
+50
-18
@@ -201,7 +201,9 @@ func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
// Смена provider/id — операция над выбранным источником, как candidate/nobase/
|
||||
// source: свопит блок источника (#source-block), а не всё тело ревью.
|
||||
s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
||||
})
|
||||
@@ -327,11 +329,7 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
|
||||
redirectErr(w, r, userErr(r, err, id))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
s.surfaceAction(w, r, id, s.deps.Reviewer.Undo(r.Context(), id))
|
||||
}
|
||||
|
||||
// handleRelink повторно привязывает откатанную задачу: перезапускает
|
||||
@@ -342,28 +340,62 @@ func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
|
||||
redirectErr(w, r, userErr(r, err, id))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
s.surfaceAction(w, r, id, s.deps.Reviewer.Relink(r.Context(), id))
|
||||
}
|
||||
|
||||
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
|
||||
// ревью (с ошибкой в ?err при неудаче).
|
||||
// reviewAction — общий помощник петлевых действий ревью (уточнить/распознать
|
||||
// заново/…): выполнить действие и обновить экран ревью. На htmx перечитывает
|
||||
// состояние и рендерит партиал `review_main` на месте (при ошибке — сообщение в
|
||||
// баннере и HTTP 200, иначе htmx не свопит DOM); без htmx деградирует до
|
||||
// PRG-редиректа на `/review/{id}`. Действия асинхронны — своп отдаёт актуальное
|
||||
// состояние (обычно `recognizing`), которое дальше само допалливается фрагментом
|
||||
// (см. handleFragReview).
|
||||
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
if err := fn(r.Context(), id); err != nil {
|
||||
// Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не
|
||||
// логируя повторно (доменный слой/валидация — не дело транспорта).
|
||||
redirectReview(w, r, id, userErr(r, err, id))
|
||||
actionErr := fn(r.Context(), id)
|
||||
|
||||
if !isHTMX(r) {
|
||||
msg := ""
|
||||
if actionErr != nil {
|
||||
msg = userErr(r, actionErr, id)
|
||||
}
|
||||
redirectReview(w, r, id, msg)
|
||||
return
|
||||
}
|
||||
redirectReview(w, r, id, "")
|
||||
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.deps.Logger.Error("review data", "id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
msg := ""
|
||||
if actionErr != nil {
|
||||
msg = userErr(r, actionErr, id)
|
||||
}
|
||||
s.render(w, "review_main", buildReviewView(id, rd, msg))
|
||||
}
|
||||
|
||||
// handleFragReview отдаёт партиал тела ревью (htmx-поллинг recognizing). Пока
|
||||
// загрузка в `recognizing`, `review_main` несёт поллер и экран сам обновляется;
|
||||
// как только состояние стало `review`, фрагмент возвращается без поллера — опрос
|
||||
// прекращается.
|
||||
func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
return
|
||||
}
|
||||
s.render(w, "review_main", buildReviewView(id, rd, ""))
|
||||
}
|
||||
|
||||
// isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу).
|
||||
|
||||
Reference in New Issue
Block a user