vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Update the change page as live regions, not one sidebar swap
707dfd10f309461a77a4a8f73dcce74b07c91fd6 · Keenan Nemetz · 10d ago · parent 9f5efa3c
Update the change page as live regions, not one sidebar swap The 20-second poll replaced the entire sidebar, so every new widget needed its own reason to opt out and live updates stopped while it was in use. The sidebar is now a set of regions: review status, changes, approvals, checks, and the combined result are data-only live regions returned with hx-swap-oob; the target picker, issue search, and composers are outside every polled swap. Approval and the other multi-region writes use the same out-of-band group, and the page action group refreshes from the write without replacing unrelated state.
server/issues_changes_test.go +33−11
| @@ -339,6 +339,8 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) { | |||
| 339 | 339 | for _, want := range []string{ | |
| 340 | 340 | `id="request-issues"`, "First linked issue", `value="` + first + `"`, | |
| 341 | 341 | `value="remove"`, `request-unlink`, `class="request-issue-search"`, | |
| 342 | `hx-get="/r/` + f.repo + `/requests/` + id + `/live"`, | ||
| 343 | `hx-trigger="every 20s [document.visibilityState === 'visible']"`, `hx-swap="none"`, | ||
| 342 | 344 | } { | |
| 343 | 345 | if !strings.Contains(page, want) { | |
| 344 | 346 | t.Fatalf("the request page is missing %q:\n%s", want, page) | |
| @@ -347,6 +349,9 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) { | |||
| 347 | 349 | if strings.Contains(page, "when merged.") { | |
| 348 | 350 | t.Fatalf("the old closes line is still on the page:\n%s", page) | |
| 349 | 351 | } | |
| 352 | if strings.Contains(page, "document.activeElement") { | ||
| 353 | t.Fatalf("the poll still guards on focus instead of updating only data regions:\n%s", page) | ||
| 354 | } | ||
| 350 | 355 | ||
| 351 | 356 | // The search box filters by title, `#number` and ULID prefix. A match is a | |
| 352 | 357 | // button that posts the link, and the Issues section comes back in place. | |
| @@ -525,7 +530,9 @@ func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) { | |||
| 525 | 530 | } | |
| 526 | 531 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/") | |
| 527 | 532 | ||
| 528 | // A maintainer approves; the sidebar comes back with the approval and progress. | ||
| 533 | // A maintainer approves; the data-only regions and the action buttons come | ||
| 534 | // back as out-of-band updates, so the Issues section and any open search are | ||
| 535 | // left alone. | ||
| 529 | 536 | writerSess, writerCookie := f.browserSession(t, f.writerID) | |
| 530 | 537 | request, err := metadata.GetRequest(mustRepo(t, f).Git(), id) | |
| 531 | 538 | if err != nil { | |
| @@ -534,19 +541,34 @@ func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) { | |||
| 534 | 541 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/approve", writerCookie, url.Values{ | |
| 535 | 542 | "csrf": {writerSess.CSRF}, "base": {request.Base}, "head": {request.Head}, | |
| 536 | 543 | }) | |
| 537 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-sidebar"`) || | ||
| 538 | !strings.Contains(body, "1 of 1 required approval(s).") { | ||
| 539 | t.Fatalf("approval response is not the expected sidebar (%d):\n%s", resp.StatusCode, body) | ||
| 544 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `hx-swap-oob="outerHTML"`) || | ||
| 545 | !strings.Contains(body, `id="request-approvals"`) || !strings.Contains(body, "1 of 1 required approval(s).") || | ||
| 546 | !strings.Contains(body, `id="request-actions"`) || !strings.Contains(body, `id="request-review-status"`) { | ||
| 547 | t.Fatalf("approval response is not the expected live update (%d):\n%s", resp.StatusCode, body) | ||
| 548 | } | ||
| 549 | for _, unwanted := range []string{`id="request-issues"`, `id="request-target"`, `request-issue-search`} { | ||
| 550 | if strings.Contains(body, unwanted) { | ||
| 551 | t.Fatalf("approval response reset unrelated state %q:\n%s", unwanted, body) | ||
| 552 | } | ||
| 540 | 553 | } | |
| 541 | 554 | ||
| 542 | // The page's poll replaces the whole aside: the Changes block and the | ||
| 543 | // sidebar come back together, without a page shell. | ||
| 544 | resp, body = f.htmxGet(t, "/r/"+f.repo+"/requests/"+id+"/aside", writerCookie) | ||
| 555 | // The page's poll returns only out-of-band data regions. It has no sidebar | ||
| 556 | // wrapper and no form, so it cannot close a search or replace a picker. | ||
| 557 | resp, body = f.htmxGet(t, "/r/"+f.repo+"/requests/"+id+"/live", writerCookie) | ||
| 545 | 558 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") || | |
| 546 | !strings.Contains(body, `id="request-aside"`) || !strings.Contains(body, `id="request-sidebar"`) || | ||
| 547 | !strings.Contains(body, `hx-trigger="every 20s`) || !strings.Contains(body, `class="help"`) || | ||
| 548 | !strings.Contains(body, "Merges into main cleanly") || !strings.Contains(body, "1 of 1 required approval(s).") { | ||
| 549 | t.Fatalf("aside poll is not the expected fragment (%d):\n%s", resp.StatusCode, body) | ||
| 559 | strings.Contains(body, "<form") || strings.Contains(body, `id="request-aside"`) || | ||
| 560 | strings.Contains(body, `id="request-sidebar"`) || strings.Contains(body, `request-issues`) { | ||
| 561 | t.Fatalf("live poll is not the expected data-only fragment (%d):\n%s", resp.StatusCode, body) | ||
| 562 | } | ||
| 563 | for _, want := range []string{ | ||
| 564 | `hx-swap-oob="outerHTML"`, `id="request-review-status"`, `id="request-changes"`, | ||
| 565 | `id="request-approvals"`, `id="request-checks"`, `id="request-combined"`, | ||
| 566 | `id="request-merge-note"`, "Merges into main cleanly", "1 of 1 required approval(s).", | ||
| 567 | `class="help"`, | ||
| 568 | } { | ||
| 569 | if !strings.Contains(body, want) { | ||
| 570 | t.Fatalf("live poll is missing %q (%d):\n%s", want, resp.StatusCode, body) | ||
| 571 | } | ||
| 550 | 572 | } | |
| 551 | 573 | ||
| 552 | 574 | // Posting a comment replaces only the discussion fragment. | |