patx/gitman
improve sesarch ux
Commit 01f59f9 · patx · 2026-05-10T15:41:56-04:00
Comments
No comments yet.
Diff
diff --git a/templates/base.tpl b/templates/base.tpl
index c4a7158..3bedc11 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -464,64 +464,176 @@
</script>
<script>
(() => {
- const pager = document.querySelector("[data-pagination]");
- const list = document.querySelector("[data-paginated-list]");
- if (!pager || !list) return;
-
- let loading = false;
+ const forms = document.querySelectorAll("[data-live-search-form]");
+ if (!forms.length) return;
- const setLoading = (isLoading) => {
- loading = isLoading;
- const status = pager.querySelector("[data-pagination-status]");
- if (status) status.hidden = !isLoading;
+ const importChildren = (target, source) => {
+ target.replaceChildren(
+ ...Array.from(source.childNodes).map((child) => document.importNode(child, true))
+ );
};
- const loadNextPage = () => {
- const nextUrl = pager.dataset.nextUrl;
- if (loading || !nextUrl) return;
- setLoading(true);
+ forms.forEach((form) => {
+ const input = form.querySelector("[data-live-search-input]");
+ const resultsSelector = form.dataset.liveSearchResults;
+ const filtersSelector = form.dataset.liveSearchFilters;
+ const results = resultsSelector ? document.querySelector(resultsSelector) : null;
+ if (!input || !results) return;
+
+ let searchTimeout = null;
+ let activeToken = 0;
+
+ const buildSearchUrl = () => {
+ const url = new URL(form.getAttribute("action") || window.location.href, window.location.origin);
+ const defaultStatus = form.dataset.liveSearchDefaultStatus || "";
+ url.search = "";
+
+ new FormData(form).forEach((value, key) => {
+ const text = String(value).trim();
+ if (!text || key === "page") return;
+ if (key === "status" && text === defaultStatus) return;
+ url.searchParams.set(key, text);
+ });
- fetch(nextUrl, { headers: { Accept: "text/html" } })
- .then((response) => {
- if (!response.ok) throw new Error("Unable to load the next page.");
- return response.text();
- })
- .then((html) => {
- const doc = new DOMParser().parseFromString(html, "text/html");
- const nextList = doc.querySelector("[data-paginated-list]");
- const nextPager = doc.querySelector("[data-pagination]");
- if (!nextList) throw new Error("Next page has no list.");
-
- Array.from(nextList.children).forEach((item) => {
- list.appendChild(document.importNode(item, true));
+ return url;
+ };
+
+ const replaceSearchContent = (html) => {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ const nextResults = doc.querySelector(resultsSelector);
+ if (!nextResults) throw new Error("Search response did not include results.");
+
+ importChildren(results, nextResults);
+
+ if (filtersSelector) {
+ const filters = document.querySelector(filtersSelector);
+ const nextFilters = doc.querySelector(filtersSelector);
+ if (filters && nextFilters) importChildren(filters, nextFilters);
+ }
+
+ if (window.gitmanInitPagination) window.gitmanInitPagination(results);
+ };
+
+ const search = () => {
+ const token = activeToken + 1;
+ const url = buildSearchUrl();
+ const searchUrl = url.toString();
+ activeToken = token;
+ results.setAttribute("aria-busy", "true");
+
+ fetch(searchUrl, { headers: { Accept: "text/html" } })
+ .then((response) => {
+ if (!response.ok) throw new Error("Unable to search.");
+ return response.text();
+ })
+ .then((html) => {
+ if (activeToken !== token || buildSearchUrl().toString() !== searchUrl) return;
+ replaceSearchContent(html);
+ if (window.history && window.history.replaceState) {
+ window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
+ }
+ })
+ .catch(() => {
+ if (activeToken !== token || buildSearchUrl().toString() !== searchUrl) return;
+ })
+ .finally(() => {
+ if (activeToken === token && buildSearchUrl().toString() === searchUrl) {
+ results.removeAttribute("aria-busy");
+ }
});
+ };
+
+ input.addEventListener("input", () => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(search, 160);
+ });
+
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ clearTimeout(searchTimeout);
+ search();
+ });
+ });
+ })();
+ </script>
+ <script>
+ (() => {
+ const initPager = (pager) => {
+ if (!pager || pager.dataset.paginationInitialized === "true") return;
+ const scope = pager.closest("[data-live-search-results]") || document;
+ const list = scope.querySelector("[data-paginated-list]");
+ if (!list) return;
+
+ pager.dataset.paginationInitialized = "true";
+ let loading = false;
+
+ const setLoading = (isLoading) => {
+ loading = isLoading;
+ const status = pager.querySelector("[data-pagination-status]");
+ if (status) status.hidden = !isLoading;
+ };
- if (nextPager && nextPager.dataset.nextUrl) {
- pager.dataset.nextUrl = nextPager.dataset.nextUrl;
+ const loadNextPage = () => {
+ const nextUrl = pager.dataset.nextUrl;
+ if (loading || !nextUrl) return;
+ setLoading(true);
+
+ fetch(nextUrl, { headers: { Accept: "text/html" } })
+ .then((response) => {
+ if (!response.ok) throw new Error("Unable to load the next page.");
+ return response.text();
+ })
+ .then((html) => {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ const nextScope = scope === document
+ ? doc
+ : (scope.id ? doc.getElementById(scope.id) : doc.querySelector("[data-live-search-results]"));
+ const nextList = (nextScope || doc).querySelector("[data-paginated-list]");
+ const nextPager = (nextScope || doc).querySelector("[data-pagination]");
+ if (!nextList) throw new Error("Next page has no list.");
+
+ Array.from(nextList.children).forEach((item) => {
+ list.appendChild(document.importNode(item, true));
+ });
+
+ if (nextPager && nextPager.dataset.nextUrl) {
+ pager.dataset.nextUrl = nextPager.dataset.nextUrl;
+ setLoading(false);
+ } else {
+ pager.remove();
+ }
+ })
+ .catch(() => {
setLoading(false);
- } else {
- pager.remove();
- }
- })
- .catch(() => {
- setLoading(false);
- });
+ });
+ };
+
+ if ("IntersectionObserver" in window) {
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) loadNextPage();
+ });
+ }, { rootMargin: "600px 0px" });
+ observer.observe(pager);
+ return;
+ }
+
+ window.addEventListener("scroll", () => {
+ const bottom = pager.getBoundingClientRect().top - window.innerHeight;
+ if (bottom < 600) loadNextPage();
+ });
};
- if ("IntersectionObserver" in window) {
- const observer = new IntersectionObserver((entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) loadNextPage();
- });
- }, { rootMargin: "600px 0px" });
- observer.observe(pager);
- return;
- }
+ window.gitmanInitPagination = (scope = document) => {
+ const root = scope && scope.querySelectorAll ? scope : document;
+ if (root.matches && root.matches("[data-pagination]")) {
+ initPager(root);
+ return;
+ }
+ root.querySelectorAll("[data-pagination]").forEach(initPager);
+ };
- window.addEventListener("scroll", () => {
- const bottom = pager.getBoundingClientRect().top - window.innerHeight;
- if (bottom < 600) loadNextPage();
- });
+ window.gitmanInitPagination();
})();
</script>
<script>
diff --git a/templates/issues.tpl b/templates/issues.tpl
index 2040053..fabaf29 100644
--- a/templates/issues.tpl
+++ b/templates/issues.tpl
@@ -14,13 +14,13 @@
<section class="panel">
<div class="panel-heading">
- <form class="repo-search-form" action="/{{repo['owner_username']}}/{{repo['name']}}/issues" method="get" role="search">
+ <form class="repo-search-form" action="/{{repo['owner_username']}}/{{repo['name']}}/issues" method="get" role="search" data-live-search-form data-live-search-results="#issue-search-results" data-live-search-filters="#issue-search-filters" data-live-search-default-status="open">
<input type="hidden" name="status" value="{{status}}">
<label class="sr-only" for="issue-search-input">Search issues</label>
- <input id="issue-search-input" class="repo-search-input" type="search" name="q" value="{{q}}" placeholder="Search issues" autocomplete="off">
+ <input id="issue-search-input" class="repo-search-input" type="search" name="q" value="{{q}}" placeholder="Search issues" autocomplete="off" data-live-search-input>
<button class="sr-only" type="submit">Search issues</button>
</form>
- <div class="filters">
+ <div id="issue-search-filters" class="filters" data-live-search-filters>
<a class="{{'active' if status == 'open' else ''}}" href="{{current_url_with_params(status='open')}}">Open ({{counts["open"]}})</a>
<a class="{{'active' if status == 'closed' else ''}}" href="{{current_url_with_params(status='closed')}}">Closed ({{counts["closed"]}})</a>
<a class="{{'active' if status == 'all' else ''}}" href="{{current_url_with_params(status='all')}}">All</a>
@@ -34,21 +34,23 @@
% end
</div>
</div>
- % if issues:
- <ul class="issue-list" data-paginated-list>
- % for issue in issues:
- <li>
- <a href="/{{repo['owner_username']}}/{{repo['name']}}/issues/{{issue['number']}}">#{{issue["number"]}} {{issue["title"]}}</a> <span>({{issue["status"]}})</span>
- </li>
- % end
- </ul>
- % include("pagination.tpl", pagination=pagination)
- % else:
- % if q:
- <p class="empty">No {{issue_scope}} matching "{{q}}".</p>
+ <div id="issue-search-results" style="margin-top:25px;" data-live-search-results aria-live="polite">
+ % if issues:
+ <ul class="issue-list" data-paginated-list>
+ % for issue in issues:
+ <li>
+ <a href="/{{repo['owner_username']}}/{{repo['name']}}/issues/{{issue['number']}}">#{{issue["number"]}} {{issue["title"]}}</a> <span>({{issue["status"]}})</span>
+ </li>
+ % end
+ </ul>
+ % include("pagination.tpl", pagination=pagination)
% else:
- <p class="empty">No {{issue_scope}}.</p>
+ % if q:
+ <p class="empty">No {{issue_scope}} matching "{{q}}".</p>
+ % else:
+ <p class="empty">No {{issue_scope}}.</p>
+ % end
+ % include("pagination.tpl", pagination=pagination)
% end
- % include("pagination.tpl", pagination=pagination)
- % end
+ </div>
</section>
diff --git a/templates/pull_requests.tpl b/templates/pull_requests.tpl
index 057d30a..d51a1d8 100644
--- a/templates/pull_requests.tpl
+++ b/templates/pull_requests.tpl
@@ -14,13 +14,13 @@
<section class="panel">
<div class="panel-heading">
- <form class="repo-search-form" action="/{{repo['owner_username']}}/{{repo['name']}}/pulls" method="get" role="search">
+ <form class="repo-search-form" action="/{{repo['owner_username']}}/{{repo['name']}}/pulls" method="get" role="search" data-live-search-form data-live-search-results="#pull-request-search-results" data-live-search-filters="#pull-request-search-filters" data-live-search-default-status="open">
<input type="hidden" name="status" value="{{status}}">
<label class="sr-only" for="pull-request-search-input">Search pull requests</label>
- <input id="pull-request-search-input" class="repo-search-input" type="search" name="q" value="{{q}}" placeholder="Search pull requests" autocomplete="off">
+ <input id="pull-request-search-input" class="repo-search-input" type="search" name="q" value="{{q}}" placeholder="Search pull requests" autocomplete="off" data-live-search-input>
<button class="sr-only" type="submit">Search pull requests</button>
</form>
- <div class="filters">
+ <div id="pull-request-search-filters" class="filters" data-live-search-filters>
<a class="{{'active' if status == 'open' else ''}}" href="{{current_url_with_params(status='open')}}">Open ({{counts["open"]}})</a>
<a class="{{'active' if status == 'merged' else ''}}" href="{{current_url_with_params(status='merged')}}">Merged ({{counts["merged"]}})</a>
<a class="{{'active' if status == 'closed' else ''}}" href="{{current_url_with_params(status='closed')}}">Closed ({{counts["closed"]}})</a>
@@ -35,22 +35,24 @@
% end
</div>
</div>
- % if pull_requests:
- <ul class="issue-list" data-paginated-list>
- % for pr in pull_requests:
- <li>
- <a href="/{{repo['owner_username']}}/{{repo['name']}}/pulls/{{pr['number']}}">#{{pr["number"]}} {{pr["title"]}}</a>
- <span>({{pr["status"]}} from {{pr["source_owner_username"]}}/{{pr["source_repo_name"]}} {{format_ref_label(pr["source_ref_type"], pr["source_ref_name"])}} into {{format_ref_label(pr["target_ref_type"], pr["target_ref_name"])}})</span>
- </li>
- % end
- </ul>
- % include("pagination.tpl", pagination=pagination)
- % else:
- % if q:
- <p class="empty">No {{pull_request_scope}} matching "{{q}}".</p>
+ <div id="pull-request-search-results" style="margin-top:25px;" data-live-search-results aria-live="polite">
+ % if pull_requests:
+ <ul class="issue-list" data-paginated-list>
+ % for pr in pull_requests:
+ <li>
+ <a href="/{{repo['owner_username']}}/{{repo['name']}}/pulls/{{pr['number']}}">#{{pr["number"]}} {{pr["title"]}}</a>
+ <span>({{pr["status"]}} from {{pr["source_owner_username"]}}/{{pr["source_repo_name"]}} {{format_ref_label(pr["source_ref_type"], pr["source_ref_name"])}} into {{format_ref_label(pr["target_ref_type"], pr["target_ref_name"])}})</span>
+ </li>
+ % end
+ </ul>
+ % include("pagination.tpl", pagination=pagination)
% else:
- <p class="empty">No {{pull_request_scope}}.</p>
+ % if q:
+ <p class="empty">No {{pull_request_scope}} matching "{{q}}".</p>
+ % else:
+ <p class="empty">No {{pull_request_scope}}.</p>
+ % end
+ % include("pagination.tpl", pagination=pagination)
% end
- % include("pagination.tpl", pagination=pagination)
- % end
+ </div>
</section>
diff --git a/tests/test_app.py b/tests/test_app.py
index 660514a..17c8a12 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1461,6 +1461,9 @@ def test_issue_list_searches_and_paginates_results(isolated_app, monkeypatch):
assert "#2 Memory leak 2" in first.text
assert "#1 Memory leak 1" not in first.text
assert "Unrelated report" not in first.text
+ assert 'data-live-search-form' in first.text
+ assert 'data-live-search-input' in first.text
+ assert 'id="issue-search-results" style="margin-top:25px;"' in first.text
assert 'data-paginated-list' in first.text
assert 'data-next-url="/alice/demo/issues?q=memory&page=2"' in first.text
assert 'href="/alice/demo/issues?q=memory&status=closed"' in first.text
@@ -2304,6 +2307,9 @@ def test_pull_request_list_searches_and_paginates_results(isolated_app, monkeypa
assert "#2 Feature search 2" in first.text
assert "#1 Feature search 1" not in first.text
assert "Maintenance cleanup" not in first.text
+ assert 'data-live-search-form' in first.text
+ assert 'data-live-search-input' in first.text
+ assert 'id="pull-request-search-results" style="margin-top:25px;"' in first.text
assert 'data-paginated-list' in first.text
assert 'data-next-url="/alice/demo/pulls?q=feature&page=2"' in first.text
assert 'href="/alice/demo/pulls?q=feature&status=merged"' in first.text