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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Standardize filter rows and add tag selection to the code page

55615c7a4b8472f8bfccab8048f608978d389821 · Keenan Nemetz · 11d ago · parent 3a068983

Standardize filter rows and add tag selection to the code page

The code page kept its branch picker in the header actions, had no tag picker,
and left every list page to shape its own filters: search inputs ranged from
10rem to 18rem, some selects had a visible label and some did not, and text
searches submitted through a Filter button, a Search button, or Enter alone
while selects sometimes waited for a click.

- One .filters row on issues, requests, activity, the code page and code
  search. One --search-w (18rem) for page and global search, a visible label on
  every filter select, auto-apply on select change, and one icon-and-Search
  button for text.
- The code page puts its search, branch picker and new tag picker on the shared
  row under the header. The tag picker lists tags newest first and leaves the
  branch picker unclaimed when a tag is open.
- data-autosubmit uses requestSubmit() so htmx swaps the filtered pane instead
  of a full page load.

Checks: ./vole workflow check; ./vole workflow run verify.

server/ui_test.go +118

@@ -643,6 +643,124 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) {
643 643 }
644 644 }
645 645
646 func TestCodeBrowserRefPickers(t *testing.T) {
647 t.Parallel()
648 f := newFixture(t)
649 f.publish(f.repo)
650 work := workRepo(t, map[string]string{"README.md": "# refs\n"})
651 if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "release notes"); err != nil {
652 t.Fatalf("tag: %s", errs)
653 }
654 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main", "refs/tags/v1.0.0"); err != nil {
655 t.Fatalf("push: %s", errs)
656 }
657
658 // Search and the ref pickers share one row under the page header, and the
659 // tag picker lists tags beside the branch picker.
660 code, body := f.get("/r/" + f.repo + "/code")
661 if code != 200 {
662 t.Fatalf("code page = %d", code)
663 }
664 filters := body[strings.Index(body, `class="filters"`):]
665 for _, want := range []string{
666 `aria-label="Search code"`,
667 `<span class="filter-label">Branch</span>`,
668 `<span class="filter-label">Tag</span>`,
669 `<option value="main" selected>main</option>`,
670 `<option value="v1.0.0">v1.0.0</option>`,
671 } {
672 if !strings.Contains(filters, want) {
673 t.Fatalf("ref picker row does not contain %q:\n%s", want, body)
674 }
675 }
676
677 // Opening a tag selects it in the tag picker and leaves the branch picker
678 // unclaimed rather than pretending the first branch is open.
679 code, body = f.get("/r/" + f.repo + "/code?ref=v1.0.0")
680 if code != 200 {
681 t.Fatalf("tag page = %d", code)
682 }
683 filters = body[strings.Index(body, `class="filters"`):]
684 for _, want := range []string{
685 `<option value="v1.0.0" selected>v1.0.0</option>`,
686 `<option value="" disabled selected>—</option>`,
687 } {
688 if !strings.Contains(filters, want) {
689 t.Fatalf("tag page ref pickers do not contain %q:\n%s", want, body)
690 }
691 }
692 if strings.Contains(filters, `<option value="main" selected>main</option>`) {
693 t.Fatalf("branch picker claims main while a tag is open:\n%s", body)
694 }
695 }
696
697 // Filter rows are one component: the same class, a visible label on every
698 // select, selects that apply on change, and a Search button for text. A new page
699 // should reuse this row instead of inventing a second shape.
700 func TestFilterRowsShareOneShape(t *testing.T) {
701 t.Parallel()
702 f := newFixture(t)
703 f.publish(f.repo)
704 work := workRepo(t, map[string]string{"README.md": "# filters\n"})
705 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
706 t.Fatalf("push: %s", errs)
707 }
708 issues := f.issuesRepo(t, map[string]string{
709 "issues/01M5JR2JK29KKMAA1T9MDNTPF1/issue.md": issueFile("Filter shape", "keenan", "open",
710 "2026-10-22T21:00:00Z", []string{"bug"}, "body\n", "wilbur"),
711 })
712 if _, errs, err := f.git(issues, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
713 t.Fatalf("issues push: %s", errs)
714 }
715
716 for _, row := range []struct {
717 path string
718 labels []string
719 }{
720 {"/r/" + f.repo + "/issues", []string{"Label", "Assignee"}},
721 {"/r/" + f.repo + "/activity", []string{"Actor", "Assignee"}},
722 {"/r/" + f.repo + "/code", []string{"Branch"}},
723 } {
724 code, body := f.get(row.path)
725 if code != 200 {
726 t.Fatalf("%s = %d", row.path, code)
727 }
728 at := strings.Index(body, `class="filters"`)
729 if at < 0 {
730 t.Fatalf("%s has no shared filter row", row.path)
731 }
732 filters := body[at:]
733 for _, label := range row.labels {
734 if !strings.Contains(filters, `<span class="filter-label">`+label+`</span>`) {
735 t.Errorf("%s has an unlabeled %s dropdown:\n%s", row.path, label, body)
736 }
737 }
738 if !strings.Contains(filters, "data-autosubmit") {
739 t.Errorf("%s does not apply its selects on change", row.path)
740 }
741 if strings.Contains(filters, ">Filter<") {
742 t.Errorf("%s still has its own Filter button", row.path)
743 }
744 }
745
746 // Text search is the same button on every page: an icon and the word Search.
747 for _, path := range []string{
748 "/r/" + f.repo + "/issues",
749 "/r/" + f.repo + "/requests",
750 "/r/" + f.repo + "/search",
751 "/r/" + f.repo + "/code",
752 } {
753 code, body := f.get(path)
754 if code != 200 {
755 t.Fatalf("%s = %d", path, code)
756 }
757 at := strings.Index(body, `class="filters"`)
758 if at < 0 || !strings.Contains(body[at:], "Search</button>") {
759 t.Errorf("%s does not end its search field with a Search button:\n%s", path, body)
760 }
761 }
762 }
763
646 764 func TestSourceBlobNamesItsLanguageAndEscapes(t *testing.T) {
647 765 t.Parallel()
648 766 f := newFixture(t)