feat: filter Find Work by repository and label (Closes #681)
This commit is contained in:
parent
057bec7dc2
commit
f031a37e8d
|
|
@ -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 { 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 > 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-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-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 { 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); }
|
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,14 @@
|
||||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||||
</form>
|
</form>
|
||||||
|
<details class="find-work-filters" id="find-work-filters">
|
||||||
|
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||||
|
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||||
|
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||||
|
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||||
|
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {} }) {
|
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
|
||||||
let available = [];
|
let available = [];
|
||||||
let pagination = { page: 1, total: 0, has_more: false };
|
let pagination = { page: 1, total: 0, has_more: false };
|
||||||
let loadRequest = null;
|
let loadRequest = null;
|
||||||
let loadGeneration = 0;
|
let loadGeneration = 0;
|
||||||
let query = '';
|
let query = '';
|
||||||
|
let filters = { repositories: [], labels: [] };
|
||||||
|
let facetView;
|
||||||
let claimRequest = null;
|
let claimRequest = null;
|
||||||
const previewed = new Set();
|
const previewed = new Set();
|
||||||
let selecting = false;
|
let selecting = false;
|
||||||
|
|
@ -41,12 +43,27 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
|
||||||
};
|
};
|
||||||
onItems(available.slice());
|
onItems(available.slice());
|
||||||
onPagination({ ...pagination });
|
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) {
|
function loadPage(page, append, requestedQuery = query) {
|
||||||
if (loadRequest && requestedQuery === query && append) return loadRequest;
|
if (loadRequest && requestedQuery === query && append) return loadRequest;
|
||||||
const generation = ++loadGeneration;
|
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, {
|
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
}).then(result => {
|
}).then(result => {
|
||||||
|
|
@ -81,6 +98,17 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelectio
|
||||||
query() {
|
query() {
|
||||||
return 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() {
|
items() {
|
||||||
return available.slice();
|
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 => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').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;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||||
|
|
|
||||||
28
src/main.py
28
src/main.py
|
|
@ -2722,8 +2722,20 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
|
||||||
|
|
||||||
|
|
||||||
def _available_issue_page(
|
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:
|
) -> 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()
|
normalized_query = query.strip().casefold()
|
||||||
if normalized_query:
|
if normalized_query:
|
||||||
number_query = normalized_query.removeprefix("#")
|
number_query = normalized_query.removeprefix("#")
|
||||||
|
|
@ -2735,19 +2747,27 @@ def _available_issue_page(
|
||||||
]
|
]
|
||||||
start = (page - 1) * limit
|
start = (page - 1) * limit
|
||||||
page_items = items[start:start + limit]
|
page_items = items[start:start + limit]
|
||||||
return {
|
result = {
|
||||||
"items": page_items,
|
"items": page_items,
|
||||||
"page": page,
|
"page": page,
|
||||||
"total": len(items),
|
"total": len(items),
|
||||||
"has_more": start + len(page_items) < 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")
|
@app.get("/api/v1/available-issues")
|
||||||
async def available_issues(
|
async def available_issues(
|
||||||
page: int = Query(default=1, ge=1, le=100),
|
page: int = Query(default=1, ge=1, le=100),
|
||||||
q: str = Query(default="", max_length=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:
|
) -> JSONResponse:
|
||||||
|
if len(repository) > 10 or len(label) > 10:
|
||||||
|
return JSONResponse({"error": "Select at most 10 repositories or labels."}, status_code=422)
|
||||||
try:
|
try:
|
||||||
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
|
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
|
||||||
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
|
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
|
||||||
|
|
@ -2758,7 +2778,9 @@ async def available_issues(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
|
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:
|
if stale:
|
||||||
result["stale"] = True
|
result["stale"] = True
|
||||||
if revalidating:
|
if revalidating:
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ const pages={{
|
||||||
}};
|
}};
|
||||||
const controller=createFindWork({{
|
const controller=createFindWork({{
|
||||||
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
|
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:()=>{{}},
|
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||||
}});
|
}});
|
||||||
controller.load().then(()=>{{
|
controller.load().then(()=>{{
|
||||||
|
|
@ -426,9 +426,9 @@ second.then(()=>{{
|
||||||
|
|
||||||
assert run_node(script) == {
|
assert run_node(script) == {
|
||||||
"calls": [
|
"calls": [
|
||||||
"api/v1/available-issues?page=1&q=api",
|
"api/v1/available-issues?page=1&facets=true&q=api",
|
||||||
"api/v1/available-issues?page=1&q=dashboard",
|
"api/v1/available-issues?page=1&facets=true&q=dashboard",
|
||||||
"api/v1/available-issues?page=1&q=worker",
|
"api/v1/available-issues?page=1&facets=true&q=worker",
|
||||||
],
|
],
|
||||||
"rendered": [[673], [674]],
|
"rendered": [[673], [674]],
|
||||||
"items": [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 dashboard
|
||||||
assert ".find-work-search" in css
|
assert ".find-work-search" in css
|
||||||
assert ".find-work-search button" in css and "min-height:44px" 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
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,74 @@ async def test_available_issue_search_filters_full_catalog_before_pagination(mon
|
||||||
assert by_title.json() == by_repo.json()
|
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
|
@pytest.mark.anyio
|
||||||
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
|
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
|
||||||
async def must_not_scan():
|
async def must_not_scan():
|
||||||
|
|
|
||||||
|
|
@ -3345,7 +3345,10 @@ controller.load().then(() => controller.loadMore()).then(() =>
|
||||||
)
|
)
|
||||||
output = json.loads(result.stdout)
|
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 [item["id"] for item in output["items"]] == [1, 2]
|
||||||
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
|
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user