Skip to content

Commit d594e41

Browse files
guidooswaldDBclaude
andcommitted
Add Load All button for paginated APIs with has_more
Adds a Load All button that appears when an API response contains has_more: true (e.g., List Jobs). Fetches all remaining pages synchronously in a single callback using next_page_token or offset pagination, then merges results. Includes status bar showing progress. Also fixes page-ticker to start disabled, preventing interval flooding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6e9aad8 commit d594e41

3 files changed

Lines changed: 162 additions & 24 deletions

File tree

app.py

Lines changed: 127 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -503,18 +503,19 @@ def build_response_panel(result: Dict[str, Any], chips: Optional[List] = None) -
503503
title=action["title"],
504504
))
505505
sp_items.append(html.Div(item_children, className="sp-item"))
506+
header_children = [
507+
html.Button(
508+
html.I(className="bi bi-layout-sidebar-reverse"),
509+
id="sp-toggle-btn",
510+
className="sp-toggle",
511+
n_clicks=0,
512+
title="Collapse/expand panel",
513+
),
514+
html.Span(f"{len(chips)} items", className="sp-header-text"),
515+
]
506516
side_panel = html.Div([
507517
html.Div(className="sp-resize-handle"),
508-
html.Div([
509-
html.Button(
510-
html.I(className="bi bi-layout-sidebar-reverse"),
511-
id="sp-toggle-btn",
512-
className="sp-toggle",
513-
n_clicks=0,
514-
title="Collapse/expand panel",
515-
),
516-
html.Span(f"{len(chips)} items", className="sp-header-text"),
517-
], className="sp-header"),
518+
html.Div(header_children, className="sp-header"),
518519
html.Div(sp_items, className="sp-list"),
519520
], id="side-panel", className="side-panel")
520521
else:
@@ -930,7 +931,7 @@ def _custom_section():
930931
dcc.Store(id="curl-dummy", data=None), # dummy output for curl copy clientside CB
931932
dcc.Store(id="sso-pending", data=None), # {"host": "..."} while browser OAuth is running
932933
dcc.Interval(id="sso-poller", interval=1000, disabled=True, n_intervals=0),
933-
dcc.Interval(id="page-ticker", interval=500, disabled=False, n_intervals=0),
934+
dcc.Interval(id="page-ticker", interval=500, disabled=True, n_intervals=0),
934935

935936
dcc.Store(id="dropdown-open", data=False), # tracks dropdown visibility
936937
dcc.Store(id="response-cache", data={}), # {endpoint_id: {result, chips}} — cached API responses
@@ -946,7 +947,17 @@ def _custom_section():
946947
html.Div([
947948
html.Div(WELCOME, id="endpoint-detail", className="form-panel"),
948949
html.Div([
949-
html.Div(id="fetch-status-bar", className="fetch-status-bar"),
950+
html.Div([
951+
html.Div(id="fetch-status-bar", className="fetch-status-bar"),
952+
html.Button(
953+
[html.I(className="bi bi-cloud-download me-1"), "Load All"],
954+
id="sp-load-all-btn",
955+
n_clicks=0,
956+
className="sp-load-all-btn",
957+
title="Fetch all remaining pages",
958+
style={"display": "none"},
959+
),
960+
], className="fetch-bar-row"),
950961
html.Div(_RESPONSE_EMPTY, id="response-container", className="response-container"),
951962
], className="response-panel"),
952963
], className="main-content"),
@@ -1436,6 +1447,7 @@ def restore_cached_response(endpoint, cache):
14361447
Output("spinner-off", "data"),
14371448
Output("chips-store", "data"),
14381449
Output("response-cache", "data", allow_duplicate=True),
1450+
Output("sp-load-all-btn", "style"),
14391451
Input("execute-btn", "n_clicks"),
14401452
State("selected-endpoint", "data"),
14411453
State({"type": "param-input", "name": ALL}, "value"),
@@ -1447,7 +1459,7 @@ def restore_cached_response(endpoint, cache):
14471459
)
14481460
def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, conn_config, cache):
14491461
if not n_clicks or not endpoint:
1450-
return no_update, no_update, no_update, no_update, no_update
1462+
return no_update, no_update, no_update, no_update, no_update, no_update
14511463

14521464
params: Dict[str, Any] = {}
14531465
ep_param_map = {p["name"]: p for p in endpoint.get("params", [])}
@@ -1467,7 +1479,7 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14671479
for pp in endpoint.get("path_params", []):
14681480
val = params.pop(pp, "")
14691481
if not val:
1470-
return build_error_panel(f"Path parameter '{pp}' is required."), no_update, time.time(), None, no_update
1482+
return build_error_panel(f"Path parameter '{pp}' is required."), no_update, time.time(), None, no_update, {"display": "none"}
14711483
path = path.replace(f"{{{pp}}}", str(val))
14721484

14731485
method = endpoint.get("method", "GET")
@@ -1476,19 +1488,21 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14761488
try:
14771489
body = json.loads(body_text)
14781490
except json.JSONDecodeError as e:
1479-
return build_error_panel(f"Invalid JSON body: {e}"), no_update, time.time(), None, no_update
1491+
return build_error_panel(f"Invalid JSON body: {e}"), no_update, time.time(), None, no_update, {"display": "none"}
14801492

14811493
host, token = _resolve_conn(conn_config)
14821494
if not token:
1483-
return build_error_panel("No auth token. Configure a connection in the user menu."), no_update, time.time(), None, no_update
1495+
return build_error_panel("No auth token. Configure a connection in the user menu."), no_update, time.time(), None, no_update, {"display": "none"}
14841496
if not host:
1485-
return build_error_panel("No workspace host. Configure a connection in the user menu."), no_update, time.time(), None, no_update
1497+
return build_error_panel("No workspace host. Configure a connection in the user menu."), no_update, time.time(), None, no_update, {"display": "none"}
14861498

14871499
result = make_api_call(
14881500
method=method, path=path, token=token, host=host,
14891501
query_params=params if method == "GET" else None, body=body,
14901502
)
14911503
chips = extract_chips(endpoint.get("id", ""), result["data"]) if result["success"] else []
1504+
resp_data = result["data"] if isinstance(result["data"], dict) else {}
1505+
has_more = bool(resp_data.get("has_more"))
14921506
last_req = {
14931507
"path": path,
14941508
"method": method,
@@ -1503,23 +1517,29 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
15031517
ep_id = endpoint.get("id", "")
15041518
new_cache = dict(cache or {})
15051519
new_cache[ep_id] = {"result": result, "chips": chips or None}
1506-
return build_response_panel(result, chips), last_req, time.time(), chips or None, new_cache
1520+
btn_style = {"display": "inline-flex"} if has_more else {"display": "none"}
1521+
return build_response_panel(result, chips), last_req, time.time(), chips or None, new_cache, btn_style
15071522

15081523

15091524
# 11. Signal tick_fetch to start a new pagination run whenever execute fires.
15101525
# Writes to page-trigger (separate store) so tick_fetch stays the sole
15111526
# primary writer of page-state — ensuring sync_status_bar fires on every tick.
15121527
@app.callback(
15131528
Output("page-trigger", "data"),
1529+
Output("page-ticker", "disabled"),
15141530
Input("last-request", "data"),
15151531
prevent_initial_call=True,
15161532
)
15171533
def start_pagination(last_req):
15181534
if not last_req:
1519-
return {}
1535+
return {}, True
15201536
initial_data = last_req.get("initial_data", {})
1537+
# Skip auto-pagination when has_more is present — "Load All" handles those
1538+
if isinstance(initial_data, dict) and initial_data.get("has_more"):
1539+
return {"run_id": time.time()}, True
15211540
next_token = _detect_next_page_token(initial_data)
15221541
list_key = _find_list_key(initial_data) if next_token else None
1542+
should_paginate = next_token is not None
15231543
return {
15241544
"run_id": time.time(),
15251545
"next_token": next_token,
@@ -1533,7 +1553,7 @@ def start_pagination(last_req):
15331553
"method": last_req.get("method", "GET"),
15341554
"query_params": last_req.get("query_params"),
15351555
"body": last_req.get("body"),
1536-
}
1556+
}, not should_paginate
15371557

15381558

15391559
# 11b. PRIMARY writer of page-state — no allow_duplicate, so sync_status_bar
@@ -1680,14 +1700,15 @@ def sync_status_bar(page_state):
16801700
@app.callback(
16811701
Output("response-container", "children", allow_duplicate=True),
16821702
Output("response-cache", "data", allow_duplicate=True),
1703+
Output("page-ticker", "disabled", allow_duplicate=True),
16831704
Input("page-state", "data"),
16841705
State("response-cache", "data"),
16851706
prevent_initial_call=True,
16861707
)
16871708
def render_when_done(page_state, cache):
16881709
state = page_state or {}
16891710
if state.get("running") or not state.get("items"):
1690-
return no_update, no_update
1711+
return no_update, no_update, no_update
16911712
initial_data = state.get("initial_data", {})
16921713
list_key = state.get("list_key")
16931714
if not list_key:
@@ -1708,7 +1729,7 @@ def render_when_done(page_state, cache):
17081729
new_cache = dict(cache or {})
17091730
if ep_id:
17101731
new_cache[ep_id] = {"result": merged_result, "chips": chips or None}
1711-
return build_response_panel(merged_result, chips), new_cache
1732+
return build_response_panel(merged_result, chips), new_cache, True
17121733

17131734

17141735
# 11g. Abort button — cancel in-flight pagination
@@ -1725,6 +1746,90 @@ def abort_pagination(n_clicks, page_state):
17251746
return {**state, "running": False, "error": "Cancelled"}
17261747

17271748

1749+
# 11h. "Load All" — fetch all offset-paginated pages in one synchronous callback
1750+
@app.callback(
1751+
Output("response-container", "children", allow_duplicate=True),
1752+
Output("response-cache", "data", allow_duplicate=True),
1753+
Output("chips-store", "data", allow_duplicate=True),
1754+
Output("fetch-status-bar", "children", allow_duplicate=True),
1755+
Output("sp-load-all-btn", "style", allow_duplicate=True),
1756+
Input("sp-load-all-btn", "n_clicks"),
1757+
State("last-request", "data"),
1758+
State("conn-config", "data"),
1759+
State("response-cache", "data"),
1760+
prevent_initial_call=True,
1761+
)
1762+
def load_all_pages(n_clicks, last_req, conn_config, cache):
1763+
NO = (no_update, no_update, no_update, no_update, no_update)
1764+
if not n_clicks or not last_req:
1765+
return NO
1766+
initial_data = last_req.get("initial_data", {})
1767+
if not isinstance(initial_data, dict) or not initial_data.get("has_more"):
1768+
return NO
1769+
list_key = _find_list_key(initial_data)
1770+
if not list_key:
1771+
return NO
1772+
1773+
host, token = _resolve_conn(conn_config)
1774+
if not token or not host:
1775+
return NO
1776+
1777+
items = list(initial_data.get(list_key, []))
1778+
limit = len(items) or 25
1779+
offset = len(items)
1780+
next_token = _detect_next_page_token(initial_data)
1781+
use_token = next_token is not None
1782+
total_elapsed = last_req.get("elapsed_ms", 0)
1783+
pages = 1
1784+
1785+
while pages < 200:
1786+
qp = dict(last_req.get("query_params") or {})
1787+
if use_token and next_token:
1788+
qp["page_token"] = next_token
1789+
else:
1790+
qp["offset"] = str(offset)
1791+
qp["limit"] = str(limit)
1792+
1793+
t0 = time.perf_counter()
1794+
r = make_api_call(last_req["method"], last_req["path"], token, host, query_params=qp, body=last_req.get("body"))
1795+
total_elapsed += int((time.perf_counter() - t0) * 1000)
1796+
1797+
if not r["success"]:
1798+
break
1799+
1800+
page_data = r["data"]
1801+
new_page_items = page_data.get(list_key, [])
1802+
items.extend(new_page_items)
1803+
pages += 1
1804+
offset += limit
1805+
next_token = _detect_next_page_token(page_data)
1806+
1807+
if not page_data.get("has_more"):
1808+
break
1809+
1810+
merged_data = {**initial_data, list_key: items}
1811+
merged_data.pop("has_more", None)
1812+
merged_result = {
1813+
"status_code": last_req.get("status_code", 200),
1814+
"elapsed_ms": total_elapsed,
1815+
"data": merged_data,
1816+
"success": True, "error": None,
1817+
"url": last_req.get("url", ""),
1818+
}
1819+
chips = extract_chips(last_req.get("endpoint_id", ""), merged_data)
1820+
ep_id = last_req.get("endpoint_id", "")
1821+
new_cache = dict(cache or {})
1822+
if ep_id:
1823+
new_cache[ep_id] = {"result": merged_result, "chips": chips or None}
1824+
1825+
status = html.Div([
1826+
html.I(className="bi bi-check-circle-fill me-2"),
1827+
f"All pages loaded — {len(items):,} items · {pages} pages · {total_elapsed}ms",
1828+
], className="fetch-status-inner done")
1829+
1830+
return build_response_panel(merged_result, chips), new_cache, chips or None, status, {"display": "none"}
1831+
1832+
17281833
# 13. Search filter
17291834
@app.callback(
17301835
Output({"type": "endpoint-btn", "id": ALL}, "style"),

assets/style.css

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,38 @@ body {
613613
border-radius: 3px;
614614
}
615615
.sp-toggle:hover { color: var(--cyan); background: rgba(0,212,255,0.08); }
616+
.sp-load-all-btn {
617+
background: rgba(0,212,255,0.1);
618+
border: 1px solid rgba(0,212,255,0.25);
619+
color: var(--cyan);
620+
cursor: pointer;
621+
padding: 1px 8px;
622+
font-size: 10px;
623+
font-family: var(--font-mono);
624+
border-radius: 3px;
625+
white-space: nowrap;
626+
flex-shrink: 0;
627+
transition: all 0.15s;
628+
}
629+
.sp-load-all-btn:hover {
630+
background: rgba(0,212,255,0.2);
631+
border-color: rgba(0,212,255,0.45);
632+
}
633+
.side-panel.sp-collapsed .sp-load-all-btn { display: none; }
634+
.sp-load-progress { flex-shrink: 0; }
635+
.sp-load-progress-active {
636+
padding: 6px 10px;
637+
border-bottom: 1px solid var(--border);
638+
}
639+
.sp-progress-text {
640+
font-size: 10px;
641+
color: var(--cyan);
642+
font-family: var(--font-mono);
643+
display: flex;
644+
align-items: center;
645+
margin-bottom: 4px;
646+
}
647+
.sp-progress-bar { margin: 0; }
616648
.sp-list {
617649
overflow-y: auto;
618650
flex: 1;
@@ -683,7 +715,8 @@ body {
683715

684716

685717
/* ── Pagination status bar ────────────────────────────────────────── */
686-
.fetch-status-bar { flex-shrink: 0; overflow: hidden; }
718+
.fetch-bar-row { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
719+
.fetch-status-bar { flex: 1; overflow: hidden; }
687720
.fetch-status-bar:empty { display: none; }
688721

689722
.fetch-status-inner {

version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
309
1+
364

0 commit comments

Comments
 (0)