Repository: jupyterhub/jupyterhub

Issue:
modifyable api_page_default_limit variable on the admin page
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Proposed change

It is rather annoying that the number of the listed servers (`api_page_default_limit`?) cannot be changed on the admin page,
but only use the number fixed in `jupyterhub_config.py`.

It would help the administration of jupyter HUB very much if this number could be changed on the admin page.

### Alternative options
<!-- Use this section to describe alternative options and why you've decided on the proposed feature above. -->

The variable `api_page_default_limit` can be changed in `jupyterhub_config.py`, however, this requires restarting the server.

### Who would use this feature?
<!-- Describe who would benefit from using this feature. -->

Most of the users with _admin_ access.

### (Optional): Suggest a solution
<!-- Describe what you think needs to be done. Doing that is an excellent first step to get the feature implemented. -->

Completing the admin page template(?) with a choice box (50|100|200|500) or input box.


Discussion:
The admin pages should definitely have control for selecting the number of items on the page. This should probably be able to come from the URL. In fact, all input parameters should be in the URL so refreshing the page doesn't result in a change of view.

Existing API parameters that should be supported in UI:

- state (not settable at all)
- limit (not persisted)
- offset (in UI via pager, not persisted)
- name_filter (in UI, not persisted)

and `order_by` should be in the UI if/when #3816 is implemented on the API side.

I don't think the admin page should be able to modify the Hub's _internal_ API limits, which would affect other requests and not persist across hub restarts, but it should certainly be able to control the current browser's view.

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -215,13 +215,6 @@ def run(self):
             shell=shell,
         )
 
-        print("Copying JSX admin app to static/js")
-        check_call(
-            ["npm", "run", "place"],
-            cwd=self.jsx_dir,
-            shell=shell,
-        )
-
         # update data-files in case this created new files
         self.distribution.data_files = get_data_files()
         assert not self.should_run(), 'JSX.run failed'


Test patch:
diff --git a/jsx/src/components/Groups/Groups.test.js b/jsx/src/components/Groups/Groups.test.js
--- a/jsx/src/components/Groups/Groups.test.js
+++ b/jsx/src/components/Groups/Groups.test.js
@@ -2,9 +2,9 @@ import React from "react";
 import "@testing-library/jest-dom";
 import { act } from "react-dom/test-utils";
 import { render, screen, fireEvent } from "@testing-library/react";
-import { Provider, useDispatch, useSelector } from "react-redux";
+import { Provider, useSelector } from "react-redux";
 import { createStore } from "redux";
-import { HashRouter } from "react-router-dom";
+import { HashRouter, useSearchParams } from "react-router-dom";
 // eslint-disable-next-line
 import regeneratorRuntime from "regenerator-runtime";
 
@@ -16,6 +16,11 @@ jest.mock("react-redux", () => ({
   useSelector: jest.fn(),
 }));
 
+jest.mock("react-router-dom", () => ({
+  ...jest.requireActual("react-router-dom"),
+  useSearchParams: jest.fn(),
+}));
+
 var mockAsync = () =>
   jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
 
@@ -50,11 +55,6 @@ var mockAppState = () =>
       offset: 0,
       limit: 2,
       total: 4,
-      next: {
-        offset: 2,
-        limit: 2,
-        url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
-      },
     },
   });
 
@@ -62,11 +62,15 @@ beforeEach(() => {
   useSelector.mockImplementation((callback) => {
     return callback(mockAppState());
   });
+  useSearchParams.mockImplementation(() => {
+    return [new URLSearchParams(), jest.fn()];
+  });
 });
 
 afterEach(() => {
   useSelector.mockClear();
   mockReducers.mockClear();
+  useSearchParams.mockClear();
 });
 
 test("Renders", async () => {
@@ -109,13 +113,23 @@ test("Renders nothing if required data is not available", async () => {
 });
 
 test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
-  let callbackSpy = mockAsync();
-
+  let upgradeGroupsSpy = mockAsync();
+  let setSearchParamsSpy = mockAsync();
+  let searchParams = new URLSearchParams({ limit: "2" });
+  useSearchParams.mockImplementation(() => [
+    searchParams,
+    (callback) => {
+      searchParams = callback(searchParams);
+      setSearchParamsSpy(searchParams.toString());
+    },
+  ]);
+  let _, setSearchParams;
   await act(async () => {
-    render(groupsJsx(callbackSpy));
+    render(groupsJsx(upgradeGroupsSpy));
+    [_, setSearchParams] = useSearchParams();
   });
 
-  expect(callbackSpy).toBeCalledWith(0, 2);
+  expect(upgradeGroupsSpy).toBeCalledWith(0, 2);
 
   var lastState =
     mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
@@ -123,12 +137,10 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
   expect(lastState.groups_page.limit).toEqual(2);
 
   let next = screen.getByTestId("paginate-next");
-  fireEvent.click(next);
-
-  lastState =
-    mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
-  expect(lastState.groups_page.offset).toEqual(2);
-  expect(lastState.groups_page.limit).toEqual(2);
+  await act(async () => {
+    fireEvent.click(next);
+  });
+  expect(setSearchParamsSpy).toBeCalledWith("limit=2&offset=2");
 
   // FIXME: mocked useSelector, state seem to prevent updateGroups from being called
   // making the test environment not representative
diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.test.js b/jsx/src/components/ServerDashboard/ServerDashboard.test.js
--- a/jsx/src/components/ServerDashboard/ServerDashboard.test.js
+++ b/jsx/src/components/ServerDashboard/ServerDashboard.test.js
@@ -1,4 +1,5 @@
 import React from "react";
+import { withProps } from "recompose";
 import "@testing-library/jest-dom";
 import { act } from "react-dom/test-utils";
 import userEvent from "@testing-library/user-event";
@@ -9,7 +10,8 @@ import {
   getByText,
   getAllByRole,
 } from "@testing-library/react";
-import { HashRouter, Switch } from "react-router-dom";
+import { HashRouter, Routes, Route, useSearchParams } from "react-router-dom";
+// import { CompatRouter,  } from "react-router-dom-v5-compat";
 import { Provider, useSelector } from "react-redux";
 import { createStore } from "redux";
 // eslint-disable-next-line
@@ -17,31 +19,35 @@ import regeneratorRuntime from "regenerator-runtime";
 
 import ServerDashboard from "./ServerDashboard";
 import { initialState, reducers } from "../../Store";
-import * as sinon from "sinon";
-
-let clock;
 
 jest.mock("react-redux", () => ({
   ...jest.requireActual("react-redux"),
   useSelector: jest.fn(),
 }));
+jest.mock("react-router-dom", () => ({
+  ...jest.requireActual("react-router-dom"),
+  useSearchParams: jest.fn(),
+}));
 
-var serverDashboardJsx = (spy) => (
-  <Provider store={createStore(mockReducers, mockAppState())}>
-    <HashRouter>
-      <Switch>
-        <ServerDashboard
-          updateUsers={spy}
-          shutdownHub={spy}
-          startServer={spy}
-          stopServer={spy}
-          startAll={spy}
-          stopAll={spy}
-        />
-      </Switch>
-    </HashRouter>
-  </Provider>
-);
+const serverDashboardJsx = (props) => {
+  // create mock ServerDashboard
+  // spies is a dict of properties to mock in
+  // any API calls that will fire during the test should be mocked
+  props = props || {};
+  const defaultSpy = mockAsync();
+  if (!props.updateUsers) {
+    props.updateUsers = defaultSpy;
+  }
+  return (
+    <Provider store={createStore(mockReducers, mockAppState())}>
+      <HashRouter>
+        <Routes>
+          <Route path="/" element={withProps(props)(ServerDashboard)()} />
+        </Routes>
+      </HashRouter>
+    </Provider>
+  );
+};
 
 var mockAsync = (data) =>
   jest.fn().mockImplementation(() => Promise.resolve(data ? data : { k: "v" }));
@@ -137,34 +143,41 @@ var mockReducers = jest.fn((state, action) => {
   return state;
 });
 
+let searchParams = new URLSearchParams();
+
 beforeEach(() => {
-  clock = sinon.useFakeTimers();
+  jest.useFakeTimers();
   useSelector.mockImplementation((callback) => {
     return callback(mockAppState());
   });
+  searchParams = new URLSearchParams();
+
+  useSearchParams.mockImplementation(() => [
+    searchParams,
+    (callback) => {
+      searchParams = callback(searchParams);
+    },
+  ]);
 });
 
 afterEach(() => {
+  useSearchParams.mockClear();
   useSelector.mockClear();
   mockReducers.mockClear();
-  clock.restore();
+  jest.runAllTimers();
 });
 
 test("Renders", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   expect(screen.getByTestId("container")).toBeVisible();
 });
 
 test("Renders users from props.user_data into table", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   let foo = screen.getByTestId("user-name-div-foo");
@@ -177,10 +190,8 @@ test("Renders users from props.user_data into table", async () => {
 });
 
 test("Renders correctly the status of a single-user server", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   let start_elems = screen.getAllByText("Start Server");
@@ -194,10 +205,8 @@ test("Renders correctly the status of a single-user server", async () => {
 });
 
 test("Renders spawn page link", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   for (let server in bar_servers) {
@@ -212,7 +221,7 @@ test("Invokes the startServer event on button click", async () => {
   let callbackSpy = mockAsync();
 
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx({ startServer: callbackSpy }));
   });
 
   let start_elems = screen.getAllByText("Start Server");
@@ -229,7 +238,7 @@ test("Invokes the stopServer event on button click", async () => {
   let callbackSpy = mockAsync();
 
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx({ stopServer: callbackSpy }));
   });
 
   let stop = screen.getByText("Stop Server");
@@ -245,7 +254,7 @@ test("Invokes the shutdownHub event on button click", async () => {
   let callbackSpy = mockAsync();
 
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx({ shutdownHub: callbackSpy }));
   });
 
   let shutdown = screen.getByText("Shutdown Hub");
@@ -258,10 +267,8 @@ test("Invokes the shutdownHub event on button click", async () => {
 });
 
 test("Sorts according to username", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   let handler = screen.getByTestId("user-sort");
@@ -277,10 +284,8 @@ test("Sorts according to username", async () => {
 });
 
 test("Sorts according to admin", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   let handler = screen.getByTestId("admin-sort");
@@ -296,10 +301,8 @@ test("Sorts according to admin", async () => {
 });
 
 test("Sorts according to last activity", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   let handler = screen.getByTestId("last-activity-sort");
@@ -315,10 +318,8 @@ test("Sorts according to last activity", async () => {
 });
 
 test("Sorts according to server status (running/not running)", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   let handler = screen.getByTestId("running-status-sort");
@@ -334,10 +335,8 @@ test("Sorts according to server status (running/not running)", async () => {
 });
 
 test("Shows server details with button click", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
   let button = screen.getByTestId("foo-collapse-button");
   let collapse = screen.getByTestId("foo-collapse");
@@ -350,16 +349,16 @@ test("Shows server details with button click", async () => {
 
   await act(async () => {
     fireEvent.click(button);
+    jest.runAllTimers();
   });
-  clock.tick(400);
 
   expect(collapse).toHaveClass("collapse show");
   expect(collapseBar).not.toHaveClass("show");
 
   await act(async () => {
     fireEvent.click(button);
+    jest.runAllTimers();
   });
-  clock.tick(400);
 
   expect(collapse).toHaveClass("collapse");
   expect(collapse).not.toHaveClass("show");
@@ -367,8 +366,8 @@ test("Shows server details with button click", async () => {
 
   await act(async () => {
     fireEvent.click(button);
+    jest.runAllTimers();
   });
-  clock.tick(400);
 
   expect(collapse).toHaveClass("collapse show");
   expect(collapseBar).not.toHaveClass("show");
@@ -379,10 +378,8 @@ test("Renders nothing if required data is not available", async () => {
     return callback({});
   });
 
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   let noShow = screen.getByTestId("no-show");
@@ -391,26 +388,8 @@ test("Renders nothing if required data is not available", async () => {
 });
 
 test("Shows a UI error dialogue when start all servers fails", async () => {
-  let spy = mockAsync();
-  let rejectSpy = mockAsyncRejection;
-
   await act(async () => {
-    render(
-      <Provider store={createStore(() => {}, {})}>
-        <HashRouter>
-          <Switch>
-            <ServerDashboard
-              updateUsers={spy}
-              shutdownHub={spy}
-              startServer={spy}
-              stopServer={spy}
-              startAll={rejectSpy}
-              stopAll={spy}
-            />
-          </Switch>
-        </HashRouter>
-      </Provider>,
-    );
+    render(serverDashboardJsx({ startAll: mockAsyncRejection }));
   });
 
   let startAll = screen.getByTestId("start-all");
@@ -425,26 +404,8 @@ test("Shows a UI error dialogue when start all servers fails", async () => {
 });
 
 test("Shows a UI error dialogue when stop all servers fails", async () => {
-  let spy = mockAsync();
-  let rejectSpy = mockAsyncRejection;
-
   await act(async () => {
-    render(
-      <Provider store={createStore(() => {}, {})}>
-        <HashRouter>
-          <Switch>
-            <ServerDashboard
-              updateUsers={spy}
-              shutdownHub={spy}
-              startServer={spy}
-              stopServer={spy}
-              startAll={spy}
-              stopAll={rejectSpy}
-            />
-          </Switch>
-        </HashRouter>
-      </Provider>,
-    );
+    render(serverDashboardJsx({ stopAll: mockAsyncRejection }));
   });
 
   let stopAll = screen.getByTestId("stop-all");
@@ -459,26 +420,8 @@ test("Shows a UI error dialogue when stop all servers fails", async () => {
 });
 
 test("Shows a UI error dialogue when start user server fails", async () => {
-  let spy = mockAsync();
-  let rejectSpy = mockAsyncRejection();
-
   await act(async () => {
-    render(
-      <Provider store={createStore(() => {}, {})}>
-        <HashRouter>
-          <Switch>
-            <ServerDashboard
-              updateUsers={spy}
-              shutdownHub={spy}
-              startServer={rejectSpy}
-              stopServer={spy}
-              startAll={spy}
-              stopAll={spy}
-            />
-          </Switch>
-        </HashRouter>
-      </Provider>,
-    );
+    render(serverDashboardJsx({ startServer: mockAsyncRejection() }));
   });
 
   let start_elems = screen.getAllByText("Start Server");
@@ -494,26 +437,9 @@ test("Shows a UI error dialogue when start user server fails", async () => {
 });
 
 test("Shows a UI error dialogue when start user server returns an improper status code", async () => {
-  let spy = mockAsync();
   let rejectSpy = mockAsync({ status: 403 });
-
   await act(async () => {
-    render(
-      <Provider store={createStore(() => {}, {})}>
-        <HashRouter>
-          <Switch>
-            <ServerDashboard
-              updateUsers={spy}
-              shutdownHub={spy}
-              startServer={rejectSpy}
-              stopServer={spy}
-              startAll={spy}
-              stopAll={spy}
-            />
-          </Switch>
-        </HashRouter>
-      </Provider>,
-    );
+    render(serverDashboardJsx({ startServer: rejectSpy }));
   });
 
   let start_elems = screen.getAllByText("Start Server");
@@ -533,22 +459,7 @@ test("Shows a UI error dialogue when stop user servers fails", async () => {
   let rejectSpy = mockAsyncRejection();
 
   await act(async () => {
-    render(
-      <Provider store={createStore(() => {}, {})}>
-        <HashRouter>
-          <Switch>
-            <ServerDashboard
-              updateUsers={spy}
-              shutdownHub={spy}
-              startServer={spy}
-              stopServer={rejectSpy}
-              startAll={spy}
-              stopAll={spy}
-            />
-          </Switch>
-        </HashRouter>
-      </Provider>,
-    );
+    render(serverDashboardJsx({ stopServer: rejectSpy }));
   });
 
   let stop = screen.getByText("Stop Server");
@@ -567,22 +478,7 @@ test("Shows a UI error dialogue when stop user server returns an improper status
   let rejectSpy = mockAsync({ status: 403 });
 
   await act(async () => {
-    render(
-      <Provider store={createStore(() => {}, {})}>
-        <HashRouter>
-          <Switch>
-            <ServerDashboard
-              updateUsers={spy}
-              shutdownHub={spy}
-              startServer={spy}
-              stopServer={rejectSpy}
-              startAll={spy}
-              stopAll={spy}
-            />
-          </Switch>
-        </HashRouter>
-      </Provider>,
-    );
+    render(serverDashboardJsx({ stopServer: rejectSpy }));
   });
 
   let stop = screen.getByText("Stop Server");
@@ -613,72 +509,55 @@ test("Search for user calls updateUsers with name filter", async () => {
     });
   });
   await act(async () => {
-    render(
-      <Provider store={createStore(mockReducers, mockAppState())}>
-        <HashRouter>
-          <Switch>
-            <ServerDashboard
-              updateUsers={mockUpdateUsers}
-              shutdownHub={spy}
-              startServer={spy}
-              stopServer={spy}
-              startAll={spy}
-              stopAll={spy}
-            />
-          </Switch>
-        </HashRouter>
-      </Provider>,
-    );
+    searchParams.set("offset", "2");
+    render(serverDashboardJsx({ updateUsers: mockUpdateUsers }));
   });
 
   let search = screen.getByLabelText("user-search");
 
   expect(mockUpdateUsers.mock.calls).toHaveLength(1);
 
+  expect(searchParams.get("offset")).toEqual("2");
   userEvent.type(search, "a");
   expect(search.value).toEqual("a");
-  clock.tick(400);
-  expect(mockReducers.mock.calls).toHaveLength(3);
-  var lastState =
-    mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
-  expect(lastState.name_filter).toEqual("a");
-  // TODO: this should
-  expect(mockUpdateUsers.mock.calls).toHaveLength(1);
+  await act(async () => {
+    jest.runAllTimers();
+  });
+  expect(searchParams.get("name_filter")).toEqual("a");
+  expect(searchParams.get("offset")).toEqual(null);
+  // FIXME: useSelector mocks prevent updateUsers from being called
+  // expect(mockUpdateUsers.mock.calls).toHaveLength(2);
+  // expect(mockUpdateUsers).toBeCalledWith(0, 100, "a");
   userEvent.type(search, "b");
   expect(search.value).toEqual("ab");
-  clock.tick(400);
-  expect(mockReducers.mock.calls).toHaveLength(4);
-  lastState =
-    mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
-  expect(lastState.name_filter).toEqual("ab");
-  expect(lastState.user_page.offset).toEqual(0);
+  await act(async () => {
+    jest.runAllTimers();
+  });
+  expect(searchParams.get("name_filter")).toEqual("ab");
+  // expect(mockUpdateUsers).toBeCalledWith(0, 100, "ab");
 });
 
 test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
-  let callbackSpy = mockAsync();
+  let updateUsers = mockAsync();
 
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx({ updateUsers: updateUsers }));
   });
 
-  expect(callbackSpy).toBeCalledWith(0, 2, "");
+  expect(updateUsers).toBeCalledWith(0, 100, "");
 
-  expect(mockReducers.mock.results).toHaveLength(2);
-  lastState =
-    mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
-  console.log(lastState);
-  expect(lastState.user_page.offset).toEqual(0);
-  expect(lastState.user_page.limit).toEqual(2);
+  var n = 3;
+  expect(searchParams.get("offset")).toEqual(null);
+  expect(searchParams.get("limit")).toEqual(null);
 
   let next = screen.getByTestId("paginate-next");
-  fireEvent.click(next);
-  clock.tick(400);
+  await act(async () => {
+    fireEvent.click(next);
+    jest.runAllTimers();
+  });
 
-  expect(mockReducers.mock.results).toHaveLength(3);
-  var lastState =
-    mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
-  expect(lastState.user_page.offset).toEqual(2);
-  expect(lastState.user_page.limit).toEqual(2);
+  expect(searchParams.get("offset")).toEqual("100");
+  expect(searchParams.get("limit")).toEqual(null);
 
   // FIXME: should call updateUsers, does in reality.
   // tests don't reflect reality due to mocked state/useSelector
@@ -688,10 +567,8 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
 });
 
 test("Server delete button exists for named servers", async () => {
-  let callbackSpy = mockAsync();
-
   await act(async () => {
-    render(serverDashboardJsx(callbackSpy));
+    render(serverDashboardJsx());
   });
 
   for (let server in bar_servers) {
@@ -705,11 +582,9 @@ test("Server delete button exists for named servers", async () => {
 });
 
 test("Start server and confirm pending state", async () => {
-  let spy = mockAsync();
-
   let mockStartServer = jest.fn(() => {
     return new Promise(async (resolve) =>
-      clock.setTimeout(() => {
+      setTimeout(() => {
         resolve({ status: 200 });
       }, 100),
     );
@@ -719,20 +594,10 @@ test("Start server and confirm pending state", async () => {
 
   await act(async () => {
     render(
-      <Provider store={createStore(mockReducers, {})}>
-        <HashRouter>
-          <Switch>
-            <ServerDashboard
-              updateUsers={mockUpdateUsers}
-              shutdownHub={spy}
-              startServer={mockStartServer}
-              stopServer={spy}
-              startAll={spy}
-              stopAll={spy}
-            />
-          </Switch>
-        </HashRouter>
-      </Provider>,
+      serverDashboardJsx({
+        updateUsers: mockUpdateUsers,
+        startServer: mockStartServer,
+      }),
     );
   });
 
@@ -755,7 +620,7 @@ test("Start server and confirm pending state", async () => {
   expect(buttons[1]).toBeEnabled();
 
   await act(async () => {
-    await clock.tick(100);
+    jest.runAllTimers();
   });
   expect(mockUpdateUsers.mock.calls).toHaveLength(2);
 });
diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py
--- a/jupyterhub/tests/browser/test_browser.py
+++ b/jupyterhub/tests/browser/test_browser.py
@@ -1041,7 +1041,7 @@ async def test_start_stop_all_servers_on_admin_page(app, browser, admin_user):
     )
 
 
-@pytest.mark.parametrize("added_count_users", [10, 47, 48, 49, 110])
+@pytest.mark.parametrize("added_count_users", [10, 49, 50, 51, 99, 100, 101])
 async def test_paging_on_admin_page(
     app, browser, admin_user, added_count_users, create_user_with_scopes
 ):
@@ -1057,7 +1057,7 @@ async def test_paging_on_admin_page(
     btn_next = browser.get_by_role("button", name="Next")
     # verify "Previous"/"Next" button clickability depending on users number on the page
     await expect(displaying).to_have_text(
-        re.compile(".*" + f"0-{min(users_count_db,50)}" + ".*")
+        re.compile(".*" + f"1-{min(users_count_db, 50)}" + ".*")
     )
     if users_count_db > 50:
         await expect(btn_next.locator("//span")).to_have_class("active-pagination")
@@ -1065,10 +1065,10 @@ async def test_paging_on_admin_page(
         await btn_next.click()
         if users_count_db <= 100:
             await expect(displaying).to_have_text(
-                re.compile(".*" + f"50-{users_count_db}" + ".*")
+                re.compile(".*" + f"51-{users_count_db}" + ".*")
             )
         else:
-            await expect(displaying).to_have_text(re.compile(".*" + "50-100" + ".*"))
+            await expect(displaying).to_have_text(re.compile(".*" + "51-100" + ".*"))
             await expect(btn_next.locator("//span")).to_have_class("active-pagination")
         await expect(btn_previous.locator("//span")).to_have_class("active-pagination")
         # click on Previous button
@@ -1117,7 +1117,7 @@ async def test_search_on_admin_page(
     if users_count_db_filtered <= 50:
         await expect(filtered_list_on_page).to_have_count(users_count_db_filtered)
         await expect(displaying).to_contain_text(
-            re.compile(f"0-{users_count_db_filtered}")
+            re.compile(f"1-{users_count_db_filtered}")
         )
         # check that users names contain the search value in the filtered list
         for element in await filtered_list_on_page.get_by_test_id(
@@ -1126,7 +1126,7 @@ async def test_search_on_admin_page(
             await expect(element).to_contain_text(re.compile(f".*{search_value}.*"))
     else:
         await expect(filtered_list_on_page).to_have_count(50)
-        await expect(displaying).to_contain_text(re.compile("0-50"))
+        await expect(displaying).to_contain_text(re.compile("1-50"))
         # click on Next button to verify that the rest part of filtered list is displayed on the next page
         await browser.get_by_role("button", name="Next").click()
         filtered_list_on_next_page = browser.locator('//tr[@class="user-row"]')