diff --git a/frontend/dashboard.css b/frontend/dashboard.css index f99d6df..deaee06 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -458,6 +458,14 @@ textarea { resize: vertical; min-height: 120px; } .find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; } .find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; } .find-work-search input, .find-work-search button { min-height:44px; } +.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; } +.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; } +.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; } +.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; } +.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); } +.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; } +.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; } +#clear-find-work-filters { min-height:44px; width:100%; } .find-work-list { display:grid; gap:10px; } .find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; } .find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); } diff --git a/frontend/index.html b/frontend/index.html index 6fb934c..66fa1b4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -541,6 +541,14 @@ +
+ Filter work +
+
Repositories
+
Labels
+ +
+
Open Find Work to load available issues.
diff --git a/frontend/pick-work.js b/frontend/pick-work.js index 73f38d6..99954b3 100644 --- a/frontend/pick-work.js +++ b/frontend/pick-work.js @@ -1,9 +1,11 @@ -function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {} }) { +function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) { let available = []; let pagination = { page: 1, total: 0, has_more: false }; let loadRequest = null; let loadGeneration = 0; let query = ''; + let filters = { repositories: [], labels: [] }; + let facetView; let claimRequest = null; const previewed = new Set(); let selecting = false; @@ -41,12 +43,27 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio }; onItems(available.slice()); onPagination({ ...pagination }); + if (result?.facets) onFacets({ + repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [], + labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [], + }); + if (result?.facets && typeof document !== 'undefined') { + facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value => + String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])), + next => { filters = next; loadPage(1, false); }); + facetView(result.facets); + } } function loadPage(page, append, requestedQuery = query) { if (loadRequest && requestedQuery === query && append) return loadRequest; const generation = ++loadGeneration; - const queryPart = requestedQuery ? '&q=' + encodeURIComponent(requestedQuery) : ''; + const params = new URLSearchParams(); + params.set('facets', 'true'); + if (requestedQuery) params.set('q', requestedQuery); + filters.repositories.forEach(value => params.append('repository', value)); + filters.labels.forEach(value => params.append('label', value)); + const queryPart = params.toString() ? '&' + params.toString() : ''; const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, { headers: { Accept: 'application/json' }, }).then(result => { @@ -81,6 +98,17 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio query() { return query; }, + setFilters(next) { + filters = { + repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10), + labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10), + }; + return loadPage(1, false); + }, + filters() { + return { repositories: filters.repositories.slice(), labels: filters.labels.slice() }; + }, + items() { return available.slice(); }, @@ -163,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio }; } +function attachFindWorkFacets(root, escapeHtml, onChange) { + const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value); + root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') }); + root.querySelector('#clear-find-work-filters').onclick = () => { + root.querySelectorAll('input').forEach(input => { input.checked = false; }); + onChange({ repositories:[], labels:[] }); + }; + return facets => { + const active = { repositories:values('find-work-repository'), labels:values('find-work-label') }; + const render = (id, choices, selected, name) => { + root.querySelector(id).innerHTML = choices.map(value => '').join(''); + }; + render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository'); + render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label'); + const count = active.repositories.length + active.labels.length; + root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : ''; + }; +} + if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork; diff --git a/src/main.py b/src/main.py index f619255..2487116 100644 --- a/src/main.py +++ b/src/main.py @@ -2722,8 +2722,20 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]: def _available_issue_page( - items: list[dict], page: int, limit: int = 50, query: str = "" + items: list[dict], page: int, limit: int = 50, query: str = "", + repositories: list[str] | None = None, labels: list[str] | None = None, + include_facets: bool = False, ) -> dict: + facets = { + "repositories": sorted({str(item.get("repository")) for item in items if item.get("repository")}, key=str.casefold), + "labels": sorted({str(label) for item in items for label in (item.get("labels") or [])}, key=str.casefold), + } + repository_filter = {value.casefold() for value in (repositories or [])} + label_filter = {value.casefold() for value in (labels or [])} + if repository_filter: + items = [item for item in items if str(item.get("repository") or "").casefold() in repository_filter] + if label_filter: + items = [item for item in items if label_filter & {str(label).casefold() for label in (item.get("labels") or [])}] normalized_query = query.strip().casefold() if normalized_query: number_query = normalized_query.removeprefix("#") @@ -2735,19 +2747,27 @@ def _available_issue_page( ] start = (page - 1) * limit page_items = items[start:start + limit] - return { + result = { "items": page_items, "page": page, "total": len(items), "has_more": start + len(page_items) < len(items), } + if include_facets or repository_filter or label_filter: + result["facets"] = facets + return result @app.get("/api/v1/available-issues") async def available_issues( page: int = Query(default=1, ge=1, le=100), q: str = Query(default="", max_length=100), + repository: list[str] = Query(default=[], max_length=100), + label: list[str] = Query(default=[], max_length=100), + facets: bool = Query(default=False), ) -> JSONResponse: + if len(repository) > 10 or len(label) > 10: + return JSONResponse({"error": "Select at most 10 repositories or labels."}, status_code=422) try: items, stale, revalidating, refresh_failed = await asyncio.wait_for( _available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS @@ -2758,7 +2778,9 @@ async def available_issues( status_code=503, headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))}, ) - result = _available_issue_page(items, page, query=q) + result = _available_issue_page( + items, page, query=q, repositories=repository, labels=label, include_facets=facets + ) if stale: result["stale"] = True if revalidating: diff --git a/tests/test_batch_find_work.py b/tests/test_batch_find_work.py index 1571673..46406d3 100644 --- a/tests/test_batch_find_work.py +++ b/tests/test_batch_find_work.py @@ -269,7 +269,7 @@ const pages={{ }}; const controller=createFindWork({{ fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) : - Promise.resolve(pages[path.endsWith('=2') ? 2 : 1]), + Promise.resolve(pages[path.includes('page=2') ? 2 : 1]), onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, }}); controller.load().then(()=>{{ @@ -426,9 +426,9 @@ second.then(()=>{{ assert run_node(script) == { "calls": [ - "api/v1/available-issues?page=1&q=api", - "api/v1/available-issues?page=1&q=dashboard", - "api/v1/available-issues?page=1&q=worker", + "api/v1/available-issues?page=1&facets=true&q=api", + "api/v1/available-issues?page=1&facets=true&q=dashboard", + "api/v1/available-issues?page=1&facets=true&q=worker", ], "rendered": [[673], [674]], "items": [674], @@ -449,3 +449,40 @@ def test_mobile_find_work_search_has_clear_live_results_and_touch_targets(): assert "#find-work-search" in dashboard assert ".find-work-search" in css assert ".find-work-search button" in css and "min-height:44px" in css + + +def test_find_work_facets_serialize_filters_and_preserve_selection(): + script = f""" +const createFindWork=require({json.dumps(str(PICK_WORK))}); +const calls=[]; +const controller=createFindWork({{ + fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }}, + onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}}, +}}); +controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}}); +controller.startSelection(); controller.toggleSelection(controller.items()[0]); +controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{ + calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number) +}}))); +""" + + assert run_node(script) == { + "calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"], + "filters": {"repositories": ["stackchain/api"], "labels": ["critical"]}, + "selected": [9], + } + + +def test_mobile_find_work_exposes_accessible_touch_sized_facets(): + html = HTML.read_text() + dashboard = DASHBOARD.read_text() + css = CSS.read_text() + + assert 'id="find-work-filters"' in html + assert 'id="find-work-repository-filters"' in html + assert 'id="find-work-label-filters"' in html + assert 'id="clear-find-work-filters"' in html + assert 'aria-label="Filter available work"' in html + assert "setFilters(next)" in PICK_WORK.read_text() + assert ".find-work-filters" in css + assert ".find-work-filter-option" in css and "min-height:44px" in css diff --git a/tests/test_gitea_work_search.py b/tests/test_gitea_work_search.py index b0a6791..e1f80af 100644 --- a/tests/test_gitea_work_search.py +++ b/tests/test_gitea_work_search.py @@ -248,6 +248,74 @@ async def test_available_issue_search_filters_full_catalog_before_pagination(mon assert by_title.json() == by_repo.json() +@pytest.mark.anyio +async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch): + async def available(): + return [ + {"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}, + {"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]}, + {"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]}, + ] + + monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/available-issues", + params=[("repository", "stackchain/api"), ("label", "critical")], + ) + + assert response.status_code == 200 + assert response.json() == { + "items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}], + "page": 1, + "total": 1, + "has_more": False, + "facets": { + "repositories": ["stackchain/api", "stackchain/dashboard"], + "labels": ["backend", "critical", "docs", "frontend"], + }, + } + + +@pytest.mark.anyio +async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch): + async def available(): + return [ + {"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]}, + {"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]}, + {"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]}, + {"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]}, + ] + + monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/available-issues", + params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"), + ("label", "backend"), ("label", "frontend")], + ) + + assert [item["number"] for item in response.json()["items"]] == [1, 2] + + +@pytest.mark.anyio +async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch): + async def must_not_scan(): + raise AssertionError("invalid facets must be rejected before catalog scan") + + monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/available-issues", + params=[("label", f"label-{index}") for index in range(11)], + ) + + assert response.status_code == 422 + + @pytest.mark.anyio async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch): async def must_not_scan(): diff --git a/tests/test_my_work.py b/tests/test_my_work.py index e21ffd2..5a691ca 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -3345,7 +3345,10 @@ controller.load().then(() => controller.loadMore()).then(() => ) output = json.loads(result.stdout) - assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"] + assert output["calls"] == [ + "api/v1/available-issues?page=1&facets=true", + "api/v1/available-issues?page=2&facets=true", + ] assert [item["id"] for item in output["items"]] == [1, 2] assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}