diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c4a8fff..3d7f425e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,8 @@ "editor.formatOnSave": true, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/eval_protocol/pytest/evaluation_test.py b/eval_protocol/pytest/evaluation_test.py index e51d008b..2e1254d6 100644 --- a/eval_protocol/pytest/evaluation_test.py +++ b/eval_protocol/pytest/evaluation_test.py @@ -59,6 +59,7 @@ parse_ep_passed_threshold, rollout_processor_with_retry, ) +from eval_protocol.utils.show_results_url import show_results_url from ..common_utils import load_jsonl @@ -555,6 +556,9 @@ async def execute_run_with_progress(run_idx: int, config): experiment_duration_seconds, ) + # Show URL for viewing results (after all postprocessing is complete) + show_results_url(invocation_id) + except AssertionError: _log_eval_error( Status.eval_finished(), diff --git a/eval_protocol/utils/check_server_status.py b/eval_protocol/utils/check_server_status.py new file mode 100644 index 00000000..2704ce1e --- /dev/null +++ b/eval_protocol/utils/check_server_status.py @@ -0,0 +1,77 @@ +""" +Utility functions for checking server status and generating UI URLs. +""" + +import socket +import urllib.parse +from typing import List, Dict, Any + + +def is_server_running(host: str = "localhost", port: int = 8000) -> bool: + """ + Check if a server is running on the specified host and port. + + Args: + host: The host to check (default: "localhost") + port: The port to check (default: 8000) + + Returns: + True if server is running, False otherwise + """ + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + result = s.connect_ex((host, port)) + return result == 0 + except Exception: + return False + + +def generate_invocation_filter_url(invocation_id: str, base_url: str = "http://localhost:8000") -> str: + """ + Generate a URL for viewing results filtered by invocation_id. + + Args: + invocation_id: The invocation ID to filter results by + base_url: The base URL for the UI (default: "http://localhost:8000") + + Returns: + URL-encoded URL with filter configuration + """ + filter_config = [ + { + "logic": "AND", + "filters": [ + { + "field": "$.execution_metadata.invocation_id", + "operator": "equals", + "value": invocation_id, + "type": "text", + } + ], + } + ] + + # URL encode the filter config + filter_config_json = str(filter_config).replace("'", '"') + encoded_filter = urllib.parse.quote(filter_config_json) + + return f"{base_url}/pivot?filterConfig={encoded_filter}" + + +def show_results_url(invocation_id: str) -> None: + """ + Show a URL for viewing evaluation results filtered by invocation_id. + + If the server is not running, prints a message to run "ep logs" to start the local UI. + If the server is running, prints a URL to view results filtered by invocation_id. + + Args: + invocation_id: The invocation ID to filter results by + """ + if is_server_running(): + url = generate_invocation_filter_url(invocation_id) + print(f"View your evaluation results: {url}") + else: + url = generate_invocation_filter_url(invocation_id) + print(f"Start the local UI with 'ep logs', then visit: {url}") diff --git a/eval_protocol/utils/show_results_url.py b/eval_protocol/utils/show_results_url.py new file mode 100644 index 00000000..409ac858 --- /dev/null +++ b/eval_protocol/utils/show_results_url.py @@ -0,0 +1,82 @@ +""" +Utility functions for showing evaluation results URLs and checking server status. +""" + +import socket +import urllib.parse + + +def is_server_running(host: str = "localhost", port: int = 8000) -> bool: + """ + Check if a server is running on the specified host and port. + + Args: + host: The host to check (default: "localhost") + port: The port to check (default: 8000) + + Returns: + True if server is running, False otherwise + """ + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + result = s.connect_ex((host, port)) + return result == 0 + except Exception: + return False + + +def generate_invocation_filter_url(invocation_id: str, base_url: str = "http://localhost:8000") -> str: + """ + Generate a URL for viewing results filtered by invocation_id. + + Args: + invocation_id: The invocation ID to filter results by + base_url: The base URL for the UI (default: "http://localhost:8000") + + Returns: + URL-encoded URL with filter configuration + """ + filter_config = [ + { + "logic": "AND", + "filters": [ + { + "field": "$.execution_metadata.invocation_id", + "operator": "==", + "value": invocation_id, + "type": "text", + } + ], + } + ] + + # URL encode the filter config + filter_config_json = str(filter_config).replace("'", '"') + encoded_filter = urllib.parse.quote(filter_config_json) + + return f"{base_url}?filterConfig={encoded_filter}" + + +def show_results_url(invocation_id: str) -> None: + """ + Show URLs for viewing evaluation results filtered by invocation_id. + + If the server is not running, prints a message to run "ep logs" to start the local UI. + If the server is running, prints URLs to view results filtered by invocation_id. + + Args: + invocation_id: The invocation ID to filter results by + """ + if is_server_running(): + pivot_url = generate_invocation_filter_url(invocation_id, "http://localhost:8000/pivot") + table_url = generate_invocation_filter_url(invocation_id, "http://localhost:8000/table") + print("View your evaluation results:") + print(f" 📊 Aggregate scores: {pivot_url}") + print(f" 📋 Trajectories: {table_url}") + else: + pivot_url = generate_invocation_filter_url(invocation_id, "http://localhost:8000/pivot") + table_url = generate_invocation_filter_url(invocation_id, "http://localhost:8000/table") + print("Start the local UI with 'ep logs', then visit:") + print(f" 📊 Aggregate scores: {pivot_url}") + print(f" 📋 Trajectories: {table_url}") diff --git a/pytest.ini b/pytest.ini index cd7f77df..b3c84ce1 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,8 +5,6 @@ asyncio_mode = auto asyncio_default_fixture_loop_scope = function testpaths = tests ./eval_protocol/quickstart python_files = test_*.py llm_judge_*.py -plugins = - eval_protocol.pytest.plugin python_classes = Test* python_functions = test_* # Configure stdout/stderr capture for debugging diff --git a/tests/test_show_results_url.py b/tests/test_show_results_url.py new file mode 100644 index 00000000..c840c4fc --- /dev/null +++ b/tests/test_show_results_url.py @@ -0,0 +1,246 @@ +""" +Tests for eval_protocol.utils.show_results_url module. +""" + +import socket +from unittest.mock import patch, MagicMock +import pytest + +from eval_protocol.utils.show_results_url import ( + is_server_running, + generate_invocation_filter_url, + show_results_url, +) + + +class TestIsServerRunning: + """Test the is_server_running function.""" + + @patch("socket.socket") + def test_server_running(self, mock_socket): + """Test when server is running.""" + # Mock successful connection + mock_socket_instance = MagicMock() + mock_socket_instance.connect_ex.return_value = 0 + mock_socket.return_value.__enter__.return_value = mock_socket_instance + + result = is_server_running("localhost", 8000) + assert result is True + mock_socket_instance.connect_ex.assert_called_once_with(("localhost", 8000)) + + @patch("socket.socket") + def test_server_not_running(self, mock_socket): + """Test when server is not running.""" + # Mock failed connection + mock_socket_instance = MagicMock() + mock_socket_instance.connect_ex.return_value = 1 + mock_socket.return_value.__enter__.return_value = mock_socket_instance + + result = is_server_running("localhost", 8000) + assert result is False + + @patch("socket.socket") + def test_connection_exception(self, mock_socket): + """Test when connection raises an exception.""" + # Mock connection exception + mock_socket.side_effect = Exception("Connection failed") + + result = is_server_running("localhost", 8000) + assert result is False + + def test_default_parameters(self): + """Test with default parameters.""" + with patch("socket.socket") as mock_socket: + mock_socket_instance = MagicMock() + mock_socket_instance.connect_ex.return_value = 0 + mock_socket.return_value.__enter__.return_value = mock_socket_instance + + result = is_server_running() + assert result is True + mock_socket_instance.connect_ex.assert_called_once_with(("localhost", 8000)) + + +class TestGenerateInvocationFilterUrl: + """Test the generate_invocation_filter_url function.""" + + def test_basic_url_generation(self): + """Test basic URL generation with default base URL.""" + invocation_id = "test-123" + result = generate_invocation_filter_url(invocation_id) + + assert "http://localhost:8000" in result + assert "filterConfig=" in result + assert invocation_id in result + + def test_custom_base_url(self): + """Test URL generation with custom base URL.""" + invocation_id = "test-456" + base_url = "http://example.com/pivot" + result = generate_invocation_filter_url(invocation_id, base_url) + + assert base_url in result + assert "filterConfig=" in result + assert invocation_id in result + + def test_url_encoding(self): + """Test that special characters are properly URL encoded.""" + invocation_id = "test with spaces & symbols" + result = generate_invocation_filter_url(invocation_id) + + # Should be URL encoded + assert "%20" in result # spaces + assert "%26" in result # ampersand + + def test_filter_config_structure(self): + """Test that the filter config has the correct structure.""" + invocation_id = "test-789" + result = generate_invocation_filter_url(invocation_id) + + # Decode the URL to check the filter config + from urllib.parse import unquote, parse_qs + + parsed_url = parse_qs(result.split("?")[1]) + filter_config_str = unquote(parsed_url["filterConfig"][0]) + + # Should contain the expected filter structure + assert invocation_id in filter_config_str + assert "execution_metadata.invocation_id" in filter_config_str + assert "==" in filter_config_str # operator + assert "text" in filter_config_str # type + + def test_pivot_and_table_urls(self): + """Test URL generation for both pivot and table views.""" + invocation_id = "test-pivot-table" + + pivot_url = generate_invocation_filter_url(invocation_id, "http://localhost:8000/pivot") + table_url = generate_invocation_filter_url(invocation_id, "http://localhost:8000/table") + + assert "pivot" in pivot_url + assert "table" in table_url + assert invocation_id in pivot_url + assert invocation_id in table_url + # Both should have the same filter config + assert pivot_url.split("?")[1] == table_url.split("?")[1] + + +class TestShowResultsUrl: + """Test the show_results_url function.""" + + @patch("eval_protocol.utils.show_results_url.is_server_running") + @patch("builtins.print") + def test_server_running_pivot_and_table(self, mock_print, mock_is_running): + """Test output when server is running.""" + mock_is_running.return_value = True + + show_results_url("test-invocation") + + # Should print both pivot and table URLs + assert mock_print.call_count == 3 # Header + 2 URLs + calls = [call[0][0] for call in mock_print.call_args_list] + + assert "View your evaluation results:" in calls[0] + assert "📊 Aggregate scores:" in calls[1] + assert "📋 Trajectories:" in calls[2] + assert "pivot" in calls[1] + assert "table" in calls[2] + + @patch("eval_protocol.utils.show_results_url.is_server_running") + @patch("builtins.print") + def test_server_not_running_instructions(self, mock_print, mock_is_running): + """Test output when server is not running.""" + mock_is_running.return_value = False + + show_results_url("test-invocation") + + # Should print instructions and both URLs + assert mock_print.call_count == 3 # Instructions + 2 URLs + calls = [call[0][0] for call in mock_print.call_args_list] + + assert "Start the local UI with 'ep logs'" in calls[0] + assert "📊 Aggregate scores:" in calls[1] + assert "📋 Trajectories:" in calls[2] + assert "pivot" in calls[1] + assert "table" in calls[2] + + @patch("eval_protocol.utils.show_results_url.is_server_running") + @patch("builtins.print") + def test_invocation_id_in_urls(self, mock_print, mock_is_running): + """Test that invocation_id appears in both URLs.""" + mock_is_running.return_value = True + invocation_id = "unique-test-id-123" + + show_results_url(invocation_id) + + calls = [call[0][0] for call in mock_print.call_args_list] + pivot_url = calls[1] + table_url = calls[2] + + assert invocation_id in pivot_url + assert invocation_id in table_url + + @patch("eval_protocol.utils.show_results_url.is_server_running") + @patch("builtins.print") + def test_different_invocation_ids(self, mock_print, mock_is_running): + """Test that different invocation IDs produce different URLs.""" + mock_is_running.return_value = True + + # Test with first invocation ID + show_results_url("id-1") + calls_1 = [call[0][0] for call in mock_print.call_args_list] + mock_print.reset_mock() + + # Test with second invocation ID + show_results_url("id-2") + calls_2 = [call[0][0] for call in mock_print.call_args_list] + + # URLs should be different + assert calls_1[1] != calls_2[1] # Pivot URLs different + assert calls_1[2] != calls_2[2] # Table URLs different + assert "id-1" in calls_1[1] + assert "id-2" in calls_2[1] + + +class TestIntegration: + """Integration tests for the module.""" + + @patch("socket.socket") + @patch("builtins.print") + def test_full_workflow_server_running(self, mock_print, mock_socket): + """Test the full workflow when server is running.""" + # Mock server running + mock_socket_instance = MagicMock() + mock_socket_instance.connect_ex.return_value = 0 + mock_socket.return_value.__enter__.return_value = mock_socket_instance + + show_results_url("integration-test") + + # Verify socket was checked + mock_socket_instance.connect_ex.assert_called_once_with(("localhost", 8000)) + + # Verify output + assert mock_print.call_count == 3 + calls = [call[0][0] for call in mock_print.call_args_list] + assert "View your evaluation results:" in calls[0] + assert "integration-test" in calls[1] + assert "integration-test" in calls[2] + + @patch("socket.socket") + @patch("builtins.print") + def test_full_workflow_server_not_running(self, mock_print, mock_socket): + """Test the full workflow when server is not running.""" + # Mock server not running + mock_socket_instance = MagicMock() + mock_socket_instance.connect_ex.return_value = 1 + mock_socket.return_value.__enter__.return_value = mock_socket_instance + + show_results_url("integration-test") + + # Verify socket was checked + mock_socket_instance.connect_ex.assert_called_once_with(("localhost", 8000)) + + # Verify output + assert mock_print.call_count == 3 + calls = [call[0][0] for call in mock_print.call_args_list] + assert "Start the local UI with 'ep logs'" in calls[0] + assert "integration-test" in calls[1] + assert "integration-test" in calls[2] diff --git a/vite-app/dist/assets/index-CenJkZJd.js b/vite-app/dist/assets/index-B3h7Mmhe.js similarity index 66% rename from vite-app/dist/assets/index-CenJkZJd.js rename to vite-app/dist/assets/index-B3h7Mmhe.js index 0ecc2540..ed823871 100644 --- a/vite-app/dist/assets/index-CenJkZJd.js +++ b/vite-app/dist/assets/index-B3h7Mmhe.js @@ -1,4 +1,4 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function A(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function n(i){if(i.ep)return;i.ep=!0;const s=A(i);fetch(i.href,s)}})();function Lm(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Fp={exports:{}},Bl={};/** +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function A(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function n(i){if(i.ep)return;i.ep=!0;const s=A(i);fetch(i.href,s)}})();function Im(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Ep={exports:{}},Bl={};/** * @license React * react-jsx-runtime.production.js * @@ -6,7 +6,7 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var hy;function OH(){if(hy)return Bl;hy=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function A(n,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var c in i)c!=="key"&&(s[c]=i[c])}else s=i;return i=s.ref,{$$typeof:e,type:n,key:o,ref:i!==void 0?i:null,props:s}}return Bl.Fragment=t,Bl.jsx=A,Bl.jsxs=A,Bl}var dy;function TH(){return dy||(dy=1,Fp.exports=OH()),Fp.exports}var x=TH(),Sp={exports:{}},Ut={};/** + */var dy;function LH(){if(dy)return Bl;dy=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function A(n,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var c in i)c!=="key"&&(s[c]=i[c])}else s=i;return i=s.ref,{$$typeof:e,type:n,key:o,ref:i!==void 0?i:null,props:s}}return Bl.Fragment=t,Bl.jsx=A,Bl.jsxs=A,Bl}var gy;function RH(){return gy||(gy=1,Ep.exports=LH()),Ep.exports}var x=RH(),Fp={exports:{}},Ut={};/** * @license React * react.production.js * @@ -14,7 +14,7 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var gy;function DH(){if(gy)return Ut;gy=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),A=Symbol.for("react.fragment"),n=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),c=Symbol.for("react.forward_ref"),u=Symbol.for("react.suspense"),h=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),p=Symbol.iterator;function m(T){return T===null||typeof T!="object"?null:(T=p&&T[p]||T["@@iterator"],typeof T=="function"?T:null)}var v={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},w=Object.assign,b={};function _(T,j,At){this.props=T,this.context=j,this.refs=b,this.updater=At||v}_.prototype.isReactComponent={},_.prototype.setState=function(T,j){if(typeof T!="object"&&typeof T!="function"&&T!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,T,j,"setState")},_.prototype.forceUpdate=function(T){this.updater.enqueueForceUpdate(this,T,"forceUpdate")};function C(){}C.prototype=_.prototype;function U(T,j,At){this.props=T,this.context=j,this.refs=b,this.updater=At||v}var E=U.prototype=new C;E.constructor=U,w(E,_.prototype),E.isPureReactComponent=!0;var H=Array.isArray,F={H:null,A:null,T:null,S:null,V:null},D=Object.prototype.hasOwnProperty;function R(T,j,At,tt,Y,ut){return At=ut.ref,{$$typeof:e,type:T,key:j,ref:At!==void 0?At:null,props:ut}}function z(T,j){return R(T.type,j,void 0,void 0,void 0,T.props)}function N(T){return typeof T=="object"&&T!==null&&T.$$typeof===e}function J(T){var j={"=":"=0",":":"=2"};return"$"+T.replace(/[=:]/g,function(At){return j[At]})}var et=/\/+/g;function nt(T,j){return typeof T=="object"&&T!==null&&T.key!=null?J(""+T.key):j.toString(36)}function ot(){}function ft(T){switch(T.status){case"fulfilled":return T.value;case"rejected":throw T.reason;default:switch(typeof T.status=="string"?T.then(ot,ot):(T.status="pending",T.then(function(j){T.status==="pending"&&(T.status="fulfilled",T.value=j)},function(j){T.status==="pending"&&(T.status="rejected",T.reason=j)})),T.status){case"fulfilled":return T.value;case"rejected":throw T.reason}}throw T}function st(T,j,At,tt,Y){var ut=typeof T;(ut==="undefined"||ut==="boolean")&&(T=null);var lt=!1;if(T===null)lt=!0;else switch(ut){case"bigint":case"string":case"number":lt=!0;break;case"object":switch(T.$$typeof){case e:case t:lt=!0;break;case d:return lt=T._init,st(lt(T._payload),j,At,tt,Y)}}if(lt)return Y=Y(T),lt=tt===""?"."+nt(T,0):tt,H(Y)?(At="",lt!=null&&(At=lt.replace(et,"$&/")+"/"),st(Y,j,At,"",function(Yt){return Yt})):Y!=null&&(N(Y)&&(Y=z(Y,At+(Y.key==null||T&&T.key===Y.key?"":(""+Y.key).replace(et,"$&/")+"/")+lt)),j.push(Y)),1;lt=0;var he=tt===""?".":tt+":";if(H(T))for(var Kt=0;Kt>>1,T=L[ct];if(0>>1;cti(tt,q))Yi(ut,tt)?(L[ct]=ut,L[Y]=q,ct=Y):(L[ct]=tt,L[At]=q,ct=At);else if(Yi(ut,q))L[ct]=ut,L[Y]=q,ct=Y;else break t}}return G}function i(L,G){var q=L.sortIndex-G.sortIndex;return q!==0?q:L.id-G.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;e.unstable_now=function(){return s.now()}}else{var o=Date,c=o.now();e.unstable_now=function(){return o.now()-c}}var u=[],h=[],d=1,p=null,m=3,v=!1,w=!1,b=!1,_=!1,C=typeof setTimeout=="function"?setTimeout:null,U=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function H(L){for(var G=A(h);G!==null;){if(G.callback===null)n(h);else if(G.startTime<=L)n(h),G.sortIndex=G.expirationTime,t(u,G);else break;G=A(h)}}function F(L){if(b=!1,H(L),!w)if(A(u)!==null)w=!0,D||(D=!0,nt());else{var G=A(h);G!==null&&st(F,G.startTime-L)}}var D=!1,R=-1,z=5,N=-1;function J(){return _?!0:!(e.unstable_now()-NL&&J());){var ct=p.callback;if(typeof ct=="function"){p.callback=null,m=p.priorityLevel;var T=ct(p.expirationTime<=L);if(L=e.unstable_now(),typeof T=="function"){p.callback=T,H(L),G=!0;break e}p===A(u)&&n(u),H(L)}else n(u);p=A(u)}if(p!==null)G=!0;else{var j=A(h);j!==null&&st(F,j.startTime-L),G=!1}}break t}finally{p=null,m=q,v=!1}G=void 0}}finally{G?nt():D=!1}}}var nt;if(typeof E=="function")nt=function(){E(et)};else if(typeof MessageChannel<"u"){var ot=new MessageChannel,ft=ot.port2;ot.port1.onmessage=et,nt=function(){ft.postMessage(null)}}else nt=function(){C(et,0)};function st(L,G){R=C(function(){L(e.unstable_now())},G)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(L){L.callback=null},e.unstable_forceFrameRate=function(L){0>L||125ct?(L.sortIndex=q,t(h,L),A(u)===null&&L===A(h)&&(b?(U(R),R=-1):b=!0,st(F,q-ct))):(L.sortIndex=T,t(u,L),w||v||(w=!0,D||(D=!0,nt()))),L},e.unstable_shouldYield=J,e.unstable_wrapCallback=function(L){var G=m;return function(){var q=m;m=G;try{return L.apply(this,arguments)}finally{m=q}}}}(Tp)),Tp}var my;function LH(){return my||(my=1,Op.exports=MH()),Op.exports}var Dp={exports:{}},dA={};/** + */var my;function NH(){return my||(my=1,function(e){function t(L,G){var q=L.length;L.push(G);t:for(;0>>1,T=L[ct];if(0>>1;cti(tt,q))Yi(ut,tt)?(L[ct]=ut,L[Y]=q,ct=Y):(L[ct]=tt,L[At]=q,ct=At);else if(Yi(ut,q))L[ct]=ut,L[Y]=q,ct=Y;else break t}}return G}function i(L,G){var q=L.sortIndex-G.sortIndex;return q!==0?q:L.id-G.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;e.unstable_now=function(){return s.now()}}else{var o=Date,c=o.now();e.unstable_now=function(){return o.now()-c}}var u=[],h=[],d=1,p=null,m=3,v=!1,w=!1,b=!1,_=!1,C=typeof setTimeout=="function"?setTimeout:null,U=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function H(L){for(var G=A(h);G!==null;){if(G.callback===null)n(h);else if(G.startTime<=L)n(h),G.sortIndex=G.expirationTime,t(u,G);else break;G=A(h)}}function F(L){if(b=!1,H(L),!w)if(A(u)!==null)w=!0,D||(D=!0,nt());else{var G=A(h);G!==null&&st(F,G.startTime-L)}}var D=!1,R=-1,z=5,N=-1;function J(){return _?!0:!(e.unstable_now()-NL&&J());){var ct=p.callback;if(typeof ct=="function"){p.callback=null,m=p.priorityLevel;var T=ct(p.expirationTime<=L);if(L=e.unstable_now(),typeof T=="function"){p.callback=T,H(L),G=!0;break e}p===A(u)&&n(u),H(L)}else n(u);p=A(u)}if(p!==null)G=!0;else{var j=A(h);j!==null&&st(F,j.startTime-L),G=!1}}break t}finally{p=null,m=q,v=!1}G=void 0}}finally{G?nt():D=!1}}}var nt;if(typeof E=="function")nt=function(){E(et)};else if(typeof MessageChannel<"u"){var ot=new MessageChannel,ft=ot.port2;ot.port1.onmessage=et,nt=function(){ft.postMessage(null)}}else nt=function(){C(et,0)};function st(L,G){R=C(function(){L(e.unstable_now())},G)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(L){L.callback=null},e.unstable_forceFrameRate=function(L){0>L||125ct?(L.sortIndex=q,t(h,L),A(u)===null&&L===A(h)&&(b?(U(R),R=-1):b=!0,st(F,q-ct))):(L.sortIndex=T,t(u,L),w||v||(w=!0,D||(D=!0,nt()))),L},e.unstable_shouldYield=J,e.unstable_wrapCallback=function(L){var G=m;return function(){var q=m;m=G;try{return L.apply(this,arguments)}finally{m=q}}}}(Op)),Op}var vy;function kH(){return vy||(vy=1,Hp.exports=NH()),Hp.exports}var Tp={exports:{}},dA={};/** * @license React * react-dom.production.js * @@ -30,7 +30,7 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var vy;function RH(){if(vy)return dA;vy=1;var e=kh();function t(u){var h="https://react.dev/errors/"+u;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),Dp.exports=RH(),Dp.exports}/** + */var wy;function KH(){if(wy)return dA;wy=1;var e=Ih();function t(u){var h="https://react.dev/errors/"+u;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),Tp.exports=KH(),Tp.exports}/** * @license React * react-dom-client.production.js * @@ -38,15 +38,15 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var by;function IH(){if(by)return ml;by=1;var e=LH(),t=kh(),A=vQ();function n(r){var a="https://react.dev/errors/"+r;if(1T||(r.current=ct[T],ct[T]=null,T--)}function tt(r,a){T++,ct[T]=r.current,r.current=a}var Y=j(null),ut=j(null),lt=j(null),he=j(null);function Kt(r,a){switch(tt(lt,a),tt(ut,r),tt(Y,null),a.nodeType){case 9:case 11:r=(r=a.documentElement)&&(r=r.namespaceURI)?Kb(r):0;break;default:if(r=a.tagName,a=a.namespaceURI)a=Kb(a),r=zb(a,r);else switch(r){case"svg":r=1;break;case"math":r=2;break;default:r=0}}At(Y),tt(Y,r)}function Yt(){At(Y),At(ut),At(lt)}function An(r){r.memoizedState!==null&&tt(he,r);var a=Y.current,l=zb(a,r.type);a!==l&&(tt(ut,r),tt(Y,l))}function je(r){ut.current===r&&(At(Y),At(ut)),he.current===r&&(At(he),fl._currentValue=q)}var nA=Object.prototype.hasOwnProperty,FA=e.unstable_scheduleCallback,SA=e.unstable_cancelCallback,ea=e.unstable_shouldYield,yA=e.unstable_requestPaint,CA=e.unstable_now,pi=e.unstable_getCurrentPriorityLevel,Pn=e.unstable_ImmediatePriority,se=e.unstable_UserBlockingPriority,jn=e.unstable_NormalPriority,es=e.unstable_LowPriority,Ar=e.unstable_IdlePriority,As=e.log,Aa=e.unstable_setDisableYieldValue,nn=null,xe=null;function HA(r){if(typeof As=="function"&&Aa(r),xe&&typeof xe.setStrictMode=="function")try{xe.setStrictMode(nn,r)}catch{}}var Se=Math.clz32?Math.clz32:ir,nr=Math.log,yo=Math.LN2;function ir(r){return r>>>=0,r===0?32:31-(nr(r)/yo|0)|0}var rn=256,lA=4194304;function $t(r){var a=r&42;if(a!==0)return a;switch(r&-r){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return r&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return r}}function Bi(r,a,l){var f=r.pendingLanes;if(f===0)return 0;var g=0,B=r.suspendedLanes,y=r.pingedLanes;r=r.warmLanes;var Q=f&134217727;return Q!==0?(f=Q&~B,f!==0?g=$t(f):(y&=Q,y!==0?g=$t(y):l||(l=Q&~r,l!==0&&(g=$t(l))))):(Q=f&~B,Q!==0?g=$t(Q):y!==0?g=$t(y):l||(l=f&~r,l!==0&&(g=$t(l)))),g===0?0:a!==0&&a!==g&&(a&B)===0&&(B=g&-g,l=a&-a,B>=l||B===32&&(l&4194048)!==0)?a:g}function Un(r,a){return(r.pendingLanes&~(r.suspendedLanes&~r.pingedLanes)&a)===0}function rr(r,a){switch(r){case 1:case 2:case 4:case 8:case 64:return a+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function sr(){var r=rn;return rn<<=1,(rn&4194048)===0&&(rn=256),r}function mi(){var r=lA;return lA<<=1,(lA&62914560)===0&&(lA=4194304),r}function Gn(r){for(var a=[],l=0;31>l;l++)a.push(r);return a}function sn(r,a){r.pendingLanes|=a,a!==268435456&&(r.suspendedLanes=0,r.pingedLanes=0,r.warmLanes=0)}function ns(r,a,l,f,g,B){var y=r.pendingLanes;r.pendingLanes=l,r.suspendedLanes=0,r.pingedLanes=0,r.warmLanes=0,r.expiredLanes&=l,r.entangledLanes&=l,r.errorRecoveryDisabledLanes&=l,r.shellSuspendCounter=0;var Q=r.entanglements,O=r.expirationTimes,K=r.hiddenUpdates;for(l=y&~l;0T||(r.current=ct[T],ct[T]=null,T--)}function tt(r,a){T++,ct[T]=r.current,r.current=a}var Y=j(null),ut=j(null),lt=j(null),he=j(null);function Kt(r,a){switch(tt(lt,a),tt(ut,r),tt(Y,null),a.nodeType){case 9:case 11:r=(r=a.documentElement)&&(r=r.namespaceURI)?zb(r):0;break;default:if(r=a.tagName,a=a.namespaceURI)a=zb(a),r=Vb(a,r);else switch(r){case"svg":r=1;break;case"math":r=2;break;default:r=0}}At(Y),tt(Y,r)}function Yt(){At(Y),At(ut),At(lt)}function An(r){r.memoizedState!==null&&tt(he,r);var a=Y.current,l=Vb(a,r.type);a!==l&&(tt(ut,r),tt(Y,l))}function je(r){ut.current===r&&(At(Y),At(ut)),he.current===r&&(At(he),fl._currentValue=q)}var nA=Object.prototype.hasOwnProperty,FA=e.unstable_scheduleCallback,SA=e.unstable_cancelCallback,ea=e.unstable_shouldYield,yA=e.unstable_requestPaint,CA=e.unstable_now,Bi=e.unstable_getCurrentPriorityLevel,Pn=e.unstable_ImmediatePriority,se=e.unstable_UserBlockingPriority,jn=e.unstable_NormalPriority,es=e.unstable_LowPriority,Ar=e.unstable_IdlePriority,As=e.log,Aa=e.unstable_setDisableYieldValue,nn=null,xe=null;function HA(r){if(typeof As=="function"&&Aa(r),xe&&typeof xe.setStrictMode=="function")try{xe.setStrictMode(nn,r)}catch{}}var Se=Math.clz32?Math.clz32:ir,nr=Math.log,yo=Math.LN2;function ir(r){return r>>>=0,r===0?32:31-(nr(r)/yo|0)|0}var rn=256,lA=4194304;function $t(r){var a=r&42;if(a!==0)return a;switch(r&-r){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return r&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return r}}function mi(r,a,l){var f=r.pendingLanes;if(f===0)return 0;var g=0,B=r.suspendedLanes,y=r.pingedLanes;r=r.warmLanes;var Q=f&134217727;return Q!==0?(f=Q&~B,f!==0?g=$t(f):(y&=Q,y!==0?g=$t(y):l||(l=Q&~r,l!==0&&(g=$t(l))))):(Q=f&~B,Q!==0?g=$t(Q):y!==0?g=$t(y):l||(l=f&~r,l!==0&&(g=$t(l)))),g===0?0:a!==0&&a!==g&&(a&B)===0&&(B=g&-g,l=a&-a,B>=l||B===32&&(l&4194048)!==0)?a:g}function En(r,a){return(r.pendingLanes&~(r.suspendedLanes&~r.pingedLanes)&a)===0}function rr(r,a){switch(r){case 1:case 2:case 4:case 8:case 64:return a+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function sr(){var r=rn;return rn<<=1,(rn&4194048)===0&&(rn=256),r}function vi(){var r=lA;return lA<<=1,(lA&62914560)===0&&(lA=4194304),r}function Gn(r){for(var a=[],l=0;31>l;l++)a.push(r);return a}function sn(r,a){r.pendingLanes|=a,a!==268435456&&(r.suspendedLanes=0,r.pingedLanes=0,r.warmLanes=0)}function ns(r,a,l,f,g,B){var y=r.pendingLanes;r.pendingLanes=l,r.suspendedLanes=0,r.pingedLanes=0,r.warmLanes=0,r.expiredLanes&=l,r.entangledLanes&=l,r.errorRecoveryDisabledLanes&=l,r.shellSuspendCounter=0;var Q=r.entanglements,O=r.expirationTimes,K=r.hiddenUpdates;for(l=y&~l;0)":-1g||O[f]!==K[g]){var X=` -`+O[f].replace(" at new "," at ");return r.displayName&&X.includes("")&&(X=X.replace("",r.displayName)),X}while(1<=f&&0<=g);break}}}finally{_d=!1,Error.prepareStackTrace=l}return(l=r?r.displayName||r.name:"")?ra(l):""}function QS(r){switch(r.tag){case 26:case 27:case 5:return ra(r.type);case 16:return ra("Lazy");case 13:return ra("Suspense");case 19:return ra("SuspenseList");case 0:case 15:return xd(r.type,!1);case 11:return xd(r.type.render,!1);case 1:return xd(r.type,!0);case 31:return ra("Activity");default:return""}}function Mv(r){try{var a="";do a+=QS(r),r=r.return;while(r);return a}catch(l){return` +`+O[f].replace(" at new "," at ");return r.displayName&&X.includes("")&&(X=X.replace("",r.displayName)),X}while(1<=f&&0<=g);break}}}finally{Cd=!1,Error.prepareStackTrace=l}return(l=r?r.displayName||r.name:"")?ra(l):""}function SS(r){switch(r.tag){case 26:case 27:case 5:return ra(r.type);case 16:return ra("Lazy");case 13:return ra("Suspense");case 19:return ra("SuspenseList");case 0:case 15:return _d(r.type,!1);case 11:return _d(r.type.render,!1);case 1:return _d(r.type,!0);case 31:return ra("Activity");default:return""}}function L0(r){try{var a="";do a+=SS(r),r=r.return;while(r);return a}catch(l){return` Error generating stack: `+l.message+` -`+l.stack}}function ln(r){switch(typeof r){case"bigint":case"boolean":case"number":case"string":case"undefined":return r;case"object":return r;default:return""}}function Lv(r){var a=r.type;return(r=r.nodeName)&&r.toLowerCase()==="input"&&(a==="checkbox"||a==="radio")}function US(r){var a=Lv(r)?"checked":"value",l=Object.getOwnPropertyDescriptor(r.constructor.prototype,a),f=""+r[a];if(!r.hasOwnProperty(a)&&typeof l<"u"&&typeof l.get=="function"&&typeof l.set=="function"){var g=l.get,B=l.set;return Object.defineProperty(r,a,{configurable:!0,get:function(){return g.call(this)},set:function(y){f=""+y,B.call(this,y)}}),Object.defineProperty(r,a,{enumerable:l.enumerable}),{getValue:function(){return f},setValue:function(y){f=""+y},stopTracking:function(){r._valueTracker=null,delete r[a]}}}}function Lc(r){r._valueTracker||(r._valueTracker=US(r))}function Rv(r){if(!r)return!1;var a=r._valueTracker;if(!a)return!0;var l=a.getValue(),f="";return r&&(f=Lv(r)?r.checked?"true":"false":r.value),r=f,r!==l?(a.setValue(r),!0):!1}function Rc(r){if(r=r||(typeof document<"u"?document:void 0),typeof r>"u")return null;try{return r.activeElement||r.body}catch{return r.body}}var ES=/[\n"\\]/g;function cn(r){return r.replace(ES,function(a){return"\\"+a.charCodeAt(0).toString(16)+" "})}function Qd(r,a,l,f,g,B,y,Q){r.name="",y!=null&&typeof y!="function"&&typeof y!="symbol"&&typeof y!="boolean"?r.type=y:r.removeAttribute("type"),a!=null?y==="number"?(a===0&&r.value===""||r.value!=a)&&(r.value=""+ln(a)):r.value!==""+ln(a)&&(r.value=""+ln(a)):y!=="submit"&&y!=="reset"||r.removeAttribute("value"),a!=null?Ud(r,y,ln(a)):l!=null?Ud(r,y,ln(l)):f!=null&&r.removeAttribute("value"),g==null&&B!=null&&(r.defaultChecked=!!B),g!=null&&(r.checked=g&&typeof g!="function"&&typeof g!="symbol"),Q!=null&&typeof Q!="function"&&typeof Q!="symbol"&&typeof Q!="boolean"?r.name=""+ln(Q):r.removeAttribute("name")}function Iv(r,a,l,f,g,B,y,Q){if(B!=null&&typeof B!="function"&&typeof B!="symbol"&&typeof B!="boolean"&&(r.type=B),a!=null||l!=null){if(!(B!=="submit"&&B!=="reset"||a!=null))return;l=l!=null?""+ln(l):"",a=a!=null?""+ln(a):l,Q||a===r.value||(r.value=a),r.defaultValue=a}f=f??g,f=typeof f!="function"&&typeof f!="symbol"&&!!f,r.checked=Q?r.checked:!!f,r.defaultChecked=!!f,y!=null&&typeof y!="function"&&typeof y!="symbol"&&typeof y!="boolean"&&(r.name=y)}function Ud(r,a,l){a==="number"&&Rc(r.ownerDocument)===r||r.defaultValue===""+l||(r.defaultValue=""+l)}function sa(r,a,l,f){if(r=r.options,a){a={};for(var g=0;g"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Od=!1;if(bi)try{var Qo={};Object.defineProperty(Qo,"passive",{get:function(){Od=!0}}),window.addEventListener("test",Qo,Qo),window.removeEventListener("test",Qo,Qo)}catch{Od=!1}var lr=null,Td=null,Nc=null;function jv(){if(Nc)return Nc;var r,a=Td,l=a.length,f,g="value"in lr?lr.value:lr.textContent,B=g.length;for(r=0;r=Fo),Wv=" ",Jv=!1;function qv(r,a){switch(r){case"keyup":return A1.indexOf(a.keyCode)!==-1;case"keydown":return a.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function t0(r){return r=r.detail,typeof r=="object"&&"data"in r?r.data:null}var ca=!1;function i1(r,a){switch(r){case"compositionend":return t0(a);case"keypress":return a.which!==32?null:(Jv=!0,Wv);case"textInput":return r=a.data,r===Wv&&Jv?null:r;default:return null}}function r1(r,a){if(ca)return r==="compositionend"||!Id&&qv(r,a)?(r=jv(),Nc=Td=lr=null,ca=!1,r):null;switch(r){case"paste":return null;case"keypress":if(!(a.ctrlKey||a.altKey||a.metaKey)||a.ctrlKey&&a.altKey){if(a.char&&1=a)return{node:l,offset:a-r};r=f}t:{for(;l;){if(l.nextSibling){l=l.nextSibling;break t}l=l.parentNode}l=void 0}l=o0(l)}}function c0(r,a){return r&&a?r===a?!0:r&&r.nodeType===3?!1:a&&a.nodeType===3?c0(r,a.parentNode):"contains"in r?r.contains(a):r.compareDocumentPosition?!!(r.compareDocumentPosition(a)&16):!1:!1}function u0(r){r=r!=null&&r.ownerDocument!=null&&r.ownerDocument.defaultView!=null?r.ownerDocument.defaultView:window;for(var a=Rc(r.document);a instanceof r.HTMLIFrameElement;){try{var l=typeof a.contentWindow.location.href=="string"}catch{l=!1}if(l)r=a.contentWindow;else break;a=Rc(r.document)}return a}function Kd(r){var a=r&&r.nodeName&&r.nodeName.toLowerCase();return a&&(a==="input"&&(r.type==="text"||r.type==="search"||r.type==="tel"||r.type==="url"||r.type==="password")||a==="textarea"||r.contentEditable==="true")}var h1=bi&&"documentMode"in document&&11>=document.documentMode,ua=null,zd=null,To=null,Vd=!1;function f0(r,a,l){var f=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;Vd||ua==null||ua!==Rc(f)||(f=ua,"selectionStart"in f&&Kd(f)?f={start:f.selectionStart,end:f.selectionEnd}:(f=(f.ownerDocument&&f.ownerDocument.defaultView||window).getSelection(),f={anchorNode:f.anchorNode,anchorOffset:f.anchorOffset,focusNode:f.focusNode,focusOffset:f.focusOffset}),To&&Oo(To,f)||(To=f,f=Fu(zd,"onSelect"),0>=y,g-=y,Ci=1<<32-Se(a)+g|l<B?B:8;var y=L.T,Q={};L.T=Q,Eg(r,!1,a,l);try{var O=g(),K=L.S;if(K!==null&&K(Q,O),O!==null&&typeof O=="object"&&typeof O.then=="function"){var X=y1(O,f);Zo(r,a,X,XA(r))}else Zo(r,a,f,XA(r))}catch(W){Zo(r,a,{then:function(){},status:"rejected",reason:W},XA())}finally{G.p=B,L.T=y}}function U1(){}function Qg(r,a,l,f){if(r.tag!==5)throw Error(n(476));var g=hw(r).queue;fw(r,g,a,q,l===null?U1:function(){return dw(r),l(f)})}function hw(r){var a=r.memoizedState;if(a!==null)return a;a={memoizedState:q,baseState:q,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ui,lastRenderedState:q},next:null};var l={};return a.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ui,lastRenderedState:l},next:null},r.memoizedState=a,r=r.alternate,r!==null&&(r.memoizedState=a),a}function dw(r){var a=hw(r).next.queue;Zo(r,a,{},XA())}function Ug(){return hA(fl)}function gw(){return ke().memoizedState}function pw(){return ke().memoizedState}function E1(r){for(var a=r.return;a!==null;){switch(a.tag){case 24:case 3:var l=XA();r=fr(l);var f=hr(a,r,l);f!==null&&(ZA(f,a,l),zo(f,a,l)),a={cache:ng()},r.payload=a;return}a=a.return}}function F1(r,a,l){var f=XA();l={lane:f,revertLane:0,action:l,hasEagerState:!1,eagerState:null,next:null},lu(r)?mw(a,l):(l=Xd(r,a,l,f),l!==null&&(ZA(l,r,f),vw(l,a,f)))}function Bw(r,a,l){var f=XA();Zo(r,a,l,f)}function Zo(r,a,l,f){var g={lane:f,revertLane:0,action:l,hasEagerState:!1,eagerState:null,next:null};if(lu(r))mw(a,g);else{var B=r.alternate;if(r.lanes===0&&(B===null||B.lanes===0)&&(B=a.lastRenderedReducer,B!==null))try{var y=a.lastRenderedState,Q=B(y,l);if(g.hasEagerState=!0,g.eagerState=Q,zA(Q,y))return Gc(r,a,g,0),ae===null&&jc(),!1}catch{}finally{}if(l=Xd(r,a,g,f),l!==null)return ZA(l,r,f),vw(l,a,f),!0}return!1}function Eg(r,a,l,f){if(f={lane:2,revertLane:sp(),action:f,hasEagerState:!1,eagerState:null,next:null},lu(r)){if(a)throw Error(n(479))}else a=Xd(r,l,f,2),a!==null&&ZA(a,r,2)}function lu(r){var a=r.alternate;return r===Et||a!==null&&a===Et}function mw(r,a){ba=nu=!0;var l=r.pending;l===null?a.next=a:(a.next=l.next,l.next=a),r.pending=a}function vw(r,a,l){if((l&4194048)!==0){var f=a.lanes;f&=r.pendingLanes,l|=f,a.lanes=l,He(r,l)}}var cu={readContext:hA,use:ru,useCallback:De,useContext:De,useEffect:De,useImperativeHandle:De,useLayoutEffect:De,useInsertionEffect:De,useMemo:De,useReducer:De,useRef:De,useState:De,useDebugValue:De,useDeferredValue:De,useTransition:De,useSyncExternalStore:De,useId:De,useHostTransitionStatus:De,useFormState:De,useActionState:De,useOptimistic:De,useMemoCache:De,useCacheRefresh:De},ww={readContext:hA,use:ru,useCallback:function(r,a){return TA().memoizedState=[r,a===void 0?null:a],r},useContext:hA,useEffect:nw,useImperativeHandle:function(r,a,l){l=l!=null?l.concat([r]):null,ou(4194308,4,aw.bind(null,a,r),l)},useLayoutEffect:function(r,a){return ou(4194308,4,r,a)},useInsertionEffect:function(r,a){ou(4,2,r,a)},useMemo:function(r,a){var l=TA();a=a===void 0?null:a;var f=r();if(ms){HA(!0);try{r()}finally{HA(!1)}}return l.memoizedState=[f,a],f},useReducer:function(r,a,l){var f=TA();if(l!==void 0){var g=l(a);if(ms){HA(!0);try{l(a)}finally{HA(!1)}}}else g=a;return f.memoizedState=f.baseState=g,r={pending:null,lanes:0,dispatch:null,lastRenderedReducer:r,lastRenderedState:g},f.queue=r,r=r.dispatch=F1.bind(null,Et,r),[f.memoizedState,r]},useRef:function(r){var a=TA();return r={current:r},a.memoizedState=r},useState:function(r){r=yg(r);var a=r.queue,l=Bw.bind(null,Et,a);return a.dispatch=l,[r.memoizedState,l]},useDebugValue:_g,useDeferredValue:function(r,a){var l=TA();return xg(l,r,a)},useTransition:function(){var r=yg(!1);return r=fw.bind(null,Et,r.queue,!0,!1),TA().memoizedState=r,[!1,r]},useSyncExternalStore:function(r,a,l){var f=Et,g=TA();if(Pt){if(l===void 0)throw Error(n(407));l=l()}else{if(l=a(),ae===null)throw Error(n(349));(Lt&124)!==0||K0(f,a,l)}g.memoizedState=l;var B={value:l,getSnapshot:a};return g.queue=B,nw(V0.bind(null,f,B,r),[r]),f.flags|=2048,Ca(9,au(),z0.bind(null,f,B,l,a),null),l},useId:function(){var r=TA(),a=ae.identifierPrefix;if(Pt){var l=_i,f=Ci;l=(f&~(1<<32-Se(f)-1)).toString(32)+l,a="«"+a+"R"+l,l=iu++,0Ct?(Ye=vt,vt=null):Ye=vt.sibling;var It=V(I,vt,k[Ct],$);if(It===null){vt===null&&(vt=Ye);break}r&&vt&&It.alternate===null&&a(I,vt),M=B(It,M,Ct),Ft===null?pt=It:Ft.sibling=It,Ft=It,vt=Ye}if(Ct===k.length)return l(I,vt),Pt&&fs(I,Ct),pt;if(vt===null){for(;CtCt?(Ye=vt,vt=null):Ye=vt.sibling;var Sr=V(I,vt,It.value,$);if(Sr===null){vt===null&&(vt=Ye);break}r&&vt&&Sr.alternate===null&&a(I,vt),M=B(Sr,M,Ct),Ft===null?pt=Sr:Ft.sibling=Sr,Ft=Sr,vt=Ye}if(It.done)return l(I,vt),Pt&&fs(I,Ct),pt;if(vt===null){for(;!It.done;Ct++,It=k.next())It=W(I,It.value,$),It!==null&&(M=B(It,M,Ct),Ft===null?pt=It:Ft.sibling=It,Ft=It);return Pt&&fs(I,Ct),pt}for(vt=f(vt);!It.done;Ct++,It=k.next())It=P(vt,I,Ct,It.value,$),It!==null&&(r&&It.alternate!==null&&vt.delete(It.key===null?Ct:It.key),M=B(It,M,Ct),Ft===null?pt=It:Ft.sibling=It,Ft=It);return r&&vt.forEach(function(HH){return a(I,HH)}),Pt&&fs(I,Ct),pt}function qt(I,M,k,$){if(typeof k=="object"&&k!==null&&k.type===w&&k.key===null&&(k=k.props.children),typeof k=="object"&&k!==null){switch(k.$$typeof){case m:t:{for(var pt=k.key;M!==null;){if(M.key===pt){if(pt=k.type,pt===w){if(M.tag===7){l(I,M.sibling),$=g(M,k.props.children),$.return=I,I=$;break t}}else if(M.elementType===pt||typeof pt=="object"&&pt!==null&&pt.$$typeof===z&&yw(pt)===M.type){l(I,M.sibling),$=g(M,k.props),$o($,k),$.return=I,I=$;break t}l(I,M);break}else a(I,M);M=M.sibling}k.type===w?($=cs(k.props.children,I.mode,$,k.key),$.return=I,I=$):($=Zc(k.type,k.key,k.props,null,I.mode,$),$o($,k),$.return=I,I=$)}return y(I);case v:t:{for(pt=k.key;M!==null;){if(M.key===pt)if(M.tag===4&&M.stateNode.containerInfo===k.containerInfo&&M.stateNode.implementation===k.implementation){l(I,M.sibling),$=g(M,k.children||[]),$.return=I,I=$;break t}else{l(I,M);break}else a(I,M);M=M.sibling}$=$d(k,I.mode,$),$.return=I,I=$}return y(I);case z:return pt=k._init,k=pt(k._payload),qt(I,M,k,$)}if(st(k))return _t(I,M,k,$);if(nt(k)){if(pt=nt(k),typeof pt!="function")throw Error(n(150));return k=pt.call(k),bt(I,M,k,$)}if(typeof k.then=="function")return qt(I,M,uu(k),$);if(k.$$typeof===E)return qt(I,M,Jc(I,k),$);fu(I,k)}return typeof k=="string"&&k!==""||typeof k=="number"||typeof k=="bigint"?(k=""+k,M!==null&&M.tag===6?(l(I,M.sibling),$=g(M,k),$.return=I,I=$):(l(I,M),$=Yd(k,I.mode,$),$.return=I,I=$),y(I)):l(I,M)}return function(I,M,k,$){try{Yo=0;var pt=qt(I,M,k,$);return _a=null,pt}catch(vt){if(vt===ko||vt===tu)throw vt;var Ft=VA(29,vt,null,I.mode);return Ft.lanes=$,Ft.return=I,Ft}finally{}}}var xa=Cw(!0),_w=Cw(!1),gn=j(null),Yn=null;function gr(r){var a=r.alternate;tt(ze,ze.current&1),tt(gn,r),Yn===null&&(a===null||wa.current!==null||a.memoizedState!==null)&&(Yn=r)}function xw(r){if(r.tag===22){if(tt(ze,ze.current),tt(gn,r),Yn===null){var a=r.alternate;a!==null&&a.memoizedState!==null&&(Yn=r)}}else pr()}function pr(){tt(ze,ze.current),tt(gn,gn.current)}function Ei(r){At(gn),Yn===r&&(Yn=null),At(ze)}var ze=j(0);function hu(r){for(var a=r;a!==null;){if(a.tag===13){var l=a.memoizedState;if(l!==null&&(l=l.dehydrated,l===null||l.data==="$?"||mp(l)))return a}else if(a.tag===19&&a.memoizedProps.revealOrder!==void 0){if((a.flags&128)!==0)return a}else if(a.child!==null){a.child.return=a,a=a.child;continue}if(a===r)break;for(;a.sibling===null;){if(a.return===null||a.return===r)return null;a=a.return}a.sibling.return=a.return,a=a.sibling}return null}function Fg(r,a,l,f){a=r.memoizedState,l=l(f,a),l=l==null?a:d({},a,l),r.memoizedState=l,r.lanes===0&&(r.updateQueue.baseState=l)}var Sg={enqueueSetState:function(r,a,l){r=r._reactInternals;var f=XA(),g=fr(f);g.payload=a,l!=null&&(g.callback=l),a=hr(r,g,f),a!==null&&(ZA(a,r,f),zo(a,r,f))},enqueueReplaceState:function(r,a,l){r=r._reactInternals;var f=XA(),g=fr(f);g.tag=1,g.payload=a,l!=null&&(g.callback=l),a=hr(r,g,f),a!==null&&(ZA(a,r,f),zo(a,r,f))},enqueueForceUpdate:function(r,a){r=r._reactInternals;var l=XA(),f=fr(l);f.tag=2,a!=null&&(f.callback=a),a=hr(r,f,l),a!==null&&(ZA(a,r,l),zo(a,r,l))}};function Qw(r,a,l,f,g,B,y){return r=r.stateNode,typeof r.shouldComponentUpdate=="function"?r.shouldComponentUpdate(f,B,y):a.prototype&&a.prototype.isPureReactComponent?!Oo(l,f)||!Oo(g,B):!0}function Uw(r,a,l,f){r=a.state,typeof a.componentWillReceiveProps=="function"&&a.componentWillReceiveProps(l,f),typeof a.UNSAFE_componentWillReceiveProps=="function"&&a.UNSAFE_componentWillReceiveProps(l,f),a.state!==r&&Sg.enqueueReplaceState(a,a.state,null)}function vs(r,a){var l=a;if("ref"in a){l={};for(var f in a)f!=="ref"&&(l[f]=a[f])}if(r=r.defaultProps){l===a&&(l=d({},l));for(var g in r)l[g]===void 0&&(l[g]=r[g])}return l}var du=typeof reportError=="function"?reportError:function(r){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var a=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof r=="object"&&r!==null&&typeof r.message=="string"?String(r.message):String(r),error:r});if(!window.dispatchEvent(a))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",r);return}console.error(r)};function Ew(r){du(r)}function Fw(r){console.error(r)}function Sw(r){du(r)}function gu(r,a){try{var l=r.onUncaughtError;l(a.value,{componentStack:a.stack})}catch(f){setTimeout(function(){throw f})}}function Hw(r,a,l){try{var f=r.onCaughtError;f(l.value,{componentStack:l.stack,errorBoundary:a.tag===1?a.stateNode:null})}catch(g){setTimeout(function(){throw g})}}function Hg(r,a,l){return l=fr(l),l.tag=3,l.payload={element:null},l.callback=function(){gu(r,a)},l}function Ow(r){return r=fr(r),r.tag=3,r}function Tw(r,a,l,f){var g=l.type.getDerivedStateFromError;if(typeof g=="function"){var B=f.value;r.payload=function(){return g(B)},r.callback=function(){Hw(a,l,f)}}var y=l.stateNode;y!==null&&typeof y.componentDidCatch=="function"&&(r.callback=function(){Hw(a,l,f),typeof g!="function"&&(yr===null?yr=new Set([this]):yr.add(this));var Q=f.stack;this.componentDidCatch(f.value,{componentStack:Q!==null?Q:""})})}function H1(r,a,l,f,g){if(l.flags|=32768,f!==null&&typeof f=="object"&&typeof f.then=="function"){if(a=l.alternate,a!==null&&Ro(a,l,g,!0),l=gn.current,l!==null){switch(l.tag){case 13:return Yn===null?ep():l.alternate===null&&Ue===0&&(Ue=3),l.flags&=-257,l.flags|=65536,l.lanes=g,f===sg?l.flags|=16384:(a=l.updateQueue,a===null?l.updateQueue=new Set([f]):a.add(f),np(r,f,g)),!1;case 22:return l.flags|=65536,f===sg?l.flags|=16384:(a=l.updateQueue,a===null?(a={transitions:null,markerInstances:null,retryQueue:new Set([f])},l.updateQueue=a):(l=a.retryQueue,l===null?a.retryQueue=new Set([f]):l.add(f)),np(r,f,g)),!1}throw Error(n(435,l.tag))}return np(r,f,g),ep(),!1}if(Pt)return a=gn.current,a!==null?((a.flags&65536)===0&&(a.flags|=256),a.flags|=65536,a.lanes=g,f!==qd&&(r=Error(n(422),{cause:f}),Lo(un(r,l)))):(f!==qd&&(a=Error(n(423),{cause:f}),Lo(un(a,l))),r=r.current.alternate,r.flags|=65536,g&=-g,r.lanes|=g,f=un(f,l),g=Hg(r.stateNode,f,g),lg(r,g),Ue!==4&&(Ue=2)),!1;var B=Error(n(520),{cause:f});if(B=un(B,l),nl===null?nl=[B]:nl.push(B),Ue!==4&&(Ue=2),a===null)return!0;f=un(f,l),l=a;do{switch(l.tag){case 3:return l.flags|=65536,r=g&-g,l.lanes|=r,r=Hg(l.stateNode,f,r),lg(l,r),!1;case 1:if(a=l.type,B=l.stateNode,(l.flags&128)===0&&(typeof a.getDerivedStateFromError=="function"||B!==null&&typeof B.componentDidCatch=="function"&&(yr===null||!yr.has(B))))return l.flags|=65536,g&=-g,l.lanes|=g,g=Ow(g),Tw(g,r,l,f),lg(l,g),!1}l=l.return}while(l!==null);return!1}var Dw=Error(n(461)),Xe=!1;function iA(r,a,l,f){a.child=r===null?_w(a,null,l,f):xa(a,r.child,l,f)}function Mw(r,a,l,f,g){l=l.render;var B=a.ref;if("ref"in f){var y={};for(var Q in f)Q!=="ref"&&(y[Q]=f[Q])}else y=f;return ps(a),f=dg(r,a,l,y,B,g),Q=gg(),r!==null&&!Xe?(pg(r,a,g),Fi(r,a,g)):(Pt&&Q&&Wd(a),a.flags|=1,iA(r,a,f,g),a.child)}function Lw(r,a,l,f,g){if(r===null){var B=l.type;return typeof B=="function"&&!Zd(B)&&B.defaultProps===void 0&&l.compare===null?(a.tag=15,a.type=B,Rw(r,a,B,f,g)):(r=Zc(l.type,null,f,a,a.mode,g),r.ref=a.ref,r.return=a,a.child=r)}if(B=r.child,!Ng(r,g)){var y=B.memoizedProps;if(l=l.compare,l=l!==null?l:Oo,l(y,f)&&r.ref===a.ref)return Fi(r,a,g)}return a.flags|=1,r=yi(B,f),r.ref=a.ref,r.return=a,a.child=r}function Rw(r,a,l,f,g){if(r!==null){var B=r.memoizedProps;if(Oo(B,f)&&r.ref===a.ref)if(Xe=!1,a.pendingProps=f=B,Ng(r,g))(r.flags&131072)!==0&&(Xe=!0);else return a.lanes=r.lanes,Fi(r,a,g)}return Og(r,a,l,f,g)}function Iw(r,a,l){var f=a.pendingProps,g=f.children,B=r!==null?r.memoizedState:null;if(f.mode==="hidden"){if((a.flags&128)!==0){if(f=B!==null?B.baseLanes|l:l,r!==null){for(g=a.child=r.child,B=0;g!==null;)B=B|g.lanes|g.childLanes,g=g.sibling;a.childLanes=B&~f}else a.childLanes=0,a.child=null;return Nw(r,a,f,l)}if((l&536870912)!==0)a.memoizedState={baseLanes:0,cachePool:null},r!==null&&qc(a,B!==null?B.cachePool:null),B!==null?R0(a,B):ug(),xw(a);else return a.lanes=a.childLanes=536870912,Nw(r,a,B!==null?B.baseLanes|l:l,l)}else B!==null?(qc(a,B.cachePool),R0(a,B),pr(),a.memoizedState=null):(r!==null&&qc(a,null),ug(),pr());return iA(r,a,g,l),a.child}function Nw(r,a,l,f){var g=rg();return g=g===null?null:{parent:Ke._currentValue,pool:g},a.memoizedState={baseLanes:l,cachePool:g},r!==null&&qc(a,null),ug(),xw(a),r!==null&&Ro(r,a,f,!0),null}function pu(r,a){var l=a.ref;if(l===null)r!==null&&r.ref!==null&&(a.flags|=4194816);else{if(typeof l!="function"&&typeof l!="object")throw Error(n(284));(r===null||r.ref!==l)&&(a.flags|=4194816)}}function Og(r,a,l,f,g){return ps(a),l=dg(r,a,l,f,void 0,g),f=gg(),r!==null&&!Xe?(pg(r,a,g),Fi(r,a,g)):(Pt&&f&&Wd(a),a.flags|=1,iA(r,a,l,g),a.child)}function kw(r,a,l,f,g,B){return ps(a),a.updateQueue=null,l=N0(a,f,l,g),I0(r),f=gg(),r!==null&&!Xe?(pg(r,a,B),Fi(r,a,B)):(Pt&&f&&Wd(a),a.flags|=1,iA(r,a,l,B),a.child)}function Kw(r,a,l,f,g){if(ps(a),a.stateNode===null){var B=ga,y=l.contextType;typeof y=="object"&&y!==null&&(B=hA(y)),B=new l(f,B),a.memoizedState=B.state!==null&&B.state!==void 0?B.state:null,B.updater=Sg,a.stateNode=B,B._reactInternals=a,B=a.stateNode,B.props=f,B.state=a.memoizedState,B.refs={},ag(a),y=l.contextType,B.context=typeof y=="object"&&y!==null?hA(y):ga,B.state=a.memoizedState,y=l.getDerivedStateFromProps,typeof y=="function"&&(Fg(a,l,y,f),B.state=a.memoizedState),typeof l.getDerivedStateFromProps=="function"||typeof B.getSnapshotBeforeUpdate=="function"||typeof B.UNSAFE_componentWillMount!="function"&&typeof B.componentWillMount!="function"||(y=B.state,typeof B.componentWillMount=="function"&&B.componentWillMount(),typeof B.UNSAFE_componentWillMount=="function"&&B.UNSAFE_componentWillMount(),y!==B.state&&Sg.enqueueReplaceState(B,B.state,null),Po(a,f,B,g),Vo(),B.state=a.memoizedState),typeof B.componentDidMount=="function"&&(a.flags|=4194308),f=!0}else if(r===null){B=a.stateNode;var Q=a.memoizedProps,O=vs(l,Q);B.props=O;var K=B.context,X=l.contextType;y=ga,typeof X=="object"&&X!==null&&(y=hA(X));var W=l.getDerivedStateFromProps;X=typeof W=="function"||typeof B.getSnapshotBeforeUpdate=="function",Q=a.pendingProps!==Q,X||typeof B.UNSAFE_componentWillReceiveProps!="function"&&typeof B.componentWillReceiveProps!="function"||(Q||K!==y)&&Uw(a,B,f,y),ur=!1;var V=a.memoizedState;B.state=V,Po(a,f,B,g),Vo(),K=a.memoizedState,Q||V!==K||ur?(typeof W=="function"&&(Fg(a,l,W,f),K=a.memoizedState),(O=ur||Qw(a,l,O,f,V,K,y))?(X||typeof B.UNSAFE_componentWillMount!="function"&&typeof B.componentWillMount!="function"||(typeof B.componentWillMount=="function"&&B.componentWillMount(),typeof B.UNSAFE_componentWillMount=="function"&&B.UNSAFE_componentWillMount()),typeof B.componentDidMount=="function"&&(a.flags|=4194308)):(typeof B.componentDidMount=="function"&&(a.flags|=4194308),a.memoizedProps=f,a.memoizedState=K),B.props=f,B.state=K,B.context=y,f=O):(typeof B.componentDidMount=="function"&&(a.flags|=4194308),f=!1)}else{B=a.stateNode,og(r,a),y=a.memoizedProps,X=vs(l,y),B.props=X,W=a.pendingProps,V=B.context,K=l.contextType,O=ga,typeof K=="object"&&K!==null&&(O=hA(K)),Q=l.getDerivedStateFromProps,(K=typeof Q=="function"||typeof B.getSnapshotBeforeUpdate=="function")||typeof B.UNSAFE_componentWillReceiveProps!="function"&&typeof B.componentWillReceiveProps!="function"||(y!==W||V!==O)&&Uw(a,B,f,O),ur=!1,V=a.memoizedState,B.state=V,Po(a,f,B,g),Vo();var P=a.memoizedState;y!==W||V!==P||ur||r!==null&&r.dependencies!==null&&Wc(r.dependencies)?(typeof Q=="function"&&(Fg(a,l,Q,f),P=a.memoizedState),(X=ur||Qw(a,l,X,f,V,P,O)||r!==null&&r.dependencies!==null&&Wc(r.dependencies))?(K||typeof B.UNSAFE_componentWillUpdate!="function"&&typeof B.componentWillUpdate!="function"||(typeof B.componentWillUpdate=="function"&&B.componentWillUpdate(f,P,O),typeof B.UNSAFE_componentWillUpdate=="function"&&B.UNSAFE_componentWillUpdate(f,P,O)),typeof B.componentDidUpdate=="function"&&(a.flags|=4),typeof B.getSnapshotBeforeUpdate=="function"&&(a.flags|=1024)):(typeof B.componentDidUpdate!="function"||y===r.memoizedProps&&V===r.memoizedState||(a.flags|=4),typeof B.getSnapshotBeforeUpdate!="function"||y===r.memoizedProps&&V===r.memoizedState||(a.flags|=1024),a.memoizedProps=f,a.memoizedState=P),B.props=f,B.state=P,B.context=O,f=X):(typeof B.componentDidUpdate!="function"||y===r.memoizedProps&&V===r.memoizedState||(a.flags|=4),typeof B.getSnapshotBeforeUpdate!="function"||y===r.memoizedProps&&V===r.memoizedState||(a.flags|=1024),f=!1)}return B=f,pu(r,a),f=(a.flags&128)!==0,B||f?(B=a.stateNode,l=f&&typeof l.getDerivedStateFromError!="function"?null:B.render(),a.flags|=1,r!==null&&f?(a.child=xa(a,r.child,null,g),a.child=xa(a,null,l,g)):iA(r,a,l,g),a.memoizedState=B.state,r=a.child):r=Fi(r,a,g),r}function zw(r,a,l,f){return Mo(),a.flags|=256,iA(r,a,l,f),a.child}var Tg={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Dg(r){return{baseLanes:r,cachePool:F0()}}function Mg(r,a,l){return r=r!==null?r.childLanes&~l:0,a&&(r|=pn),r}function Vw(r,a,l){var f=a.pendingProps,g=!1,B=(a.flags&128)!==0,y;if((y=B)||(y=r!==null&&r.memoizedState===null?!1:(ze.current&2)!==0),y&&(g=!0,a.flags&=-129),y=(a.flags&32)!==0,a.flags&=-33,r===null){if(Pt){if(g?gr(a):pr(),Pt){var Q=Qe,O;if(O=Q){t:{for(O=Q,Q=Zn;O.nodeType!==8;){if(!Q){Q=null;break t}if(O=Sn(O.nextSibling),O===null){Q=null;break t}}Q=O}Q!==null?(a.memoizedState={dehydrated:Q,treeContext:us!==null?{id:Ci,overflow:_i}:null,retryLane:536870912,hydrationErrors:null},O=VA(18,null,null,0),O.stateNode=Q,O.return=a,a.child=O,xA=a,Qe=null,O=!0):O=!1}O||ds(a)}if(Q=a.memoizedState,Q!==null&&(Q=Q.dehydrated,Q!==null))return mp(Q)?a.lanes=32:a.lanes=536870912,null;Ei(a)}return Q=f.children,f=f.fallback,g?(pr(),g=a.mode,Q=Bu({mode:"hidden",children:Q},g),f=cs(f,g,l,null),Q.return=a,f.return=a,Q.sibling=f,a.child=Q,g=a.child,g.memoizedState=Dg(l),g.childLanes=Mg(r,y,l),a.memoizedState=Tg,f):(gr(a),Lg(a,Q))}if(O=r.memoizedState,O!==null&&(Q=O.dehydrated,Q!==null)){if(B)a.flags&256?(gr(a),a.flags&=-257,a=Rg(r,a,l)):a.memoizedState!==null?(pr(),a.child=r.child,a.flags|=128,a=null):(pr(),g=f.fallback,Q=a.mode,f=Bu({mode:"visible",children:f.children},Q),g=cs(g,Q,l,null),g.flags|=2,f.return=a,g.return=a,f.sibling=g,a.child=f,xa(a,r.child,null,l),f=a.child,f.memoizedState=Dg(l),f.childLanes=Mg(r,y,l),a.memoizedState=Tg,a=g);else if(gr(a),mp(Q)){if(y=Q.nextSibling&&Q.nextSibling.dataset,y)var K=y.dgst;y=K,f=Error(n(419)),f.stack="",f.digest=y,Lo({value:f,source:null,stack:null}),a=Rg(r,a,l)}else if(Xe||Ro(r,a,l,!1),y=(l&r.childLanes)!==0,Xe||y){if(y=ae,y!==null&&(f=l&-l,f=(f&42)!==0?1:cA(f),f=(f&(y.suspendedLanes|l))!==0?0:f,f!==0&&f!==O.retryLane))throw O.retryLane=f,da(r,f),ZA(y,r,f),Dw;Q.data==="$?"||ep(),a=Rg(r,a,l)}else Q.data==="$?"?(a.flags|=192,a.child=r.child,a=null):(r=O.treeContext,Qe=Sn(Q.nextSibling),xA=a,Pt=!0,hs=null,Zn=!1,r!==null&&(hn[dn++]=Ci,hn[dn++]=_i,hn[dn++]=us,Ci=r.id,_i=r.overflow,us=a),a=Lg(a,f.children),a.flags|=4096);return a}return g?(pr(),g=f.fallback,Q=a.mode,O=r.child,K=O.sibling,f=yi(O,{mode:"hidden",children:f.children}),f.subtreeFlags=O.subtreeFlags&65011712,K!==null?g=yi(K,g):(g=cs(g,Q,l,null),g.flags|=2),g.return=a,f.return=a,f.sibling=g,a.child=f,f=g,g=a.child,Q=r.child.memoizedState,Q===null?Q=Dg(l):(O=Q.cachePool,O!==null?(K=Ke._currentValue,O=O.parent!==K?{parent:K,pool:K}:O):O=F0(),Q={baseLanes:Q.baseLanes|l,cachePool:O}),g.memoizedState=Q,g.childLanes=Mg(r,y,l),a.memoizedState=Tg,f):(gr(a),l=r.child,r=l.sibling,l=yi(l,{mode:"visible",children:f.children}),l.return=a,l.sibling=null,r!==null&&(y=a.deletions,y===null?(a.deletions=[r],a.flags|=16):y.push(r)),a.child=l,a.memoizedState=null,l)}function Lg(r,a){return a=Bu({mode:"visible",children:a},r.mode),a.return=r,r.child=a}function Bu(r,a){return r=VA(22,r,null,a),r.lanes=0,r.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},r}function Rg(r,a,l){return xa(a,r.child,null,l),r=Lg(a,a.pendingProps.children),r.flags|=2,a.memoizedState=null,r}function Pw(r,a,l){r.lanes|=a;var f=r.alternate;f!==null&&(f.lanes|=a),eg(r.return,a,l)}function Ig(r,a,l,f,g){var B=r.memoizedState;B===null?r.memoizedState={isBackwards:a,rendering:null,renderingStartTime:0,last:f,tail:l,tailMode:g}:(B.isBackwards=a,B.rendering=null,B.renderingStartTime=0,B.last=f,B.tail=l,B.tailMode=g)}function jw(r,a,l){var f=a.pendingProps,g=f.revealOrder,B=f.tail;if(iA(r,a,f.children,l),f=ze.current,(f&2)!==0)f=f&1|2,a.flags|=128;else{if(r!==null&&(r.flags&128)!==0)t:for(r=a.child;r!==null;){if(r.tag===13)r.memoizedState!==null&&Pw(r,l,a);else if(r.tag===19)Pw(r,l,a);else if(r.child!==null){r.child.return=r,r=r.child;continue}if(r===a)break t;for(;r.sibling===null;){if(r.return===null||r.return===a)break t;r=r.return}r.sibling.return=r.return,r=r.sibling}f&=1}switch(tt(ze,f),g){case"forwards":for(l=a.child,g=null;l!==null;)r=l.alternate,r!==null&&hu(r)===null&&(g=l),l=l.sibling;l=g,l===null?(g=a.child,a.child=null):(g=l.sibling,l.sibling=null),Ig(a,!1,g,l,B);break;case"backwards":for(l=null,g=a.child,a.child=null;g!==null;){if(r=g.alternate,r!==null&&hu(r)===null){a.child=g;break}r=g.sibling,g.sibling=l,l=g,g=r}Ig(a,!0,l,null,B);break;case"together":Ig(a,!1,null,null,void 0);break;default:a.memoizedState=null}return a.child}function Fi(r,a,l){if(r!==null&&(a.dependencies=r.dependencies),br|=a.lanes,(l&a.childLanes)===0)if(r!==null){if(Ro(r,a,l,!1),(l&a.childLanes)===0)return null}else return null;if(r!==null&&a.child!==r.child)throw Error(n(153));if(a.child!==null){for(r=a.child,l=yi(r,r.pendingProps),a.child=l,l.return=a;r.sibling!==null;)r=r.sibling,l=l.sibling=yi(r,r.pendingProps),l.return=a;l.sibling=null}return a.child}function Ng(r,a){return(r.lanes&a)!==0?!0:(r=r.dependencies,!!(r!==null&&Wc(r)))}function O1(r,a,l){switch(a.tag){case 3:Kt(a,a.stateNode.containerInfo),cr(a,Ke,r.memoizedState.cache),Mo();break;case 27:case 5:An(a);break;case 4:Kt(a,a.stateNode.containerInfo);break;case 10:cr(a,a.type,a.memoizedProps.value);break;case 13:var f=a.memoizedState;if(f!==null)return f.dehydrated!==null?(gr(a),a.flags|=128,null):(l&a.child.childLanes)!==0?Vw(r,a,l):(gr(a),r=Fi(r,a,l),r!==null?r.sibling:null);gr(a);break;case 19:var g=(r.flags&128)!==0;if(f=(l&a.childLanes)!==0,f||(Ro(r,a,l,!1),f=(l&a.childLanes)!==0),g){if(f)return jw(r,a,l);a.flags|=128}if(g=a.memoizedState,g!==null&&(g.rendering=null,g.tail=null,g.lastEffect=null),tt(ze,ze.current),f)break;return null;case 22:case 23:return a.lanes=0,Iw(r,a,l);case 24:cr(a,Ke,r.memoizedState.cache)}return Fi(r,a,l)}function Gw(r,a,l){if(r!==null)if(r.memoizedProps!==a.pendingProps)Xe=!0;else{if(!Ng(r,l)&&(a.flags&128)===0)return Xe=!1,O1(r,a,l);Xe=(r.flags&131072)!==0}else Xe=!1,Pt&&(a.flags&1048576)!==0&&y0(a,$c,a.index);switch(a.lanes=0,a.tag){case 16:t:{r=a.pendingProps;var f=a.elementType,g=f._init;if(f=g(f._payload),a.type=f,typeof f=="function")Zd(f)?(r=vs(f,r),a.tag=1,a=Kw(null,a,f,r,l)):(a.tag=0,a=Og(null,a,f,r,l));else{if(f!=null){if(g=f.$$typeof,g===H){a.tag=11,a=Mw(null,a,f,r,l);break t}else if(g===R){a.tag=14,a=Lw(null,a,f,r,l);break t}}throw a=ft(f)||f,Error(n(306,a,""))}}return a;case 0:return Og(r,a,a.type,a.pendingProps,l);case 1:return f=a.type,g=vs(f,a.pendingProps),Kw(r,a,f,g,l);case 3:t:{if(Kt(a,a.stateNode.containerInfo),r===null)throw Error(n(387));f=a.pendingProps;var B=a.memoizedState;g=B.element,og(r,a),Po(a,f,null,l);var y=a.memoizedState;if(f=y.cache,cr(a,Ke,f),f!==B.cache&&Ag(a,[Ke],l,!0),Vo(),f=y.element,B.isDehydrated)if(B={element:f,isDehydrated:!1,cache:y.cache},a.updateQueue.baseState=B,a.memoizedState=B,a.flags&256){a=zw(r,a,f,l);break t}else if(f!==g){g=un(Error(n(424)),a),Lo(g),a=zw(r,a,f,l);break t}else{switch(r=a.stateNode.containerInfo,r.nodeType){case 9:r=r.body;break;default:r=r.nodeName==="HTML"?r.ownerDocument.body:r}for(Qe=Sn(r.firstChild),xA=a,Pt=!0,hs=null,Zn=!0,l=_w(a,null,f,l),a.child=l;l;)l.flags=l.flags&-3|4096,l=l.sibling}else{if(Mo(),f===g){a=Fi(r,a,l);break t}iA(r,a,f,l)}a=a.child}return a;case 26:return pu(r,a),r===null?(l=$b(a.type,null,a.pendingProps,null))?a.memoizedState=l:Pt||(l=a.type,r=a.pendingProps,f=Hu(lt.current).createElement(l),f[it]=a,f[Bt]=r,sA(f,l,r),Te(f),a.stateNode=f):a.memoizedState=$b(a.type,r.memoizedProps,a.pendingProps,r.memoizedState),null;case 27:return An(a),r===null&&Pt&&(f=a.stateNode=Xb(a.type,a.pendingProps,lt.current),xA=a,Zn=!0,g=Qe,xr(a.type)?(vp=g,Qe=Sn(f.firstChild)):Qe=g),iA(r,a,a.pendingProps.children,l),pu(r,a),r===null&&(a.flags|=4194304),a.child;case 5:return r===null&&Pt&&((g=f=Qe)&&(f=sH(f,a.type,a.pendingProps,Zn),f!==null?(a.stateNode=f,xA=a,Qe=Sn(f.firstChild),Zn=!1,g=!0):g=!1),g||ds(a)),An(a),g=a.type,B=a.pendingProps,y=r!==null?r.memoizedProps:null,f=B.children,gp(g,B)?f=null:y!==null&&gp(g,y)&&(a.flags|=32),a.memoizedState!==null&&(g=dg(r,a,_1,null,null,l),fl._currentValue=g),pu(r,a),iA(r,a,f,l),a.child;case 6:return r===null&&Pt&&((r=l=Qe)&&(l=aH(l,a.pendingProps,Zn),l!==null?(a.stateNode=l,xA=a,Qe=null,r=!0):r=!1),r||ds(a)),null;case 13:return Vw(r,a,l);case 4:return Kt(a,a.stateNode.containerInfo),f=a.pendingProps,r===null?a.child=xa(a,null,f,l):iA(r,a,f,l),a.child;case 11:return Mw(r,a,a.type,a.pendingProps,l);case 7:return iA(r,a,a.pendingProps,l),a.child;case 8:return iA(r,a,a.pendingProps.children,l),a.child;case 12:return iA(r,a,a.pendingProps.children,l),a.child;case 10:return f=a.pendingProps,cr(a,a.type,f.value),iA(r,a,f.children,l),a.child;case 9:return g=a.type._context,f=a.pendingProps.children,ps(a),g=hA(g),f=f(g),a.flags|=1,iA(r,a,f,l),a.child;case 14:return Lw(r,a,a.type,a.pendingProps,l);case 15:return Rw(r,a,a.type,a.pendingProps,l);case 19:return jw(r,a,l);case 31:return f=a.pendingProps,l=a.mode,f={mode:f.mode,children:f.children},r===null?(l=Bu(f,l),l.ref=a.ref,a.child=l,l.return=a,a=l):(l=yi(r.child,f),l.ref=a.ref,a.child=l,l.return=a,a=l),a;case 22:return Iw(r,a,l);case 24:return ps(a),f=hA(Ke),r===null?(g=rg(),g===null&&(g=ae,B=ng(),g.pooledCache=B,B.refCount++,B!==null&&(g.pooledCacheLanes|=l),g=B),a.memoizedState={parent:f,cache:g},ag(a),cr(a,Ke,g)):((r.lanes&l)!==0&&(og(r,a),Po(a,null,null,l),Vo()),g=r.memoizedState,B=a.memoizedState,g.parent!==f?(g={parent:f,cache:f},a.memoizedState=g,a.lanes===0&&(a.memoizedState=a.updateQueue.baseState=g),cr(a,Ke,f)):(f=B.cache,cr(a,Ke,f),f!==g.cache&&Ag(a,[Ke],l,!0))),iA(r,a,a.pendingProps.children,l),a.child;case 29:throw a.pendingProps}throw Error(n(156,a.tag))}function Si(r){r.flags|=4}function Xw(r,a){if(a.type!=="stylesheet"||(a.state.loading&4)!==0)r.flags&=-16777217;else if(r.flags|=16777216,!ey(a)){if(a=gn.current,a!==null&&((Lt&4194048)===Lt?Yn!==null:(Lt&62914560)!==Lt&&(Lt&536870912)===0||a!==Yn))throw Ko=sg,S0;r.flags|=8192}}function mu(r,a){a!==null&&(r.flags|=4),r.flags&16384&&(a=r.tag!==22?mi():536870912,r.lanes|=a,Fa|=a)}function Wo(r,a){if(!Pt)switch(r.tailMode){case"hidden":a=r.tail;for(var l=null;a!==null;)a.alternate!==null&&(l=a),a=a.sibling;l===null?r.tail=null:l.sibling=null;break;case"collapsed":l=r.tail;for(var f=null;l!==null;)l.alternate!==null&&(f=l),l=l.sibling;f===null?a||r.tail===null?r.tail=null:r.tail.sibling=null:f.sibling=null}}function Ce(r){var a=r.alternate!==null&&r.alternate.child===r.child,l=0,f=0;if(a)for(var g=r.child;g!==null;)l|=g.lanes|g.childLanes,f|=g.subtreeFlags&65011712,f|=g.flags&65011712,g.return=r,g=g.sibling;else for(g=r.child;g!==null;)l|=g.lanes|g.childLanes,f|=g.subtreeFlags,f|=g.flags,g.return=r,g=g.sibling;return r.subtreeFlags|=f,r.childLanes=l,a}function T1(r,a,l){var f=a.pendingProps;switch(Jd(a),a.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Ce(a),null;case 1:return Ce(a),null;case 3:return l=a.stateNode,f=null,r!==null&&(f=r.memoizedState.cache),a.memoizedState.cache!==f&&(a.flags|=2048),Qi(Ke),Yt(),l.pendingContext&&(l.context=l.pendingContext,l.pendingContext=null),(r===null||r.child===null)&&(Do(a)?Si(a):r===null||r.memoizedState.isDehydrated&&(a.flags&256)===0||(a.flags|=1024,x0())),Ce(a),null;case 26:return l=a.memoizedState,r===null?(Si(a),l!==null?(Ce(a),Xw(a,l)):(Ce(a),a.flags&=-16777217)):l?l!==r.memoizedState?(Si(a),Ce(a),Xw(a,l)):(Ce(a),a.flags&=-16777217):(r.memoizedProps!==f&&Si(a),Ce(a),a.flags&=-16777217),null;case 27:je(a),l=lt.current;var g=a.type;if(r!==null&&a.stateNode!=null)r.memoizedProps!==f&&Si(a);else{if(!f){if(a.stateNode===null)throw Error(n(166));return Ce(a),null}r=Y.current,Do(a)?C0(a):(r=Xb(g,f,l),a.stateNode=r,Si(a))}return Ce(a),null;case 5:if(je(a),l=a.type,r!==null&&a.stateNode!=null)r.memoizedProps!==f&&Si(a);else{if(!f){if(a.stateNode===null)throw Error(n(166));return Ce(a),null}if(r=Y.current,Do(a))C0(a);else{switch(g=Hu(lt.current),r){case 1:r=g.createElementNS("http://www.w3.org/2000/svg",l);break;case 2:r=g.createElementNS("http://www.w3.org/1998/Math/MathML",l);break;default:switch(l){case"svg":r=g.createElementNS("http://www.w3.org/2000/svg",l);break;case"math":r=g.createElementNS("http://www.w3.org/1998/Math/MathML",l);break;case"script":r=g.createElement("div"),r.innerHTML=" + diff --git a/vite-app/src/GlobalState.tsx b/vite-app/src/GlobalState.tsx index 69ba2385..ef061b38 100644 --- a/vite-app/src/GlobalState.tsx +++ b/vite-app/src/GlobalState.tsx @@ -1,9 +1,20 @@ import { makeAutoObservable, runInAction } from "mobx"; import type { EvaluationRow } from "./types/eval-protocol"; -import type { PivotConfig, FilterGroup } from "./types/filters"; +import type { + PivotConfig, + FilterGroup, + PaginationConfig, + SortConfig, + GlobalConfig, + SortDirection, +} from "./types/configs"; import flattenJson from "./util/flatten-json"; import type { FlatJson } from "./util/flatten-json"; import { createFilterFunction } from "./util/filter-utils"; +import { + QueryParamsWatcher, + queryParamsToPartialConfig, +} from "./util/query-params"; // Default pivot configuration const DEFAULT_PIVOT_CONFIG: PivotConfig = { @@ -17,15 +28,22 @@ const DEFAULT_PIVOT_CONFIG: PivotConfig = { const DEFAULT_FILTER_CONFIG: FilterGroup[] = []; // Default pagination configuration -const DEFAULT_PAGINATION_CONFIG = { +const DEFAULT_PAGINATION_CONFIG: PaginationConfig = { currentPage: 1, pageSize: 25, }; // Default sort configuration -const DEFAULT_SORT_CONFIG = { +const DEFAULT_SORT_CONFIG: SortConfig = { sortField: "created_at", - sortDirection: "desc" as "asc" | "desc", + sortDirection: "desc", +}; + +export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = { + pivotConfig: DEFAULT_PIVOT_CONFIG, + filterConfig: DEFAULT_FILTER_CONFIG, + paginationConfig: DEFAULT_PAGINATION_CONFIG, + sortConfig: DEFAULT_SORT_CONFIG, }; export class GlobalState { @@ -34,21 +52,15 @@ export class GlobalState { dataset: Record = {}; // rollout_id -> expanded expandedRows: Record = {}; - // Pivot configuration - pivotConfig: PivotConfig; - // Unified filter configuration for both pivot and table views - filterConfig: FilterGroup[]; + // Unified global configuration + globalConfig: GlobalConfig; // Debounced, actually applied filter configuration (for performance while typing) appliedFilterConfig: FilterGroup[]; - // Pagination configuration - currentPage: number; - pageSize: number; - // Sort configuration - sortField: string; - sortDirection: "asc" | "desc"; // Loading state isLoading: boolean = true; + queryParamsWatcher: QueryParamsWatcher; + // Cached, denormalized data for performance // rollout_id -> flattened row private flattenedById: Record = {}; @@ -56,166 +68,103 @@ export class GlobalState { private createdAtMsById: Record = {}; // Debounce timers for localStorage saves and filter application - private savePivotConfigTimer: ReturnType | null = null; - private saveFilterConfigTimer: ReturnType | null = null; - private savePaginationConfigTimer: ReturnType | null = - null; + private saveGlobalConfigTimer: ReturnType | null = null; private applyFilterTimer: ReturnType | null = null; constructor() { - // Load pivot config from localStorage or use defaults - this.pivotConfig = this.loadPivotConfig(); - // Load filter config from localStorage or use defaults - this.filterConfig = this.loadFilterConfig(); + // Load global config from localStorage or use defaults + this.globalConfig = this.loadGlobalConfig(); // Initialize applied filter config with current value this.appliedFilterConfig = this.filterConfig.slice(); - // Load pagination config from localStorage or use defaults - const paginationConfig = this.loadPaginationConfig(); - this.currentPage = paginationConfig.currentPage; - this.pageSize = paginationConfig.pageSize; - // Load sort config from localStorage or use defaults - const sortConfig = this.loadSortConfig(); - this.sortField = sortConfig.sortField; - this.sortDirection = sortConfig.sortDirection; makeAutoObservable(this); + this.queryParamsWatcher = new QueryParamsWatcher(this); + + // Apply query params from URL if they exist + this.applyQueryParamsFromUrl(); } - // Load pivot configuration from localStorage - private loadPivotConfig(): PivotConfig { - try { - const stored = localStorage.getItem("pivotConfig"); - if (stored) { - const parsed = JSON.parse(stored); - // Merge with defaults to handle any missing properties - return { ...DEFAULT_PIVOT_CONFIG, ...parsed }; - } - } catch (error) { - console.warn("Failed to load pivot config from localStorage:", error); - } - return { ...DEFAULT_PIVOT_CONFIG }; + // Computed getters for individual configs + get pivotConfig(): PivotConfig { + return this.globalConfig.pivotConfig; } - // Load filter configuration from localStorage - private loadFilterConfig(): FilterGroup[] { - try { - const stored = localStorage.getItem("filterConfig"); - if (stored) { - const parsed = JSON.parse(stored); - return Array.isArray(parsed) ? parsed : DEFAULT_FILTER_CONFIG; - } - } catch (error) { - console.warn("Failed to load filter config from localStorage:", error); - } - return DEFAULT_FILTER_CONFIG; + get filterConfig(): FilterGroup[] { + return this.globalConfig.filterConfig; } - // Load pagination configuration from localStorage - private loadPaginationConfig() { - try { - const stored = localStorage.getItem("paginationConfig"); - if (stored) { - const parsed = JSON.parse(stored); - // Merge with defaults to handle any missing properties - return { ...DEFAULT_PAGINATION_CONFIG, ...parsed }; - } - } catch (error) { - console.warn( - "Failed to load pagination config from localStorage:", - error - ); - } - return DEFAULT_PAGINATION_CONFIG; + get paginationConfig(): PaginationConfig { + return this.globalConfig.paginationConfig; } - // Load sort configuration from localStorage - private loadSortConfig() { - try { - const stored = localStorage.getItem("sortConfig"); - if (stored) { - const parsed = JSON.parse(stored); - // Merge with defaults to handle any missing properties - return { ...DEFAULT_SORT_CONFIG, ...parsed }; - } - } catch (error) { - console.warn("Failed to load sort config from localStorage:", error); - } - return DEFAULT_SORT_CONFIG; + get sortConfig(): SortConfig { + return this.globalConfig.sortConfig; } - // Save pivot configuration to localStorage - private savePivotConfig() { - if (this.savePivotConfigTimer) clearTimeout(this.savePivotConfigTimer); - this.savePivotConfigTimer = setTimeout(() => { - try { - localStorage.setItem("pivotConfig", JSON.stringify(this.pivotConfig)); - } catch (error) { - console.warn("Failed to save pivot config to localStorage:", error); - } - }, 200); + // Computed getters for individual pagination properties + get currentPage(): number { + return this.paginationConfig.currentPage; } - // Save filter configuration to localStorage - private saveFilterConfig() { - if (this.saveFilterConfigTimer) clearTimeout(this.saveFilterConfigTimer); - this.saveFilterConfigTimer = setTimeout(() => { - try { - localStorage.setItem("filterConfig", JSON.stringify(this.filterConfig)); - } catch (error) { - console.warn("Failed to save filter config to localStorage:", error); - } - }, 200); + get pageSize(): number { + return this.paginationConfig.pageSize; } - // Save pagination configuration to localStorage - private savePaginationConfig() { - if (this.savePaginationConfigTimer) - clearTimeout(this.savePaginationConfigTimer); - this.savePaginationConfigTimer = setTimeout(() => { - try { - localStorage.setItem( - "paginationConfig", - JSON.stringify({ - currentPage: this.currentPage, - pageSize: this.pageSize, - }) - ); - } catch (error) { - console.warn( - "Failed to save pagination config to localStorage:", - error - ); + // Computed getters for individual sort properties + get sortField(): string { + return this.sortConfig.sortField; + } + + get sortDirection(): SortDirection { + return this.sortConfig.sortDirection; + } + + // Load global configuration from localStorage + private loadGlobalConfig(): GlobalConfig { + try { + const stored = localStorage.getItem("globalConfig"); + if (stored) { + const parsed = JSON.parse(stored); + // Merge with defaults to handle any missing properties + return { + pivotConfig: { ...DEFAULT_PIVOT_CONFIG, ...parsed.pivotConfig }, + filterConfig: Array.isArray(parsed.filterConfig) + ? parsed.filterConfig + : DEFAULT_FILTER_CONFIG, + paginationConfig: { + ...DEFAULT_PAGINATION_CONFIG, + ...parsed.paginationConfig, + }, + sortConfig: { ...DEFAULT_SORT_CONFIG, ...parsed.sortConfig }, + }; } - }, 200); + } catch (error) { + console.warn("Failed to load global config from localStorage:", error); + } + return { ...DEFAULT_GLOBAL_CONFIG }; } - // Save sort configuration to localStorage - private saveSortConfig() { - if (this.saveFilterConfigTimer) clearTimeout(this.saveFilterConfigTimer); - this.saveFilterConfigTimer = setTimeout(() => { + // Save global configuration to localStorage + private saveGlobalConfig() { + if (this.saveGlobalConfigTimer) clearTimeout(this.saveGlobalConfigTimer); + this.saveGlobalConfigTimer = setTimeout(() => { try { - localStorage.setItem( - "sortConfig", - JSON.stringify({ - sortField: this.sortField, - sortDirection: this.sortDirection, - }) - ); + localStorage.setItem("globalConfig", JSON.stringify(this.globalConfig)); } catch (error) { - console.warn("Failed to save sort config to localStorage:", error); + console.warn("Failed to save global config to localStorage:", error); } }, 200); } // Update pivot configuration and save to localStorage updatePivotConfig(updates: Partial) { - Object.assign(this.pivotConfig, updates); - this.savePivotConfig(); + Object.assign(this.globalConfig.pivotConfig, updates); + this.saveGlobalConfig(); } // Update filter configuration and save to localStorage updateFilterConfig(filters: FilterGroup[]) { - this.filterConfig = filters; - this.saveFilterConfig(); + this.globalConfig.filterConfig = filters; + this.saveGlobalConfig(); // Debounce application of filters to avoid re-filtering on every keystroke if (this.applyFilterTimer) clearTimeout(this.applyFilterTimer); @@ -225,79 +174,69 @@ export class GlobalState { } // Update pagination configuration and save to localStorage - updatePaginationConfig( - updates: Partial<{ currentPage: number; pageSize: number }> - ) { - if (updates.currentPage !== undefined) { - this.currentPage = updates.currentPage; - } - if (updates.pageSize !== undefined) { - this.pageSize = updates.pageSize; - } - this.savePaginationConfig(); + updatePaginationConfig(updates: Partial) { + Object.assign(this.globalConfig.paginationConfig, updates); + this.saveGlobalConfig(); } // Update sort configuration and save to localStorage - updateSortConfig( - updates: Partial<{ sortField: string; sortDirection: "asc" | "desc" }> - ) { - Object.assign(this, updates); + updateSortConfig(updates: Partial) { + Object.assign(this.globalConfig.sortConfig, updates); // Reset to first page when sorting changes - this.currentPage = 1; - this.saveSortConfig(); + this.globalConfig.paginationConfig.currentPage = 1; + this.saveGlobalConfig(); } // Handle sort field click - toggle direction if same field, set to asc if new field handleSortFieldClick(field: string) { - if (this.sortField === field) { + if (this.sortConfig.sortField === field) { // Toggle direction for same field - this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; + this.globalConfig.sortConfig.sortDirection = + this.sortConfig.sortDirection === "asc" ? "desc" : "asc"; } else { // New field, set to ascending - this.sortField = field; - this.sortDirection = "asc"; + this.globalConfig.sortConfig.sortField = field; + this.globalConfig.sortConfig.sortDirection = "asc"; } - this.saveSortConfig(); + this.saveGlobalConfig(); } // Reset pivot configuration to defaults resetPivotConfig() { - this.pivotConfig = { ...DEFAULT_PIVOT_CONFIG }; - this.savePivotConfig(); + this.globalConfig.pivotConfig = { ...DEFAULT_PIVOT_CONFIG }; + this.saveGlobalConfig(); } // Reset filter configuration to defaults resetFilterConfig() { - this.filterConfig = [...DEFAULT_FILTER_CONFIG]; + this.globalConfig.filterConfig = [...DEFAULT_FILTER_CONFIG]; this.appliedFilterConfig = [...DEFAULT_FILTER_CONFIG]; - this.saveFilterConfig(); + this.saveGlobalConfig(); } // Reset pagination configuration to defaults resetPaginationConfig() { - this.currentPage = DEFAULT_PAGINATION_CONFIG.currentPage; - this.pageSize = DEFAULT_PAGINATION_CONFIG.pageSize; - this.savePaginationConfig(); + this.globalConfig.paginationConfig = { ...DEFAULT_PAGINATION_CONFIG }; + this.saveGlobalConfig(); } // Reset sort configuration to defaults resetSortConfig() { - this.sortField = DEFAULT_SORT_CONFIG.sortField; - this.sortDirection = DEFAULT_SORT_CONFIG.sortDirection; - this.saveSortConfig(); + this.globalConfig.sortConfig = { ...DEFAULT_SORT_CONFIG }; + this.saveGlobalConfig(); } // Set current page setCurrentPage(page: number) { - this.currentPage = page; - this.savePaginationConfig(); + this.globalConfig.paginationConfig.currentPage = page; + this.saveGlobalConfig(); } // Set page size setPageSize(size: number) { - this.pageSize = size; - this.currentPage = 1; // Reset to first page when changing page size - this.savePaginationConfig(); + this.globalConfig.paginationConfig.pageSize = size; + this.globalConfig.paginationConfig.currentPage = 1; // Reset to first page when changing page size + this.saveGlobalConfig(); } // Set loading state @@ -310,6 +249,49 @@ export class GlobalState { this.isConnected = connected; } + // Apply query params to global configuration + applyQueryParams(queryParams: Record) { + debugger; + const partialConfig = queryParamsToPartialConfig(queryParams); + + // Apply each section of the partial config + if (partialConfig.pivotConfig) { + this.updatePivotConfig(partialConfig.pivotConfig); + } + + if (partialConfig.filterConfig) { + this.updateFilterConfig(partialConfig.filterConfig); + } + + if (partialConfig.paginationConfig) { + this.updatePaginationConfig(partialConfig.paginationConfig); + } + + if (partialConfig.sortConfig) { + this.updateSortConfig(partialConfig.sortConfig); + } + } + + // Extract query params from URL and apply them to global configuration + private applyQueryParamsFromUrl() { + if (typeof window === "undefined") { + return; // Skip on server-side rendering + } + + const urlParams = new URLSearchParams(window.location.search); + const queryParams: Record = {}; + + // Convert URLSearchParams to Record + for (const [key, value] of urlParams.entries()) { + queryParams[key] = value; + } + + // Only apply if there are query params + if (Object.keys(queryParams).length > 0) { + this.applyQueryParams(queryParams); + } + } + upsertRows(dataset: EvaluationRow[]) { runInAction(() => { this.isLoading = true; @@ -330,10 +312,10 @@ export class GlobalState { runInAction(() => { // Reset to first page when dataset changes - this.currentPage = 1; + this.globalConfig.paginationConfig.currentPage = 1; this.isLoading = false; }); - this.savePaginationConfig(); + this.saveGlobalConfig(); } toggleRowExpansion(rolloutId?: string) { @@ -364,12 +346,14 @@ export class GlobalState { get sortedIds() { const ids = Object.keys(this.dataset); - if (this.sortField === "created_at") { + if (this.sortConfig.sortField === "created_at") { // Special case for created_at - use cached timestamp return ids.sort((a, b) => { const aTime = this.createdAtMsById[a] ?? 0; const bTime = this.createdAtMsById[b] ?? 0; - return this.sortDirection === "asc" ? aTime - bTime : bTime - aTime; + return this.sortConfig.sortDirection === "asc" + ? aTime - bTime + : bTime - aTime; }); } @@ -380,29 +364,35 @@ export class GlobalState { if (!aFlat || !bFlat) return 0; - const aValue = aFlat[this.sortField]; - const bValue = bFlat[this.sortField]; + const aValue = aFlat[this.sortConfig.sortField]; + const bValue = bFlat[this.sortConfig.sortField]; // Handle undefined values if (aValue === undefined && bValue === undefined) return 0; - if (aValue === undefined) return this.sortDirection === "asc" ? -1 : 1; - if (bValue === undefined) return this.sortDirection === "asc" ? 1 : -1; + if (aValue === undefined) + return this.sortConfig.sortDirection === "asc" ? -1 : 1; + if (bValue === undefined) + return this.sortConfig.sortDirection === "asc" ? 1 : -1; // Handle different types if (typeof aValue === "string" && typeof bValue === "string") { const comparison = aValue.localeCompare(bValue); - return this.sortDirection === "asc" ? comparison : -comparison; + return this.sortConfig.sortDirection === "asc" + ? comparison + : -comparison; } if (typeof aValue === "number" && typeof bValue === "number") { - return this.sortDirection === "asc" ? aValue - bValue : bValue - aValue; + return this.sortConfig.sortDirection === "asc" + ? aValue - bValue + : bValue - aValue; } // Fallback to string comparison const aStr = String(aValue); const bStr = String(bValue); const comparison = aStr.localeCompare(bStr); - return this.sortDirection === "asc" ? comparison : -comparison; + return this.sortConfig.sortDirection === "asc" ? comparison : -comparison; }); } @@ -451,14 +441,20 @@ export class GlobalState { } get totalPages() { - return Math.ceil(this.totalCount / this.pageSize); + return Math.ceil(this.totalCount / this.paginationConfig.pageSize); } get startRow() { - return (this.currentPage - 1) * this.pageSize + 1; + return ( + (this.paginationConfig.currentPage - 1) * this.paginationConfig.pageSize + + 1 + ); } get endRow() { - return Math.min(this.currentPage * this.pageSize, this.totalCount); + return Math.min( + this.paginationConfig.currentPage * this.paginationConfig.pageSize, + this.totalCount + ); } } diff --git a/vite-app/src/components/Dashboard.tsx b/vite-app/src/components/Dashboard.tsx index ace28316..f7c5a8c4 100644 --- a/vite-app/src/components/Dashboard.tsx +++ b/vite-app/src/components/Dashboard.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react"; import { useState, useEffect } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { state } from "../App"; +import { useQueryParamsSync } from "../util/query-params"; import Button from "./Button"; import { EvaluationTable } from "./EvaluationTable"; import PivotTab from "./PivotTab"; @@ -74,6 +75,9 @@ const Dashboard = observer(({ onRefresh }: DashboardProps) => { const location = useLocation(); const navigate = useNavigate(); + // Use the query params sync hook + useQueryParamsSync(state.queryParamsWatcher); + const deriveTabFromPath = (path: string): "table" | "pivot" => path.endsWith("/pivot") ? "pivot" : "table"; @@ -103,7 +107,13 @@ const Dashboard = observer(({ onRefresh }: DashboardProps) => { isActive={activeTab === "table"} onClick={() => { setActiveTab("table"); - navigate("/table"); + navigate( + { + pathname: "/table", + search: location.search, + }, + { replace: true } + ); }} title="View table" /> @@ -112,7 +122,13 @@ const Dashboard = observer(({ onRefresh }: DashboardProps) => { isActive={activeTab === "pivot"} onClick={() => { setActiveTab("pivot"); - navigate("/pivot"); + navigate( + { + pathname: "/pivot", + search: location.search, + }, + { replace: true } + ); }} title="View pivot" /> diff --git a/vite-app/src/components/EvaluationRow.tsx b/vite-app/src/components/EvaluationRow.tsx index c6a0c015..9fc2f9b6 100644 --- a/vite-app/src/components/EvaluationRow.tsx +++ b/vite-app/src/components/EvaluationRow.tsx @@ -9,7 +9,7 @@ import StatusIndicator from "./StatusIndicator"; import { state } from "../App"; import { TableCell, TableRowInteractive } from "./TableContainer"; import { useState } from "react"; -import type { FilterGroup, FilterConfig } from "../types/filters"; +import type { FilterGroup, FilterConfig } from "../types/configs"; import { Tooltip } from "./Tooltip"; import { JSONTooltip } from "./JSONTooltip"; diff --git a/vite-app/src/components/FilterInput.tsx b/vite-app/src/components/FilterInput.tsx index c6d03f5a..36959c4d 100644 --- a/vite-app/src/components/FilterInput.tsx +++ b/vite-app/src/components/FilterInput.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import type { FilterConfig } from "../types/filters"; +import type { FilterConfig } from "../types/configs"; import { commonStyles } from "../styles/common"; interface FilterInputProps { diff --git a/vite-app/src/components/FilterSelector.tsx b/vite-app/src/components/FilterSelector.tsx index 2800063d..8726314e 100644 --- a/vite-app/src/components/FilterSelector.tsx +++ b/vite-app/src/components/FilterSelector.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from "react"; -import type { FilterConfig, FilterGroup } from "../types/filters"; +import type { FilterConfig, FilterGroup, FilterLogic } from "../types/configs"; import SearchableSelect from "./SearchableSelect"; import FilterInput from "./FilterInput"; import Button from "./Button"; @@ -30,7 +30,7 @@ const FilterSelectorComponent = ({ ); const updateFilterGroupLogic = useCallback( - (index: number, logic: "AND" | "OR") => { + (index: number, logic: FilterLogic) => { const newFilters = [...filters]; newFilters[index] = { ...newFilters[index], logic }; onFiltersChange(newFilters); @@ -110,7 +110,7 @@ const FilterSelectorComponent = ({ - updateFilterGroupLogic(groupIndex, value as "AND" | "OR") + updateFilterGroupLogic(groupIndex, value as FilterLogic) } options={[ { value: "AND", label: "AND (all filters must match)" }, diff --git a/vite-app/src/components/PivotTab.tsx b/vite-app/src/components/PivotTab.tsx index 27a6259b..c4259b7f 100644 --- a/vite-app/src/components/PivotTab.tsx +++ b/vite-app/src/components/PivotTab.tsx @@ -5,7 +5,7 @@ import ChartExport from "./ChartExport"; import SearchableSelect from "./SearchableSelect"; import Button from "./Button"; import FilterSelector from "./FilterSelector"; -import { type FilterGroup } from "../types/filters"; +import { type FilterGroup } from "../types/configs"; import { usePivotData } from "../hooks/usePivotData"; import { createFieldHandlerSet, diff --git a/vite-app/src/components/TableContainer.tsx b/vite-app/src/components/TableContainer.tsx index 0b04964e..fe368e9c 100644 --- a/vite-app/src/components/TableContainer.tsx +++ b/vite-app/src/components/TableContainer.tsx @@ -1,4 +1,5 @@ import React from "react"; +import type { SortDirection } from "../types/configs"; export interface TableContainerProps { /** @@ -42,7 +43,7 @@ export interface SortableTableHeaderProps extends TableHeaderProps { /** * Current sort direction */ - currentSortDirection: "asc" | "desc"; + currentSortDirection: SortDirection; /** * Click handler for sorting */ diff --git a/vite-app/src/types/configs.ts b/vite-app/src/types/configs.ts new file mode 100644 index 00000000..6cb4dfb3 --- /dev/null +++ b/vite-app/src/types/configs.ts @@ -0,0 +1,61 @@ +export type Operator = + | "==" + | "!=" + | ">" + | "<" + | ">=" + | "<=" + | "contains" + | "!contains" + | "between"; + +export type FilterType = "text" | "date" | "date-range"; + +// Filter configuration interface +export interface FilterConfig { + field: string; + operator: Operator; + value: string; + value2?: string; // For filtering between dates + type?: FilterType; +} + +export type FilterOperator = { + value: Operator; + label: string; +}; + +export type FilterLogic = "AND" | "OR"; + +// Filter group interface for AND/OR logic +export interface FilterGroup { + logic: FilterLogic; + filters: FilterConfig[]; +} + +// Pivot configuration interface +export interface PivotConfig { + selectedRowFields: string[]; + selectedColumnFields: string[]; + selectedValueField: string; + selectedAggregator: string; +} + +export interface PaginationConfig { + currentPage: number; + pageSize: number; +} + +export type SortDirection = "asc" | "desc"; + +export interface SortConfig { + sortField: string; + sortDirection: SortDirection; +} + +export interface GlobalConfig { + pivotConfig: PivotConfig; + filterConfig: FilterGroup[]; + paginationConfig: PaginationConfig; + sortConfig: SortConfig; +} diff --git a/vite-app/src/types/filters.ts b/vite-app/src/types/filters.ts deleted file mode 100644 index c73bf61f..00000000 --- a/vite-app/src/types/filters.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type Operator = "==" | "!=" | ">" | "<" | ">=" | "<=" | "contains" | "!contains" | "between"; - -// Filter configuration interface -export interface FilterConfig { - field: string; - operator: Operator; - value: string; - value2?: string; // For filtering between dates - type?: "text" | "date" | "date-range"; -} - -export type FilterOperator = { - value: Operator; - label: string; -}; - -// Filter group interface for AND/OR logic -export interface FilterGroup { - logic: "AND" | "OR"; - filters: FilterConfig[]; -} - -// Pivot configuration interface -export interface PivotConfig { - selectedRowFields: string[]; - selectedColumnFields: string[]; - selectedValueField: string; - selectedAggregator: string; -} diff --git a/vite-app/src/util/field-processors.ts b/vite-app/src/util/field-processors.ts index e1851f15..ee752efd 100644 --- a/vite-app/src/util/field-processors.ts +++ b/vite-app/src/util/field-processors.ts @@ -1,6 +1,6 @@ import { state } from '../App'; import { createFilterFunction as createFilterFunctionUtil } from '../util/filter-utils'; -import { type FilterGroup } from '../types/filters'; +import { type FilterGroup } from '../types/configs'; /** * Utility functions for processing field configurations and creating handlers diff --git a/vite-app/src/util/filter-utils.ts b/vite-app/src/util/filter-utils.ts index 7a311476..9ab2a77e 100644 --- a/vite-app/src/util/filter-utils.ts +++ b/vite-app/src/util/filter-utils.ts @@ -1,4 +1,9 @@ -import type { FilterConfig, FilterGroup, FilterOperator } from "../types/filters"; +import type { + FilterConfig, + FilterGroup, + FilterOperator, + FilterType, +} from "../types/configs"; // Filter utilities export const isDateField = (field: string): boolean => { @@ -10,11 +15,14 @@ export const isDateField = (field: string): boolean => { ); }; -export const getFieldType = (field: string): "text" | "date" | "date-range" => { +export const getFieldType = (field: string): FilterType => { return isDateField(field) ? "date" : "text"; }; -export const getOperatorsForField = (field: string, type?: string): FilterOperator[] => { +export const getOperatorsForField = ( + field: string, + type?: string +): FilterOperator[] => { if (type === "date" || type === "date-range" || isDateField(field)) { return [ { value: ">=", label: "on or after" }, diff --git a/vite-app/src/util/pivot.test.ts b/vite-app/src/util/pivot.test.ts index 558208b7..b2d45fa6 100644 --- a/vite-app/src/util/pivot.test.ts +++ b/vite-app/src/util/pivot.test.ts @@ -1,402 +1,406 @@ -import { describe, it, expect } from 'vitest' -import { computePivot, type Aggregator } from './pivot' -import { readFileSync } from 'fs' -import flattenJson, { type FlatJson } from './flatten-json' +import { describe, it, expect } from "vitest"; +import { computePivot, type Aggregator } from "./pivot"; +import { readFileSync } from "fs"; +import flattenJson, { type FlatJson } from "./flatten-json"; type Row = { - region: string - rep: string - product: string - amount?: number | string -} + region: string; + rep: string; + product: string; + amount?: number | string; +}; const rows: Row[] = [ - { region: 'West', rep: 'A', product: 'Widget', amount: 120 }, - { region: 'West', rep: 'B', product: 'Gadget', amount: 90 }, - { region: 'East', rep: 'A', product: 'Widget', amount: 200 }, - { region: 'East', rep: 'B', product: 'Gadget', amount: '10' }, - { region: 'East', rep: 'B', product: 'Gadget', amount: 'not-a-number' }, - -] - -describe('computePivot', () => { - it('computes count when no valueField provided', () => { + { region: "West", rep: "A", product: "Widget", amount: 120 }, + { region: "West", rep: "B", product: "Gadget", amount: 90 }, + { region: "East", rep: "A", product: "Widget", amount: 200 }, + { region: "East", rep: "B", product: "Gadget", amount: "10" }, + { region: "East", rep: "B", product: "Gadget", amount: "not-a-number" }, +]; + +describe("computePivot", () => { + it("computes count when no valueField provided", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - }) + rowFields: ["region"], + columnFields: ["product"], + }); // Expect two row keys and two column keys - expect(res.rowKeyTuples.map((t) => String(t))).toEqual([ - 'East', - 'West', - ]) + expect(res.rowKeyTuples.map((t) => String(t))).toEqual(["East", "West"]); expect(res.colKeyTuples.map((t) => String(t))).toEqual([ - 'Gadget', - 'Widget', - ]) + "Gadget", + "Widget", + ]); // East/Gadget should count two (one invalid amount ignored in count mode) - const rKeyEast = 'East' - const cKeyGadget = 'Gadget' - expect(res.cells[rKeyEast][cKeyGadget].value).toBe(2) - }) + const rKeyEast = "East"; + const cKeyGadget = "Gadget"; + expect(res.cells[rKeyEast][cKeyGadget].value).toBe(2); + }); - it('computes sum aggregator', () => { + it("computes sum aggregator", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', - aggregator: 'sum', - }) + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", + aggregator: "sum", + }); - const rKeyEast = 'East' - const rKeyWest = 'West' - const cKeyGadget = 'Gadget' - const cKeyWidget = 'Widget' + const rKeyEast = "East"; + const rKeyWest = "West"; + const cKeyGadget = "Gadget"; + const cKeyWidget = "Widget"; // East Gadget: 10 (string convertible) + invalid -> 10 - expect(res.cells[rKeyEast][cKeyGadget].value).toBe(10) + expect(res.cells[rKeyEast][cKeyGadget].value).toBe(10); // West Gadget: 90 - expect(res.cells[rKeyWest][cKeyGadget].value).toBe(90) + expect(res.cells[rKeyWest][cKeyGadget].value).toBe(90); // East Widget: 200 - expect(res.cells[rKeyEast][cKeyWidget].value).toBe(200) + expect(res.cells[rKeyEast][cKeyWidget].value).toBe(200); // West Widget: 120 - expect(res.cells[rKeyWest][cKeyWidget].value).toBe(120) - }) + expect(res.cells[rKeyWest][cKeyWidget].value).toBe(120); + }); - it('computes average aggregator', () => { + it("computes average aggregator", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', - aggregator: 'avg', - }) + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", + aggregator: "avg", + }); - const rKeyEast = 'East' - const rKeyWest = 'West' - const cKeyGadget = 'Gadget' + const rKeyEast = "East"; + const rKeyWest = "West"; + const cKeyGadget = "Gadget"; // East Gadget: values -> [10] => avg 10 - expect(res.cells[rKeyEast][cKeyGadget].value).toBe(10) + expect(res.cells[rKeyEast][cKeyGadget].value).toBe(10); // West Gadget: values -> [90] => avg 90 - expect(res.cells[rKeyWest][cKeyGadget].value).toBe(90) - }) + expect(res.cells[rKeyWest][cKeyGadget].value).toBe(90); + }); - it('computes minimum aggregator', () => { + it("computes minimum aggregator", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', - aggregator: 'min', - }) + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", + aggregator: "min", + }); - const rKeyEast = 'East' - const rKeyWest = 'West' - const cKeyGadget = 'Gadget' - const cKeyWidget = 'Widget' + const rKeyEast = "East"; + const rKeyWest = "West"; + const cKeyGadget = "Gadget"; + const cKeyWidget = "Widget"; // East Gadget: values -> [10] => min 10 - expect(res.cells[rKeyEast][cKeyGadget].value).toBe(10) + expect(res.cells[rKeyEast][cKeyGadget].value).toBe(10); // West Gadget: values -> [90] => min 90 - expect(res.cells[rKeyWest][cKeyGadget].value).toBe(90) + expect(res.cells[rKeyWest][cKeyGadget].value).toBe(90); // East Widget: values -> [200] => min 200 - expect(res.cells[rKeyEast][cKeyWidget].value).toBe(200) + expect(res.cells[rKeyEast][cKeyWidget].value).toBe(200); // West Widget: values -> [120] => min 120 - expect(res.cells[rKeyWest][cKeyWidget].value).toBe(120) - }) + expect(res.cells[rKeyWest][cKeyWidget].value).toBe(120); + }); - it('computes maximum aggregator', () => { + it("computes maximum aggregator", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', - aggregator: 'max', - }) + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", + aggregator: "max", + }); - const rKeyEast = 'East' - const rKeyWest = 'West' - const cKeyGadget = 'Gadget' - const cKeyWidget = 'Widget' + const rKeyEast = "East"; + const rKeyWest = "West"; + const cKeyGadget = "Gadget"; + const cKeyWidget = "Widget"; // East Gadget: values -> [10] => max 10 - expect(res.cells[rKeyEast][cKeyGadget].value).toBe(10) + expect(res.cells[rKeyEast][cKeyGadget].value).toBe(10); // West Gadget: values -> [90] => max 90 - expect(res.cells[rKeyWest][cKeyGadget].value).toBe(90) + expect(res.cells[rKeyWest][cKeyGadget].value).toBe(90); // East Widget: values -> [200] => max 200 - expect(res.cells[rKeyEast][cKeyWidget].value).toBe(200) + expect(res.cells[rKeyEast][cKeyWidget].value).toBe(200); // West Widget: values -> [120] => max 120 - expect(res.cells[rKeyWest][cKeyWidget].value).toBe(120) - }) + expect(res.cells[rKeyWest][cKeyWidget].value).toBe(120); + }); - it('handles empty cells for min/max aggregators', () => { + it("handles empty cells for min/max aggregators", () => { // Add a row with no valid numeric values const rowsWithEmpty = [ ...rows, - { region: 'North', rep: 'C', product: 'Widget', amount: 'not-a-number' }, - { region: 'North', rep: 'D', product: 'Gadget', amount: 'also-not-a-number' }, - ] + { region: "North", rep: "C", product: "Widget", amount: "not-a-number" }, + { + region: "North", + rep: "D", + product: "Gadget", + amount: "also-not-a-number", + }, + ]; const res = computePivot({ data: rowsWithEmpty, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', - aggregator: 'min', - }) + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", + aggregator: "min", + }); - const rKeyNorth = 'North' - const cKeyWidget = 'Widget' - const cKeyGadget = 'Gadget' + const rKeyNorth = "North"; + const cKeyWidget = "Widget"; + const cKeyGadget = "Gadget"; // North region has no valid numeric values, should return 0 for min - expect(res.cells[rKeyNorth][cKeyWidget].value).toBe(0) - expect(res.cells[rKeyNorth][cKeyGadget].value).toBe(0) - }) + expect(res.cells[rKeyNorth][cKeyWidget].value).toBe(0); + expect(res.cells[rKeyNorth][cKeyGadget].value).toBe(0); + }); - it('applies filter before pivoting', () => { + it("applies filter before pivoting", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', - aggregator: 'sum', - filter: (record) => record.region === 'East', // Only include East region - }) + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", + aggregator: "sum", + filter: (record) => record.region === "East", // Only include East region + }); // Should only have East region rows - expect(res.rowKeyTuples.map((t) => String(t))).toEqual(['East']) + expect(res.rowKeyTuples.map((t) => String(t))).toEqual(["East"]); // Should still have all product columns - expect(res.colKeyTuples.map((t) => String(t))).toEqual(['Gadget', 'Widget']) + expect(res.colKeyTuples.map((t) => String(t)).sort()).toEqual( + ["Gadget", "Widget"].sort() + ); // East Gadget: 10 (string convertible) + invalid -> 10 - expect(res.cells['East']['Gadget'].value).toBe(10) + expect(res.cells["East"]["Gadget"].value).toBe(10); // East Widget: 200 - expect(res.cells['East']['Widget'].value).toBe(200) + expect(res.cells["East"]["Widget"].value).toBe(200); // West region should not be present - expect(res.cells['West']).toBeUndefined() + expect(res.cells["West"]).toBeUndefined(); // Grand total should only include East region data - expect(res.grandTotal).toBe(210) // 10 + 200 - }) + expect(res.grandTotal).toBe(210); // 10 + 200 + }); - it('column totals use same aggregation method as cells', () => { + it("column totals use same aggregation method as cells", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', - aggregator: 'avg', - }) + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", + aggregator: "avg", + }); // Row totals should sum the cell values (for display purposes) - expect(res.rowTotals['East']).toBe(105) // (10 + 200) / 2 = 105 - expect(res.rowTotals['West']).toBe(105) // (90 + 120) / 2 = 105 + expect(res.rowTotals["East"]).toBe(105); // (10 + 200) / 2 = 105 + expect(res.rowTotals["West"]).toBe(105); // (90 + 120) / 2 = 105 // Column totals should use the same aggregation method (avg) over all records in that column // Gadget column: values [90, 10] -> avg = 50 - expect(res.colTotals['Gadget']).toBe(50) + expect(res.colTotals["Gadget"]).toBe(50); // Widget column: values [120, 200] -> avg = 160 - expect(res.colTotals['Widget']).toBe(160) + expect(res.colTotals["Widget"]).toBe(160); // Grand total should also use avg over all records - expect(res.grandTotal).toBe(105) // (90 + 10 + 120 + 200) / 4 = 105 - }) + expect(res.grandTotal).toBe(105); // (90 + 10 + 120 + 200) / 4 = 105 + }); - it('column totals with count aggregator', () => { + it("column totals with count aggregator", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - aggregator: 'count', // No valueField, just count records - }) + rowFields: ["region"], + columnFields: ["product"], + aggregator: "count", // No valueField, just count records + }); // Each cell should count records - expect(res.cells['East']['Gadget'].value).toBe(2) // 2 records - expect(res.cells['East']['Widget'].value).toBe(1) // 1 record - expect(res.cells['West']['Gadget'].value).toBe(1) // 1 record - expect(res.cells['West']['Widget'].value).toBe(1) // 1 record + expect(res.cells["East"]["Gadget"].value).toBe(2); // 2 records + expect(res.cells["East"]["Widget"].value).toBe(1); // 1 record + expect(res.cells["West"]["Gadget"].value).toBe(1); // 1 record + expect(res.cells["West"]["Widget"].value).toBe(1); // 1 record // Row totals should sum the counts - expect(res.rowTotals['East']).toBe(3) // 2 + 1 - expect(res.rowTotals['West']).toBe(2) // 1 + 1 + expect(res.rowTotals["East"]).toBe(3); // 2 + 1 + expect(res.rowTotals["West"]).toBe(2); // 1 + 1 // Column totals should count total records in each column - expect(res.colTotals['Gadget']).toBe(3) // 2 + 1 = 3 records - expect(res.colTotals['Widget']).toBe(2) // 1 + 1 = 2 records + expect(res.colTotals["Gadget"]).toBe(3); // 2 + 1 = 3 records + expect(res.colTotals["Widget"]).toBe(2); // 1 + 1 = 2 records // Grand total should count all records - expect(res.grandTotal).toBe(5) // Total records - }) + expect(res.grandTotal).toBe(5); // Total records + }); - it('supports custom aggregator', () => { + it("supports custom aggregator", () => { const maxAgg: Aggregator = (values) => - values.length ? Math.max(...values) : 0 + values.length ? Math.max(...values) : 0; const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", aggregator: maxAgg, - }) + }); - const rKeyWest = 'West' - const cKeyWidget = 'Widget' - expect(res.cells[rKeyWest][cKeyWidget].value).toBe(120) - }) + const rKeyWest = "West"; + const cKeyWidget = "Widget"; + expect(res.cells[rKeyWest][cKeyWidget].value).toBe(120); + }); - it('supports multiple column fields (composite columns)', () => { + it("supports multiple column fields (composite columns)", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product', 'rep'], - valueField: 'amount', - aggregator: 'sum', - }) + rowFields: ["region"], + columnFields: ["product", "rep"], + valueField: "amount", + aggregator: "sum", + }); // Row and column key tuples - expect(res.rowKeyTuples).toEqual([ - ['East'], - ['West'], - ]) - expect(res.colKeyTuples).toEqual([ - ['Gadget', 'B'], - ['Widget', 'A'], - ]) - - const rEast = 'East' - const rWest = 'West' - const cGadgetB = 'Gadget||B' - const cWidgetA = 'Widget||A' + expect(res.rowKeyTuples).toEqual([["East"], ["West"]]); + // Normalize order for comparison since columns are sorted by aggregate score + const expectedColTuples = [ + ["Gadget", "B"], + ["Widget", "A"], + ]; + expect(res.colKeyTuples.sort()).toEqual(expectedColTuples.sort()); + + const rEast = "East"; + const rWest = "West"; + const cGadgetB = "Gadget||B"; + const cWidgetA = "Widget||A"; // Cell values (sum of numeric amounts) - expect(res.cells[rEast][cGadgetB].value).toBe(10) - expect(res.cells[rWest][cGadgetB].value).toBe(90) - expect(res.cells[rEast][cWidgetA].value).toBe(200) - expect(res.cells[rWest][cWidgetA].value).toBe(120) + expect(res.cells[rEast][cGadgetB].value).toBe(10); + expect(res.cells[rWest][cGadgetB].value).toBe(90); + expect(res.cells[rEast][cWidgetA].value).toBe(200); + expect(res.cells[rWest][cWidgetA].value).toBe(120); // Totals - expect(res.rowTotals[rEast]).toBe(210) - expect(res.rowTotals[rWest]).toBe(210) - expect(res.colTotals[cGadgetB]).toBe(100) - expect(res.colTotals[cWidgetA]).toBe(320) - expect(res.grandTotal).toBe(420) - }) - - it('skips records with undefined row field values', () => { + expect(res.rowTotals[rEast]).toBe(210); + expect(res.rowTotals[rWest]).toBe(210); + expect(res.colTotals[cGadgetB]).toBe(100); + expect(res.colTotals[cWidgetA]).toBe(320); + expect(res.grandTotal).toBe(420); + }); + + it("skips records with undefined row field values", () => { type LooseRow = { - region?: string - rep?: string - product?: string - amount?: number | string - } + region?: string; + rep?: string; + product?: string; + amount?: number | string; + }; const mixed: LooseRow[] = [ - { region: 'West', rep: 'A', product: 'Widget', amount: 120 }, + { region: "West", rep: "A", product: "Widget", amount: 120 }, // Missing region should be excluded from cells entirely - { rep: 'B', product: 'Gadget', amount: 90 }, - ] + { rep: "B", product: "Gadget", amount: 90 }, + ]; const res = computePivot({ data: mixed, - rowFields: ['region'], - columnFields: ['product'], - }) + rowFields: ["region"], + columnFields: ["product"], + }); // Only 'West' row should be present; no 'undefined' row key - expect(res.rowKeyTuples.map((t) => String(t))).toEqual(['West']) - expect(Object.keys(res.cells)).toEqual(['West']) + expect(res.rowKeyTuples.map((t) => String(t))).toEqual(["West"]); + expect(Object.keys(res.cells)).toEqual(["West"]); - const rKeyWest = 'West' - const cKeyWidget = 'Widget' + const rKeyWest = "West"; + const cKeyWidget = "Widget"; // Count aggregator by default; only the valid record should be counted - expect(res.cells[rKeyWest][cKeyWidget].value).toBe(1) + expect(res.cells[rKeyWest][cKeyWidget].value).toBe(1); // Grand total reflects only included records - expect(res.grandTotal).toBe(1) - }) + expect(res.grandTotal).toBe(1); + }); - it('skips records with undefined column field values', () => { + it("skips records with undefined column field values", () => { type LooseRow = { - region: string - rep?: string - product?: string - amount?: number | string - } + region: string; + rep?: string; + product?: string; + amount?: number | string; + }; const mixed: LooseRow[] = [ - { region: 'West', rep: 'A', product: 'Widget', amount: 120 }, + { region: "West", rep: "A", product: "Widget", amount: 120 }, // Missing product should be excluded entirely (no 'undefined' column) - { region: 'West', rep: 'B', amount: 90 }, - { region: 'East', rep: 'B', product: 'Gadget', amount: 10 }, - ] + { region: "West", rep: "B", amount: 90 }, + { region: "East", rep: "B", product: "Gadget", amount: 10 }, + ]; const res = computePivot({ data: mixed, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', - aggregator: 'sum', - }) + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", + aggregator: "sum", + }); // Columns should not contain 'undefined' - expect(res.colKeyTuples.map((t) => String(t))).toEqual(['Gadget', 'Widget']) + expect(res.colKeyTuples.map((t) => String(t)).sort()).toEqual( + ["Gadget", "Widget"].sort() + ); - const rWest = 'West' - const rEast = 'East' - const cWidget = 'Widget' - const cGadget = 'Gadget' + const rWest = "West"; + const rEast = "East"; + const cWidget = "Widget"; + const cGadget = "Gadget"; // Only valid records contribute - expect(res.cells[rWest][cWidget].value).toBe(120) - expect(res.cells[rEast][cGadget].value).toBe(10) - expect(res.cells[rWest][cGadget]).toBeUndefined() - }) + expect(res.cells[rWest][cWidget].value).toBe(120); + expect(res.cells[rEast][cGadget].value).toBe(10); + expect(res.cells[rWest][cGadget]).toBeUndefined(); + }); - it('row totals use the provided aggregation over row records', () => { + it("row totals use the provided aggregation over row records", () => { const res = computePivot({ data: rows, - rowFields: ['region'], - columnFields: ['product'], - valueField: 'amount', - aggregator: 'avg', - }) + rowFields: ["region"], + columnFields: ["product"], + valueField: "amount", + aggregator: "avg", + }); // East row has values [10, 200] => avg 105 - expect(res.rowTotals['East']).toBe(105) + expect(res.rowTotals["East"]).toBe(105); // West row has values [90, 120] => avg 105 - expect(res.rowTotals['West']).toBe(105) - }) + expect(res.rowTotals["West"]).toBe(105); + }); it("test_flaky_passes_sometimes", () => { // read logs.json from data/logs.json - const logsUrl = new URL('../../data/logs.jsonl', import.meta.url) - const raw = readFileSync(logsUrl, 'utf-8') - const rows: FlatJson[] = [] + const logsUrl = new URL("../../data/logs.jsonl", import.meta.url); + const raw = readFileSync(logsUrl, "utf-8"); + const rows: FlatJson[] = []; // iterate through each line and parse JSON - raw.split('\n').forEach((line) => { - if (line.trim() === '') return - const parsed = JSON.parse(line) - rows.push(flattenJson(parsed)) - }) + raw.split("\n").forEach((line) => { + if (line.trim() === "") return; + const parsed = JSON.parse(line); + rows.push(flattenJson(parsed)); + }); const res = computePivot({ data: rows, - rowFields: ['$.eval_metadata.name', '$.execution_metadata.experiment_id'], - columnFields: ['$.input_metadata.completion_params.model'], - valueField: '$.evaluation_result.score', - aggregator: 'avg', - }) - - console.log(res) - }) -}) + rowFields: ["$.eval_metadata.name", "$.execution_metadata.experiment_id"], + columnFields: ["$.input_metadata.completion_params.model"], + valueField: "$.evaluation_result.score", + aggregator: "avg", + }); + + console.log(res); + }); +}); diff --git a/vite-app/src/util/query-params.test.ts b/vite-app/src/util/query-params.test.ts new file mode 100644 index 00000000..5dbba8ad --- /dev/null +++ b/vite-app/src/util/query-params.test.ts @@ -0,0 +1,857 @@ +import { describe, it, expect } from "vitest"; +import { nonDefaultValues } from "./query-params"; +import type { GlobalConfig } from "../types/configs"; +import { DEFAULT_GLOBAL_CONFIG } from "../GlobalState"; + +describe("query-params", () => { + describe("nonDefaultValues", () => { + it("returns empty object when config matches default", () => { + const result = nonDefaultValues(DEFAULT_GLOBAL_CONFIG); + expect(result).toEqual({}); + }); + + it("returns empty object when config is identical to default", () => { + const identicalConfig: GlobalConfig = { + pivotConfig: { + selectedRowFields: ["$.eval_metadata.name"], + selectedColumnFields: ["$.input_metadata.completion_params.model"], + selectedValueField: "$.evaluation_result.score", + selectedAggregator: "avg", + }, + filterConfig: [], + paginationConfig: { + currentPage: 1, + pageSize: 25, + }, + sortConfig: { + sortField: "created_at", + sortDirection: "desc", + }, + }; + + const result = nonDefaultValues(identicalConfig); + expect(result).toEqual({}); + }); + + it("detects differences in pivot config", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedRowFields: [ + "$.eval_metadata.name", + "$.eval_metadata.version", + ], + selectedAggregator: "sum", + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedRowFields": + '["$.eval_metadata.name","$.eval_metadata.version"]', + "pivotConfig.selectedAggregator": '"sum"', + }); + }); + + it("detects differences in filter config", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "score", + operator: ">", + value: "0.5", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"score","operator":">","value":"0.5","type":"text"}]}]', + }); + }); + + it("detects differences in pagination config", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + paginationConfig: { + currentPage: 3, + pageSize: 50, + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "paginationConfig.currentPage": "3", + "paginationConfig.pageSize": "50", + }); + }); + + it("detects differences in sort config", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + sortConfig: { + sortField: "score", + sortDirection: "asc", + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "sortConfig.sortField": '"score"', + "sortConfig.sortDirection": '"asc"', + }); + }); + + it("detects multiple differences across different config sections", () => { + const modifiedConfig: GlobalConfig = { + pivotConfig: { + selectedRowFields: ["$.eval_metadata.name"], + selectedColumnFields: ["$.input_metadata.completion_params.model"], + selectedValueField: "$.evaluation_result.score", + selectedAggregator: "max", + }, + filterConfig: [ + { + logic: "OR", + filters: [ + { + field: "status", + operator: "==", + value: "completed", + type: "text", + }, + ], + }, + ], + paginationConfig: { + currentPage: 2, + pageSize: 100, + }, + sortConfig: { + sortField: "timestamp", + sortDirection: "asc", + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedAggregator": '"max"', + filterConfig: + '[{"logic":"OR","filters":[{"field":"status","operator":"==","value":"completed","type":"text"}]}]', + "paginationConfig.currentPage": "2", + "paginationConfig.pageSize": "100", + "sortConfig.sortField": '"timestamp"', + "sortConfig.sortDirection": '"asc"', + }); + }); + + it("handles nested array differences in filter config", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "score", + operator: ">", + value: "0.5", + type: "text", + }, + { + field: "status", + operator: "==", + value: "active", + type: "text", + }, + ], + }, + { + logic: "OR", + filters: [ + { + field: "category", + operator: "contains", + value: "test", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"score","operator":">","value":"0.5","type":"text"},{"field":"status","operator":"==","value":"active","type":"text"}]},{"logic":"OR","filters":[{"field":"category","operator":"contains","value":"test","type":"text"}]}]', + }); + }); + + it("handles null and undefined values correctly", () => { + const configWithNulls: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "test", + operator: "==", + value: "null", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(configWithNulls); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"test","operator":"==","value":"null","type":"text"}]}]', + }); + }); + + it("handles empty arrays correctly", () => { + const configWithEmptyArrays: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedRowFields: [], + selectedColumnFields: [], + }, + }; + + const result = nonDefaultValues(configWithEmptyArrays); + + expect(result).toEqual({ + "pivotConfig.selectedRowFields": "[]", + "pivotConfig.selectedColumnFields": "[]", + }); + }); + + it("handles complex nested structures", () => { + const complexConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "metadata.tags", + operator: "contains", + value: "important", + type: "text", + }, + { + field: "score", + operator: "between", + value: "0.5", + value2: "1.0", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(complexConfig); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"metadata.tags","operator":"contains","value":"important","type":"text"},{"field":"score","operator":"between","value":"0.5","value2":"1.0","type":"text"}]}]', + }); + }); + + it("preserves JSON serialization format", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedRowFields: ["field1", "field2"], + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + // Verify that arrays are properly JSON stringified + expect(result["pivotConfig.selectedRowFields"]).toBe( + '["field1","field2"]' + ); + + // Verify that strings are properly JSON stringified (with quotes) + expect(result["pivotConfig.selectedAggregator"]).toBeUndefined(); // No change from default + }); + + it("handles edge case with all default values but different object references", () => { + // Create a config that has the same values but different object references + const configWithNewObjects: GlobalConfig = { + pivotConfig: { + selectedRowFields: ["$.eval_metadata.name"], + selectedColumnFields: ["$.input_metadata.completion_params.model"], + selectedValueField: "$.evaluation_result.score", + selectedAggregator: "avg", + }, + filterConfig: [], + paginationConfig: { + currentPage: 1, + pageSize: 25, + }, + sortConfig: { + sortField: "created_at", + sortDirection: "desc", + }, + }; + + const result = nonDefaultValues(configWithNewObjects); + + // Should return empty object since values are identical to defaults + expect(result).toEqual({}); + }); + + it("handles boolean values correctly", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "isActive", + operator: "==", + value: "true", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"isActive","operator":"==","value":"true","type":"text"}]}]', + }); + }); + + it("handles numeric values correctly", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + paginationConfig: { + currentPage: 0, + pageSize: 1000, + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "paginationConfig.currentPage": "0", + "paginationConfig.pageSize": "1000", + }); + }); + + it("handles special characters in string values", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedValueField: "$.metadata['special-chars']", + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedValueField": "\"$.metadata['special-chars']\"", + }); + }); + + it("handles deeply nested filter configurations", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "metadata.nested.deep.value", + operator: "contains", + value: "test", + type: "text", + }, + { + field: "scores[0]", + operator: ">", + value: "0.5", + type: "text", + }, + ], + }, + { + logic: "OR", + filters: [ + { + field: "status", + operator: "==", + value: "active", + type: "text", + }, + { + field: "status", + operator: "==", + value: "pending", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"metadata.nested.deep.value","operator":"contains","value":"test","type":"text"},{"field":"scores[0]","operator":">","value":"0.5","type":"text"}]},{"logic":"OR","filters":[{"field":"status","operator":"==","value":"active","type":"text"},{"field":"status","operator":"==","value":"pending","type":"text"}]}]', + }); + }); + + it("handles date range filters correctly", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "created_at", + operator: "between", + value: "2024-01-01", + value2: "2024-12-31", + type: "date-range", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"created_at","operator":"between","value":"2024-01-01","value2":"2024-12-31","type":"date-range"}]}]', + }); + }); + + it("handles all sort directions", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + sortConfig: { + sortField: "name", + sortDirection: "asc", + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "sortConfig.sortField": '"name"', + "sortConfig.sortDirection": '"asc"', + }); + }); + + it("handles all aggregator types", () => { + const aggregators = ["sum", "min", "max", "count"]; // Exclude "avg" since it's the default + + for (const aggregator of aggregators) { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedAggregator: aggregator, + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedAggregator": `"${aggregator}"`, + }); + } + }); + + it("verifies default aggregator returns empty result", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedAggregator: "avg", // This is the default value + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({}); + }); + + it("handles all filter operators", () => { + const operators = [ + "==", + "!=", + ">", + "<", + ">=", + "<=", + "contains", + "!contains", + "between", + ]; + + for (const operator of operators) { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "test", + operator: operator as any, + value: "test-value", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: `[{"logic":"AND","filters":[{"field":"test","operator":"${operator}","value":"test-value","type":"text"}]}]`, + }); + } + }); + + it("handles all filter logic types", () => { + const logics = ["AND", "OR"]; + + for (const logic of logics) { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: logic as any, + filters: [ + { + field: "test", + operator: "==", + value: "test-value", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: `[{"logic":"${logic}","filters":[{"field":"test","operator":"==","value":"test-value","type":"text"}]}]`, + }); + } + }); + + it("handles all filter types", () => { + const filterTypes = ["text", "date", "date-range"]; + + for (const filterType of filterTypes) { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "test", + operator: "==", + value: "test-value", + type: filterType as any, + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: `[{"logic":"AND","filters":[{"field":"test","operator":"==","value":"test-value","type":"${filterType}"}]}]`, + }); + } + }); + + it("handles very large page sizes", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + paginationConfig: { + currentPage: 1, + pageSize: 10000, + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "paginationConfig.pageSize": "10000", + }); + }); + + it("handles zero values correctly", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + paginationConfig: { + currentPage: 0, + pageSize: 0, + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "paginationConfig.currentPage": "0", + "paginationConfig.pageSize": "0", + }); + }); + + it("handles negative values correctly", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + paginationConfig: { + currentPage: -1, + pageSize: -5, + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "paginationConfig.currentPage": "-1", + "paginationConfig.pageSize": "-5", + }); + }); + + it("handles very long field names", () => { + const longFieldName = + "very_long_field_name_that_might_be_used_in_real_world_scenarios_with_deeply_nested_objects_and_arrays"; + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedValueField: `$.${longFieldName}`, + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedValueField": `"$.${longFieldName}"`, + }); + }); + + it("handles unicode characters in values", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedValueField: "$.metadata.测试字段", + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedValueField": '"$.metadata.测试字段"', + }); + }); + + it("handles empty string values", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedValueField: "", + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedValueField": '""', + }); + }); + + it("handles very deep nesting in filter configs", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p", + operator: "==", + value: "deep_value", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p","operator":"==","value":"deep_value","type":"text"}]}]', + }); + }); + + it("handles mixed data types in arrays", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedRowFields: ["string", "123", "true", "null"], + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedRowFields": '["string","123","true","null"]', + }); + }); + + it("handles complex JSON paths", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + ...DEFAULT_GLOBAL_CONFIG.pivotConfig, + selectedRowFields: [ + "$.data[0].items[1].metadata.tags[2]", + "$['special-key'].value", + "$.nested['with spaces'].value", + ], + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedRowFields": + '["$.data[0].items[1].metadata.tags[2]","$[\'special-key\'].value","$.nested[\'with spaces\'].value"]', + }); + }); + + it("handles partial config changes with some defaults preserved", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + pivotConfig: { + selectedRowFields: ["$.eval_metadata.name"], // Same as default + selectedColumnFields: ["$.input_metadata.completion_params.model"], // Same as default + selectedValueField: "$.evaluation_result.score", // Same as default + selectedAggregator: "sum", // Different from default + }, + filterConfig: [], // Same as default + paginationConfig: { + currentPage: 1, // Same as default + pageSize: 50, // Different from default + }, + sortConfig: { + sortField: "created_at", // Same as default + sortDirection: "asc", // Different from default + }, + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + "pivotConfig.selectedAggregator": '"sum"', + "paginationConfig.pageSize": "50", + "sortConfig.sortDirection": '"asc"', + }); + }); + + it("handles edge case with undefined values in optional fields", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "test", + operator: "==", + value: "test", + type: "text", + // value2 is optional and undefined + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"test","operator":"==","value":"test","type":"text"}]}]', + }); + }); + + it("handles edge case with value2 field in between operator", () => { + const modifiedConfig: GlobalConfig = { + ...DEFAULT_GLOBAL_CONFIG, + filterConfig: [ + { + logic: "AND", + filters: [ + { + field: "score", + operator: "between", + value: "0.5", + value2: "1.0", + type: "text", + }, + ], + }, + ], + }; + + const result = nonDefaultValues(modifiedConfig); + + expect(result).toEqual({ + filterConfig: + '[{"logic":"AND","filters":[{"field":"score","operator":"between","value":"0.5","value2":"1.0","type":"text"}]}]', + }); + }); + }); +}); diff --git a/vite-app/src/util/query-params.ts b/vite-app/src/util/query-params.ts new file mode 100644 index 00000000..6246bd60 --- /dev/null +++ b/vite-app/src/util/query-params.ts @@ -0,0 +1,244 @@ +/** + * Module for handling the reactivity, update, and querying of query params based on GlobalState + */ + +import { autorun } from "mobx"; +import { useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { GlobalConfig } from "../types/configs"; +import { DEFAULT_GLOBAL_CONFIG, GlobalState } from "../GlobalState"; + +export class QueryParamsWatcher { + queryParams: Record; + private updateUrlCallback: + | ((queryParams: Record) => void) + | null = null; + private state: GlobalState; + + constructor(state: GlobalState) { + this.state = state; + this.queryParams = nonDefaultValues(this.state.globalConfig); + this.init(); + } + + init() { + autorun(() => { + const globalConfig = this.state.globalConfig; + const diff = nonDefaultValues(globalConfig); + const previousUrlEncodedQueryParams = + this.generateUrlEncodedQueryParams(); + this.queryParams = diff; + const newUrlEncodedQueryParams = this.generateUrlEncodedQueryParams(); + if (previousUrlEncodedQueryParams !== newUrlEncodedQueryParams) { + console.log( + `Query params changed from ${previousUrlEncodedQueryParams} to ${newUrlEncodedQueryParams}` + ); + this.updateUrl(); + } + }); + } + + setUpdateUrlCallback( + callback: (queryParams: Record) => void + ) { + this.updateUrlCallback = callback; + } + + stableQueryParams(): [string, string][] { + /** + * Returns a stable query params object that is idempotent based on this.queryParams. First sorts by key and then by value. + */ + return Object.entries(this.queryParams).sort((a, b) => { + const keyCompare = a[0].localeCompare(b[0]); + if (keyCompare !== 0) return keyCompare; + return a[1].localeCompare(b[1]); + }); + } + + generateUrlEncodedQueryParams(): string { + return this.stableQueryParams() + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ) + .join("&"); + } + + private updateUrl() { + /** + * Update the browser URL with current query params using React Router callback + */ + if (this.updateUrlCallback) { + this.updateUrlCallback(this.queryParams); + } + } +} + +export function nonDefaultValues( + globalConfig: GlobalConfig, + defaultConfig: GlobalConfig = DEFAULT_GLOBAL_CONFIG +): Record { + /** + * Return a collection of non-default values based on an instance of GlobalConfig + * + * This is particularly useful for computing query params since we want to + * keep the links as minimal as possible so they are easy to understand and + * log to console. + * + * Return + * - The key is a JSON path to the field + * - The value is the JSON serialized value of the field + */ + return calculateDifferentValues(globalConfig, defaultConfig); +} + +function calculateDifferentValues( + globalConfig: GlobalConfig, + defaultConfig: GlobalConfig +): Record { + const differences: Record = {}; + + function compareObjects(obj1: any, obj2: any, path: string = ""): void { + // Handle null/undefined cases + if (obj1 === null || obj1 === undefined) { + if (obj2 !== null && obj2 !== undefined) { + differences[path] = JSON.stringify(obj1); + } + return; + } + + if (obj2 === null || obj2 === undefined) { + if (obj1 !== null && obj1 !== undefined) { + differences[path] = JSON.stringify(obj1); + } + return; + } + + // Handle primitive types + if (typeof obj1 !== "object" || typeof obj2 !== "object") { + if (obj1 !== obj2) { + differences[path] = JSON.stringify(obj1); + } + return; + } + + // Handle arrays + if (Array.isArray(obj1) && Array.isArray(obj2)) { + if (JSON.stringify(obj1) !== JSON.stringify(obj2)) { + differences[path] = JSON.stringify(obj1); + } + return; + } + + // Handle objects + if (Array.isArray(obj1) || Array.isArray(obj2)) { + if (JSON.stringify(obj1) !== JSON.stringify(obj2)) { + differences[path] = JSON.stringify(obj1); + } + return; + } + + // Get all unique keys from both objects + const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]); + + for (const key of allKeys) { + const currentPath = path ? `${path}.${key}` : key; + + if (!(key in obj1)) { + // Key exists in obj2 but not obj1 + differences[currentPath] = JSON.stringify(undefined); + } else if (!(key in obj2)) { + // Key exists in obj1 but not obj2 + differences[currentPath] = JSON.stringify(obj1[key]); + } else { + // Key exists in both objects, compare recursively + compareObjects(obj1[key], obj2[key], currentPath); + } + } + } + + compareObjects(globalConfig, defaultConfig); + return differences; +} + +/** + * Converts serialized query params back into a partial GlobalConfig + * This is the inverse operation of nonDefaultValues() + * + * @param queryParams - Record where keys are JSON paths and values are JSON-serialized values + * @returns Partial that can be applied to update GlobalConfig + */ +export function queryParamsToPartialConfig( + queryParams: Record +): Partial { + const result: any = {}; + + for (const [path, serializedValue] of Object.entries(queryParams)) { + try { + const value = JSON.parse(serializedValue); + setNestedValue(result, path, value); + } catch (error) { + console.warn( + `Failed to parse query param value for path "${path}":`, + error + ); + } + } + + return result; +} + +/** + * Helper function to set a nested value in an object using dot notation path + * @param obj - The object to modify + * @param path - Dot notation path (e.g., "pivotConfig.selectedRowFields") + * @param value - The value to set + */ +function setNestedValue(obj: any, path: string, value: any): void { + const keys = path.split("."); + let current = obj; + + // Navigate to the parent of the target key + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!(key in current)) { + current[key] = {}; + } + current = current[key]; + } + + // Set the final value + const finalKey = keys[keys.length - 1]; + current[finalKey] = value; +} + +/** + * Custom hook that integrates QueryParamsWatcher with React Router's useSearchParams + * This hook should be used in components that need to sync global state with URL query params + */ +export function useQueryParamsSync(queryParamsWatcher: QueryParamsWatcher) { + const [, setSearchParams] = useSearchParams(); + + useEffect(() => { + // Set up the callback for the QueryParamsWatcher to update URL + const updateUrl = (queryParams: Record) => { + const newSearchParams = new URLSearchParams(); + + // Add all query params to URLSearchParams + Object.entries(queryParams).forEach(([key, value]) => { + newSearchParams.set(key, value); + }); + + // Update the URL using React Router + setSearchParams(newSearchParams, { replace: true }); + }; + + // Set the callback on the global queryParamsWatcher + queryParamsWatcher.setUpdateUrlCallback(updateUrl); + + // Cleanup: remove callback when component unmounts + return () => { + queryParamsWatcher.setUpdateUrlCallback(() => {}); + }; + }, [setSearchParams]); +}