@@ -772,6 +772,142 @@ describe("pagination_controls data-extra-params handling", () => {
772772 } ) ;
773773} ) ;
774774
775+ // ---------------------------------------------------------------------------
776+ // pagination_controls: dynamic search input reading (#3128)
777+ //
778+ // When paginating, loadPage() reads the current search and tag filter input
779+ // values from the DOM. This ensures the user's active search filter is
780+ // preserved across pages even if the input changed after the last server
781+ // render (which would make data-extra-params stale).
782+ // ---------------------------------------------------------------------------
783+ describe ( "pagination_controls dynamic search input reading (#3128)" , ( ) => {
784+ /**
785+ * Replicates the full loadPage() URL-building logic from
786+ * pagination_controls.html, including extraParams AND the dynamic
787+ * input-reading block added in #3128.
788+ */
789+ function buildUrlWithInputs (
790+ baseUrl ,
791+ extraParamsJson ,
792+ tableName ,
793+ inputs = { } ,
794+ ) {
795+ const url = new URL ( baseUrl , "http://localhost" ) ;
796+ url . searchParams . set ( "page" , "1" ) ;
797+ url . searchParams . set ( "per_page" , "50" ) ;
798+
799+ // Step 1: extraParams from server-rendered data attribute
800+ const extraParams = JSON . parse ( extraParamsJson || "{}" ) ;
801+ Object . entries ( extraParams ) . forEach ( ( [ k , v ] ) => {
802+ if ( k !== "include_inactive" && v !== null && v !== undefined ) {
803+ url . searchParams . set ( k , String ( v ) ) ;
804+ }
805+ } ) ;
806+
807+ // Step 2: dynamic input reading (mirrors #3128 fix)
808+ if ( tableName ) {
809+ if ( inputs . search !== undefined ) {
810+ const trimmedQuery = inputs . search . trim ( ) ;
811+ if ( trimmedQuery ) {
812+ url . searchParams . set ( "q" , trimmedQuery ) ;
813+ } else {
814+ url . searchParams . delete ( "q" ) ;
815+ }
816+ }
817+ if ( inputs . tags !== undefined ) {
818+ const trimmedTags = inputs . tags . trim ( ) ;
819+ if ( trimmedTags ) {
820+ url . searchParams . set ( "tags" , trimmedTags ) ;
821+ } else {
822+ url . searchParams . delete ( "tags" ) ;
823+ }
824+ }
825+ }
826+
827+ return url ;
828+ }
829+
830+ test ( "search input value is used for q param" , ( ) => {
831+ const url = buildUrlWithInputs ( "/admin/tools/partial" , "{}" , "tools" , {
832+ search : "my query" ,
833+ } ) ;
834+ expect ( url . searchParams . get ( "q" ) ) . toBe ( "my query" ) ;
835+ } ) ;
836+
837+ test ( "tag input value is used for tags param" , ( ) => {
838+ const url = buildUrlWithInputs ( "/admin/tools/partial" , "{}" , "tools" , {
839+ tags : "prod,staging" ,
840+ } ) ;
841+ expect ( url . searchParams . get ( "tags" ) ) . toBe ( "prod,staging" ) ;
842+ } ) ;
843+
844+ test ( "input values override stale extraParams q and tags" , ( ) => {
845+ const json = JSON . stringify ( { q : "old query" , tags : "old-tag" } ) ;
846+ const url = buildUrlWithInputs ( "/admin/tools/partial" , json , "tools" , {
847+ search : "new query" ,
848+ tags : "new-tag" ,
849+ } ) ;
850+ expect ( url . searchParams . get ( "q" ) ) . toBe ( "new query" ) ;
851+ expect ( url . searchParams . get ( "tags" ) ) . toBe ( "new-tag" ) ;
852+ } ) ;
853+
854+ test ( "empty input clears stale extraParams q" , ( ) => {
855+ const json = JSON . stringify ( { q : "stale search" } ) ;
856+ const url = buildUrlWithInputs ( "/admin/tools/partial" , json , "tools" , {
857+ search : "" ,
858+ } ) ;
859+ expect ( url . searchParams . has ( "q" ) ) . toBe ( false ) ;
860+ } ) ;
861+
862+ test ( "empty input clears stale extraParams tags" , ( ) => {
863+ const json = JSON . stringify ( { tags : "stale-tag" } ) ;
864+ const url = buildUrlWithInputs ( "/admin/tools/partial" , json , "tools" , {
865+ tags : "" ,
866+ } ) ;
867+ expect ( url . searchParams . has ( "tags" ) ) . toBe ( false ) ;
868+ } ) ;
869+
870+ test ( "whitespace-only input clears q" , ( ) => {
871+ const json = JSON . stringify ( { q : "stale" } ) ;
872+ const url = buildUrlWithInputs ( "/admin/tools/partial" , json , "tools" , {
873+ search : " " ,
874+ } ) ;
875+ expect ( url . searchParams . has ( "q" ) ) . toBe ( false ) ;
876+ } ) ;
877+
878+ test ( "input values are trimmed" , ( ) => {
879+ const url = buildUrlWithInputs ( "/admin/tools/partial" , "{}" , "tools" , {
880+ search : " hello " ,
881+ tags : " alpha " ,
882+ } ) ;
883+ expect ( url . searchParams . get ( "q" ) ) . toBe ( "hello" ) ;
884+ expect ( url . searchParams . get ( "tags" ) ) . toBe ( "alpha" ) ;
885+ } ) ;
886+
887+ test ( "other extraParams are preserved when input overrides q" , ( ) => {
888+ const json = JSON . stringify ( {
889+ q : "old" ,
890+ gateway_id : "42" ,
891+ team_id : "t1" ,
892+ } ) ;
893+ const url = buildUrlWithInputs ( "/admin/tools/partial" , json , "tools" , {
894+ search : "new" ,
895+ } ) ;
896+ expect ( url . searchParams . get ( "q" ) ) . toBe ( "new" ) ;
897+ expect ( url . searchParams . get ( "gateway_id" ) ) . toBe ( "42" ) ;
898+ expect ( url . searchParams . get ( "team_id" ) ) . toBe ( "t1" ) ;
899+ } ) ;
900+
901+ test ( "skips input reading when tableName is empty" , ( ) => {
902+ const json = JSON . stringify ( { q : "from-server" } ) ;
903+ const url = buildUrlWithInputs ( "/admin/tools/partial" , json , "" , {
904+ search : "from-input" ,
905+ } ) ;
906+ // Without tableName, input reading is skipped; extraParams value stands
907+ expect ( url . searchParams . get ( "q" ) ) . toBe ( "from-server" ) ;
908+ } ) ;
909+ } ) ;
910+
775911// ---------------------------------------------------------------------------
776912// Pagination swapStyle used by loadPage (#3396)
777913//
@@ -800,7 +936,7 @@ describe("pagination loadPage swapStyle (#3396)", () => {
800936 hasNext : true ,
801937 hasPrev : false ,
802938 targetSelector : "#tools-table" ,
803- swapStyle : swapStyle ,
939+ swapStyle,
804940 tableName : "tools" ,
805941 baseUrl : "/admin/tools/partial" ,
806942 $el : {
0 commit comments