@@ -75,7 +75,10 @@ beforeAll(() => {
7575 }
7676
7777 if (
78- ! additionalParams . hasOwnProperty ( "include_inactive" ) &&
78+ ! Object . prototype . hasOwnProperty . call (
79+ additionalParams ,
80+ "include_inactive" ,
81+ ) &&
7982 params . includeInactive !== null
8083 ) {
8184 url . searchParams . set (
@@ -848,3 +851,149 @@ describe("pagination loadPage swapStyle (#3396)", () => {
848851 expect ( ajaxCalls [ 0 ] . swap ) . toBe ( "innerHTML" ) ;
849852 } ) ;
850853} ) ;
854+
855+ // ---------------------------------------------------------------------------
856+ // _navigateAdmin: pagination state preservation (#3389)
857+ //
858+ // _navigateAdmin is the single function all edit/save/toggle handlers use to
859+ // redirect after a successful operation. The fix copies namespaced pagination
860+ // params (*_page, *_size, *_inactive, *_q, *_tags) from the current URL into
861+ // the outgoing searchParams so editing an item on page 3 returns to page 3.
862+ //
863+ // Testing strategy: the function mutates the passed URLSearchParams before
864+ // attempting navigation (which throws "Not implemented" in JSDOM). We inspect
865+ // the URLSearchParams object after catching the error to verify the mutation.
866+ // ---------------------------------------------------------------------------
867+ describe ( "_navigateAdmin pagination state preservation (#3389)" , ( ) => {
868+ /**
869+ * Call _navigateAdmin and swallow the JSDOM navigation error.
870+ * Returns the searchParams object after mutation.
871+ */
872+ function callNavigateAdmin ( fragment , searchParams ) {
873+ try {
874+ win . _navigateAdmin ( fragment , searchParams ) ;
875+ } catch ( _ ) {
876+ // JSDOM throws "Not implemented: navigation" — expected
877+ }
878+ return searchParams ;
879+ }
880+
881+ test ( "preserves *_page and *_size params from current URL" , ( ) => {
882+ win . history . replaceState ( { } , "" , "/admin?tools_page=3&tools_size=25" ) ;
883+ const params = new win . URLSearchParams ( ) ;
884+ callNavigateAdmin ( "tools" , params ) ;
885+ expect ( params . get ( "tools_page" ) ) . toBe ( "3" ) ;
886+ expect ( params . get ( "tools_size" ) ) . toBe ( "25" ) ;
887+ } ) ;
888+
889+ test ( "preserves *_q and *_tags params from current URL" , ( ) => {
890+ win . history . replaceState (
891+ { } ,
892+ "" ,
893+ "/admin?tools_q=search&tools_tags=alpha,beta" ,
894+ ) ;
895+ const params = new win . URLSearchParams ( ) ;
896+ callNavigateAdmin ( "tools" , params ) ;
897+ expect ( params . get ( "tools_q" ) ) . toBe ( "search" ) ;
898+ expect ( params . get ( "tools_tags" ) ) . toBe ( "alpha,beta" ) ;
899+ } ) ;
900+
901+ test ( "preserves namespaced *_inactive params (e.g. tools_inactive)" , ( ) => {
902+ win . history . replaceState ( { } , "" , "/admin?tools_inactive=true" ) ;
903+ const params = new win . URLSearchParams ( ) ;
904+ callNavigateAdmin ( "tools" , params ) ;
905+ expect ( params . get ( "tools_inactive" ) ) . toBe ( "true" ) ;
906+ } ) ;
907+
908+ test ( "does NOT preserve bare include_inactive param" , ( ) => {
909+ win . history . replaceState ( { } , "" , "/admin?include_inactive=true" ) ;
910+ const params = new win . URLSearchParams ( ) ;
911+ callNavigateAdmin ( "tools" , params ) ;
912+ expect ( params . has ( "include_inactive" ) ) . toBe ( false ) ;
913+ } ) ;
914+
915+ test ( "does NOT preserve non-pagination params (e.g. team_id, random)" , ( ) => {
916+ win . history . replaceState (
917+ { } ,
918+ "" ,
919+ "/admin?team_id=abc&random=42&tools_page=2" ,
920+ ) ;
921+ const params = new win . URLSearchParams ( ) ;
922+ callNavigateAdmin ( "tools" , params ) ;
923+ expect ( params . has ( "team_id" ) ) . toBe ( false ) ;
924+ expect ( params . has ( "random" ) ) . toBe ( false ) ;
925+ expect ( params . get ( "tools_page" ) ) . toBe ( "2" ) ;
926+ } ) ;
927+
928+ test ( "caller-set params take precedence over URL params" , ( ) => {
929+ win . history . replaceState ( { } , "" , "/admin?tools_page=5&tools_size=50" ) ;
930+ const params = new win . URLSearchParams ( ) ;
931+ params . set ( "tools_page" , "1" ) ;
932+ callNavigateAdmin ( "tools" , params ) ;
933+ expect ( params . get ( "tools_page" ) ) . toBe ( "1" ) ;
934+ expect ( params . get ( "tools_size" ) ) . toBe ( "50" ) ;
935+ } ) ;
936+
937+ test ( "preserves params across multiple table namespaces" , ( ) => {
938+ win . history . replaceState (
939+ { } ,
940+ "" ,
941+ "/admin?tools_page=3&gateways_page=2&servers_size=50&agents_q=bot" ,
942+ ) ;
943+ const params = new win . URLSearchParams ( ) ;
944+ callNavigateAdmin ( "tools" , params ) ;
945+ expect ( params . get ( "tools_page" ) ) . toBe ( "3" ) ;
946+ expect ( params . get ( "gateways_page" ) ) . toBe ( "2" ) ;
947+ expect ( params . get ( "servers_size" ) ) . toBe ( "50" ) ;
948+ expect ( params . get ( "agents_q" ) ) . toBe ( "bot" ) ;
949+ } ) ;
950+
951+ test ( "handles null searchParams without TypeError" , ( ) => {
952+ win . history . replaceState ( { } , "" , "/admin?tools_page=4" ) ;
953+ let typeError = false ;
954+ try {
955+ win . _navigateAdmin ( "tools" , null ) ;
956+ } catch ( e ) {
957+ if ( e . message && e . message . includes ( "Cannot read properties" ) ) {
958+ typeError = true ;
959+ }
960+ }
961+ expect ( typeError ) . toBe ( false ) ;
962+ } ) ;
963+
964+ test ( "handles undefined searchParams without TypeError" , ( ) => {
965+ win . history . replaceState ( { } , "" , "/admin?tools_page=4" ) ;
966+ let typeError = false ;
967+ try {
968+ win . _navigateAdmin ( "tools" ) ;
969+ } catch ( e ) {
970+ if ( e . message && e . message . includes ( "Cannot read properties" ) ) {
971+ typeError = true ;
972+ }
973+ }
974+ expect ( typeError ) . toBe ( false ) ;
975+ } ) ;
976+
977+ test ( "preserves all five pagination suffixes simultaneously" , ( ) => {
978+ win . history . replaceState (
979+ { } ,
980+ "" ,
981+ "/admin?tools_page=3&tools_size=25&tools_inactive=true&tools_q=search&tools_tags=v1,v2" ,
982+ ) ;
983+ const params = new win . URLSearchParams ( ) ;
984+ callNavigateAdmin ( "tools" , params ) ;
985+ expect ( params . get ( "tools_page" ) ) . toBe ( "3" ) ;
986+ expect ( params . get ( "tools_size" ) ) . toBe ( "25" ) ;
987+ expect ( params . get ( "tools_inactive" ) ) . toBe ( "true" ) ;
988+ expect ( params . get ( "tools_q" ) ) . toBe ( "search" ) ;
989+ expect ( params . get ( "tools_tags" ) ) . toBe ( "v1,v2" ) ;
990+ } ) ;
991+
992+ test ( "does not overwrite caller include_inactive with URL's bare include_inactive" , ( ) => {
993+ win . history . replaceState ( { } , "" , "/admin?include_inactive=true" ) ;
994+ const params = new win . URLSearchParams ( ) ;
995+ params . set ( "include_inactive" , "false" ) ;
996+ callNavigateAdmin ( "tools" , params ) ;
997+ expect ( params . get ( "include_inactive" ) ) . toBe ( "false" ) ;
998+ } ) ;
999+ } ) ;
0 commit comments