Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.