@@ -139,13 +139,13 @@ def _find_list_key(data: dict) -> Optional[str]:
139139_TREE_JS = r"""
140140var currentDepth=INITIAL_DEPTH;
141141function mkEl(tag,cls,txt){var e=document.createElement(tag);if(cls)e.className=cls;if(txt!==undefined)e.textContent=txt;return e;}
142- function idLink(cls,display,gid,par,val){var e=mkEl('span','id-link '+cls,display);e.dataset.gid=gid;e.dataset.par=par;e.dataset.val=val;e. onclick=function(){window.parent.postMessage( {type:'id-link',gid:e.dataset.gid,par:e.dataset.par,val:e.dataset.val},'*');};return e;}
142+ function idLink(cls,display,gid,par,val,ext ){var e=mkEl('span','id-link '+cls,display);e.dataset.gid=gid;e.dataset.par=par;e.dataset.val=val;if(ext)e.dataset.ext=JSON.stringify(ext);e. onclick=function(){var msg= {type:'id-link',gid:e.dataset.gid,par:e.dataset.par,val:e.dataset.val};if(e.dataset.ext)msg.ext=JSON.parse(e.dataset.ext);window.parent.postMessage(msg ,'*');};return e;}
143143function renderValue(val,pKey,depth){
144144 if(val===null)return mkEl('span','jbn','null');
145145 var t=typeof val;
146146 if(t==='boolean')return mkEl('span','jb',String(val));
147- if(t==='number'){var c=pKey&&LOOKUP[pKey]&&LOOKUP[pKey][String(val)];return c?idLink('jn',String(val),c.gid,c.par,String(val)):mkEl('span','jn',String(val));}
148- if(t==='string'){var c2=pKey&&LOOKUP[pKey]&&LOOKUP[pKey][val];return c2?idLink('jv','"'+val+'"',c2.gid,c2.par,val):mkEl('span','jv','"'+val+'"');}
147+ if(t==='number'){var c=pKey&&LOOKUP[pKey]&&LOOKUP[pKey][String(val)];return c?idLink('jn',String(val),c.gid,c.par,String(val),c.ext ):mkEl('span','jn',String(val));}
148+ if(t==='string'){var c2=pKey&&LOOKUP[pKey]&&LOOKUP[pKey][val];return c2?idLink('jv','"'+val+'"',c2.gid,c2.par,val,c2.ext ):mkEl('span','jv','"'+val+'"');}
149149 if(Array.isArray(val))return renderArray(val,depth);
150150 if(t==='object')return renderObject(val,depth);
151151 return mkEl('span','jbn',String(val));
@@ -256,10 +256,13 @@ def _build_json_tree_html(data: Any, chips: Optional[List[Dict]] = None) -> str:
256256 field = chip ["id_field" ]
257257 if field not in link_lookup :
258258 link_lookup [field ] = {}
259- link_lookup [ field ][ str ( chip [ "value" ]) ] = {
259+ entry : Dict [ str , Any ] = {
260260 "gid" : chip ["get_id" ],
261261 "par" : chip ["param" ],
262262 }
263+ if chip .get ("extras" ):
264+ entry ["ext" ] = chip ["extras" ]
265+ link_lookup [field ][str (chip ["value" ])] = entry
263266 data_js = json .dumps (data , ensure_ascii = False , separators = ("," , ":" )).replace ("</script>" , r"<\/script>" )
264267 lookup_js = json .dumps (link_lookup , ensure_ascii = False ).replace ("</script>" , r"<\/script>" )
265268 return "" .join ([
@@ -479,16 +482,27 @@ def build_response_panel(result: Dict[str, Any], chips: Optional[List] = None) -
479482 has_label = chip ["label" ] != chip ["title" ]
480483 name_text = chip ["label" ]
481484 id_text = chip ["title" ] if has_label else ""
482- item_children = [html .Span (name_text , className = "sp-name" )]
485+ text_children = [html .Span (name_text , className = "sp-name" )]
483486 if id_text :
484- item_children .append (html .Span (id_text , className = "sp-id" ))
485- sp_items .append (html .Button (
486- item_children ,
487- id = {"type" : "sp-item" , "index" : i },
488- n_clicks = 0 ,
489- className = "sp-item" ,
490- title = f"{ chip ['label' ]} · { chip ['title' ]} " ,
491- ))
487+ text_children .append (html .Span (id_text , className = "sp-id" ))
488+ item_children = [
489+ html .Button (
490+ text_children ,
491+ id = {"type" : "sp-item" , "index" : i },
492+ n_clicks = 0 ,
493+ className = "sp-item-main" ,
494+ title = f"{ chip ['label' ]} · { chip ['title' ]} " ,
495+ ),
496+ ]
497+ for j , action in enumerate (chip .get ("actions" ) or []):
498+ item_children .append (html .Button (
499+ html .I (className = f"bi { action ['icon' ]} " ),
500+ id = {"type" : "sp-action" , "index" : i , "action" : j },
501+ n_clicks = 0 ,
502+ className = "sp-action-btn" ,
503+ title = action ["title" ],
504+ ))
505+ sp_items .append (html .Div (item_children , className = "sp-item" ))
492506 side_panel = html .Div ([
493507 html .Div (className = "sp-resize-handle" ),
494508 html .Div ([
@@ -909,6 +923,7 @@ def _custom_section():
909923 dcc .Interval (id = "page-ticker" , interval = 500 , disabled = False , n_intervals = 0 ),
910924
911925 dcc .Store (id = "dropdown-open" , data = False ), # tracks dropdown visibility
926+ dcc .Store (id = "response-cache" , data = {}), # {endpoint_id: {result, chips}} — cached API responses
912927
913928 TOPBAR ,
914929 _DROPDOWN_OVERLAY ,
@@ -1376,23 +1391,45 @@ def render_endpoint_detail(endpoint: Optional[Dict]):
13761391 return content , "form-panel"
13771392
13781393
1394+ # 9b. Restore cached response when switching endpoints via sidebar
1395+ @app .callback (
1396+ Output ("response-container" , "children" , allow_duplicate = True ),
1397+ Output ("chips-store" , "data" , allow_duplicate = True ),
1398+ Input ("selected-endpoint" , "data" ),
1399+ State ("response-cache" , "data" ),
1400+ prevent_initial_call = True ,
1401+ )
1402+ def restore_cached_response (endpoint , cache ):
1403+ if not endpoint :
1404+ return _RESPONSE_EMPTY , None
1405+ ep_id = endpoint .get ("id" , "" )
1406+ cached = (cache or {}).get (ep_id )
1407+ if not cached :
1408+ return _RESPONSE_EMPTY , None
1409+ result = cached ["result" ]
1410+ chips = cached .get ("chips" )
1411+ return build_response_panel (result , chips ), chips
1412+
1413+
13791414# 10. Execute API call
13801415@app .callback (
13811416 Output ("response-container" , "children" ),
13821417 Output ("last-request" , "data" ),
13831418 Output ("spinner-off" , "data" ),
13841419 Output ("chips-store" , "data" ),
1420+ Output ("response-cache" , "data" , allow_duplicate = True ),
13851421 Input ("execute-btn" , "n_clicks" ),
13861422 State ("selected-endpoint" , "data" ),
13871423 State ({"type" : "param-input" , "name" : ALL }, "value" ),
13881424 State ({"type" : "param-input" , "name" : ALL }, "id" ),
13891425 State ("body-textarea" , "value" ),
13901426 State ("conn-config" , "data" ),
1427+ State ("response-cache" , "data" ),
13911428 prevent_initial_call = True ,
13921429)
1393- def execute_api_call (n_clicks , endpoint , param_values , param_ids , body_text , conn_config ):
1430+ def execute_api_call (n_clicks , endpoint , param_values , param_ids , body_text , conn_config , cache ):
13941431 if not n_clicks or not endpoint :
1395- return no_update , no_update , no_update , no_update
1432+ return no_update , no_update , no_update , no_update , no_update
13961433
13971434 params : Dict [str , Any ] = {}
13981435 ep_param_map = {p ["name" ]: p for p in endpoint .get ("params" , [])}
@@ -1412,7 +1449,7 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14121449 for pp in endpoint .get ("path_params" , []):
14131450 val = params .pop (pp , "" )
14141451 if not val :
1415- return build_error_panel (f"Path parameter '{ pp } ' is required." ), no_update , time .time (), None
1452+ return build_error_panel (f"Path parameter '{ pp } ' is required." ), no_update , time .time (), None , no_update
14161453 path = path .replace (f"{{{ pp } }}" , str (val ))
14171454
14181455 method = endpoint .get ("method" , "GET" )
@@ -1421,13 +1458,13 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14211458 try :
14221459 body = json .loads (body_text )
14231460 except json .JSONDecodeError as e :
1424- return build_error_panel (f"Invalid JSON body: { e } " ), no_update , time .time (), None
1461+ return build_error_panel (f"Invalid JSON body: { e } " ), no_update , time .time (), None , no_update
14251462
14261463 host , token = _resolve_conn (conn_config )
14271464 if not token :
1428- return build_error_panel ("No auth token. Configure a connection in the user menu." ), no_update , time .time (), None
1465+ return build_error_panel ("No auth token. Configure a connection in the user menu." ), no_update , time .time (), None , no_update
14291466 if not host :
1430- return build_error_panel ("No workspace host. Configure a connection in the user menu." ), no_update , time .time (), None
1467+ return build_error_panel ("No workspace host. Configure a connection in the user menu." ), no_update , time .time (), None , no_update
14311468
14321469 result = make_api_call (
14331470 method = method , path = path , token = token , host = host ,
@@ -1445,7 +1482,10 @@ def execute_api_call(n_clicks, endpoint, param_values, param_ids, body_text, con
14451482 "status_code" : result ["status_code" ],
14461483 "url" : result .get ("url" , "" ),
14471484 }
1448- return build_response_panel (result , chips ), last_req , time .time (), chips or None
1485+ ep_id = endpoint .get ("id" , "" )
1486+ new_cache = dict (cache or {})
1487+ new_cache [ep_id ] = {"result" : result , "chips" : chips or None }
1488+ return build_response_panel (result , chips ), last_req , time .time (), chips or None , new_cache
14491489
14501490
14511491# 11. Signal tick_fetch to start a new pagination run whenever execute fires.
@@ -1621,17 +1661,19 @@ def sync_status_bar(page_state):
16211661# 11c. Render final merged result when pagination completes
16221662@app .callback (
16231663 Output ("response-container" , "children" , allow_duplicate = True ),
1664+ Output ("response-cache" , "data" , allow_duplicate = True ),
16241665 Input ("page-state" , "data" ),
1666+ State ("response-cache" , "data" ),
16251667 prevent_initial_call = True ,
16261668)
1627- def render_when_done (page_state ):
1669+ def render_when_done (page_state , cache ):
16281670 state = page_state or {}
16291671 if state .get ("running" ) or not state .get ("items" ):
1630- return no_update
1672+ return no_update , no_update
16311673 initial_data = state .get ("initial_data" , {})
16321674 list_key = state .get ("list_key" )
16331675 if not list_key :
1634- return no_update
1676+ return no_update , no_update
16351677 merged_data = {** initial_data , list_key : state ["items" ]}
16361678 merged_data .pop ("next_page_token" , None )
16371679 merged_data .pop ("has_more" , None )
@@ -1644,7 +1686,11 @@ def render_when_done(page_state):
16441686 "url" : state .get ("url" , "" ),
16451687 }
16461688 chips = extract_chips (state .get ("endpoint_id" , "" ), merged_data )
1647- return build_response_panel (merged_result , chips )
1689+ ep_id = state .get ("endpoint_id" , "" )
1690+ new_cache = dict (cache or {})
1691+ if ep_id :
1692+ new_cache [ep_id ] = {"result" : merged_result , "chips" : chips or None }
1693+ return build_response_panel (merged_result , chips ), new_cache
16481694
16491695
16501696# 11g. Abort button — cancel in-flight pagination
@@ -1734,43 +1780,51 @@ def filter_endpoints(query, btn_ids):
17341780@app .callback (
17351781 Output ("selected-endpoint" , "data" , allow_duplicate = True ),
17361782 Output ("response-container" , "children" , allow_duplicate = True ),
1783+ Output ("response-cache" , "data" , allow_duplicate = True ),
17371784 Input ("iframe-link-click" , "data" ),
17381785 State ("conn-config" , "data" ),
1786+ State ("response-cache" , "data" ),
17391787 prevent_initial_call = True ,
17401788)
1741- def handle_iframe_link_click (link_data , conn_config ):
1789+ def handle_iframe_link_click (link_data , conn_config , cache ):
17421790 if not link_data :
1743- return no_update , no_update
1791+ return no_update , no_update , no_update
17441792 get_id = link_data .get ("gid" , "" )
17451793 param_name = link_data .get ("par" , "" )
17461794 value = link_data .get ("val" , "" )
17471795 if not get_id or not param_name or value == "" :
1748- return no_update , no_update
1796+ return no_update , no_update , no_update
17491797
17501798 endpoint = get_endpoint_by_id (get_id )
17511799 if not endpoint :
1752- return no_update , no_update
1800+ return no_update , no_update , no_update
17531801
1802+ extras = link_data .get ("ext" ) or {}
17541803 path = endpoint ["path" ]
17551804 query_params : Dict [str , Any ] = {}
1756- if param_name in endpoint .get ("path_params" , []):
1757- path = path .replace (f"{{{ param_name } }}" , value )
1758- else :
1759- query_params [param_name ] = value
1805+ prefill = {param_name : value , ** extras }
1806+ for k , v in {param_name : value , ** extras }.items ():
1807+ if k in endpoint .get ("path_params" , []):
1808+ path = path .replace (f"{{{ k } }}" , v )
1809+ else :
1810+ query_params [k ] = v
17601811
17611812 host , token = _resolve_conn (conn_config )
17621813 if not token :
1763- return no_update , build_error_panel ("No auth token." )
1814+ return no_update , build_error_panel ("No auth token." ), no_update
17641815 if not host :
1765- return no_update , build_error_panel ("No workspace host." )
1816+ return no_update , build_error_panel ("No workspace host." ), no_update
17661817
17671818 result = make_api_call (
17681819 method = endpoint .get ("method" , "GET" ),
17691820 path = path , token = token , host = host ,
17701821 query_params = query_params or None ,
17711822 )
1772- endpoint_with_prefill = {** endpoint , "_prefill" : {param_name : value }}
1773- return endpoint_with_prefill , build_response_panel (result )
1823+ chips = extract_chips (get_id , result ["data" ]) if result ["success" ] else []
1824+ endpoint_with_prefill = {** endpoint , "_prefill" : prefill }
1825+ new_cache = dict (cache or {})
1826+ new_cache [get_id ] = {"result" : result , "chips" : chips or None }
1827+ return endpoint_with_prefill , build_response_panel (result , chips ), new_cache
17741828
17751829
17761830# 17. Side-panel toggle — pure JS, no server round-trip
@@ -1808,7 +1862,44 @@ def handle_sp_item_click(n_clicks_list, chips_data):
18081862 if idx >= len (chips_data ):
18091863 return no_update
18101864 chip = chips_data [idx ]
1811- return {"type" : "id-link" , "gid" : chip ["get_id" ], "par" : chip ["param" ], "val" : chip ["value" ]}
1865+ msg = {"type" : "id-link" , "gid" : chip ["get_id" ], "par" : chip ["param" ], "val" : chip ["value" ]}
1866+ if chip .get ("extras" ):
1867+ msg ["ext" ] = chip ["extras" ]
1868+ return msg
1869+
1870+
1871+ # 18b. Side-panel action button click → navigate to action endpoint
1872+ @app .callback (
1873+ Output ("iframe-link-click" , "data" , allow_duplicate = True ),
1874+ Input ({"type" : "sp-action" , "index" : ALL , "action" : ALL }, "n_clicks" ),
1875+ State ("chips-store" , "data" ),
1876+ prevent_initial_call = True ,
1877+ )
1878+ def handle_sp_action_click (n_clicks_list , chips_data ):
1879+ from dash import callback_context
1880+ if not callback_context .triggered or not chips_data :
1881+ return no_update
1882+ triggered = callback_context .triggered [0 ]
1883+ if not triggered ["value" ]:
1884+ return no_update
1885+ tid = json .loads (triggered ["prop_id" ].split ("." )[0 ])
1886+ idx , action_idx = tid ["index" ], tid ["action" ]
1887+ if idx >= len (chips_data ):
1888+ return no_update
1889+ chip = chips_data [idx ]
1890+ actions = chip .get ("actions" ) or []
1891+ if action_idx >= len (actions ):
1892+ return no_update
1893+ action = actions [action_idx ]
1894+ # The action's params dict has all params needed; pick the first as the primary par/val
1895+ params = action ["params" ]
1896+ first_key = next (iter (params ), "" )
1897+ first_val = params .get (first_key , "" )
1898+ ext = {k : v for k , v in params .items () if k != first_key }
1899+ msg = {"type" : "id-link" , "gid" : action ["gid" ], "par" : first_key , "val" : first_val }
1900+ if ext :
1901+ msg ["ext" ] = ext
1902+ return msg
18121903
18131904
18141905# 19. Build curl command below Execute button whenever a request completes
0 commit comments