← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~jonesogolo/maas-site-manager:1525-add-enrollment-pagination into maas-site-manager:main

 

Jones Ogolo has proposed merging ~jonesogolo/maas-site-manager:1525-add-enrollment-pagination into maas-site-manager:main.

Commit message:
added pagination bar to requests page

Requested reviews:
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~jonesogolo/maas-site-manager/+git/maas-site-manager/+merge/440958

This ticket adds a pagination bar to the requests and tokens pages and abstracts the pagination methods to a separate hook.

Steps to QA:
1. goto /requests
2. ensure pagination buttons work as expected
3. click the next button a couple of times and then change the items per page
4. Ensure that you're returned to the first page after step 3.
-- 
Your team MAAS Committers is requested to review the proposed merge of ~jonesogolo/maas-site-manager:1525-add-enrollment-pagination into maas-site-manager:main.
diff --git a/frontend/src/components/RequestsList/RequestsList.tsx b/frontend/src/components/RequestsList/RequestsList.tsx
index da895ed..5554850 100644
--- a/frontend/src/components/RequestsList/RequestsList.tsx
+++ b/frontend/src/components/RequestsList/RequestsList.tsx
@@ -1,22 +1,48 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
 
 import { Col, Row } from "@canonical/react-components";
 
 import EnrollmentActions from "@/components/EnrollmentActions";
 import RequestsTable from "@/components/RequestsTable";
+import PaginationBar from "@/components/base/PaginationBar";
 import { useRequestsQuery } from "@/hooks/api";
+import usePagination from "@/hooks/usePagination";
 
+const DEFAULT_PAGE_SIZE = 50;
 const Requests: React.FC = () => {
-  // TODO: update page and size when pagination is implemented
-  // https://warthogs.atlassian.net/browse/MAASENG-1525
-  const [page] = useState<number>(0);
-  const [size] = useState<number>(50);
-  const { data, isLoading, isFetchedAfterMount } = useRequestsQuery({ page: `${page}`, size: `${size}` });
+  const [totalDataCount, setTotalDataCount] = useState(0);
+  const { page, debouncedPage, size, handleNextClick, handlePreviousClick, handlePageSizeChange, setPage } =
+    usePagination(DEFAULT_PAGE_SIZE, totalDataCount);
+  const { data, isLoading, isFetchedAfterMount } = useRequestsQuery({
+    page: `${debouncedPage}`,
+    size: `${size}`,
+  });
+
+  useEffect(() => {
+    if (data && "total" in data) {
+      setTotalDataCount(data.total);
+    }
+  }, [data]);
+
   return (
     <section>
       <EnrollmentActions />
       <Row>
         <Col size={12}>
+          <PaginationBar
+            currentPage={page + 1}
+            dataContext="open enrolment requests"
+            debouncedPageNumber={debouncedPage + 1}
+            handlePageSizeChange={handlePageSizeChange}
+            isLoading={isLoading}
+            itemsPerPage={size}
+            onNextClick={handleNextClick}
+            onPreviousClick={handlePreviousClick}
+            setCurrentPage={setPage}
+            totalItems={data?.total || 0}
+          />
+        </Col>
+        <Col size={12}>
           <RequestsTable data={data} isFetchedAfterMount={isFetchedAfterMount} isLoading={isLoading} />
         </Col>
       </Row>
diff --git a/frontend/src/components/TokensList/TokensList.tsx b/frontend/src/components/TokensList/TokensList.tsx
index ea75f63..a6b1912 100644
--- a/frontend/src/components/TokensList/TokensList.tsx
+++ b/frontend/src/components/TokensList/TokensList.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
 
 import { Button, Col, Row } from "@canonical/react-components";
 
@@ -8,26 +8,23 @@ import TokensTable from "./components/TokensTable/TokensTable";
 
 import { useAppContext } from "@/context";
 import { useTokensQuery } from "@/hooks/api";
-import useDebouncedValue from "@/hooks/useDebouncedValue";
+import usePagination from "@/hooks/usePagination";
 
 const DEFAULT_PAGE_SIZE = 50;
 
 const TokensList = () => {
   const { setSidebar } = useAppContext();
-  const [page, setPage] = useState(0);
-  const [size, setSize] = useState(DEFAULT_PAGE_SIZE);
-  const debouncedPageNumber = useDebouncedValue(page);
+  const [totalDataCount, setTotalDataCount] = useState(0);
+  const { page, debouncedPage, size, handleNextClick, handlePreviousClick, handlePageSizeChange, setPage } =
+    usePagination(DEFAULT_PAGE_SIZE, totalDataCount);
 
-  const { data, isLoading, isFetchedAfterMount } = useTokensQuery({ page: `${debouncedPageNumber}`, size: `${size}` });
+  const { data, isLoading, isFetchedAfterMount } = useTokensQuery({ page: `${debouncedPage}`, size: `${size}` });
 
-  const handleNextClick = () => {
-    const maxPage = data?.total ? data?.total / size : 1;
-    setPage((prev) => (prev >= maxPage ? maxPage : prev + 1));
-  };
-
-  const handlePreviousClick = () => {
-    setPage((prev) => (prev === 0 ? 0 : prev - 1));
-  };
+  useEffect(() => {
+    if (data && "total" in data) {
+      setTotalDataCount(data.total);
+    }
+  }, [data]);
 
   return (
     <section>
@@ -50,13 +47,13 @@ const TokensList = () => {
       <PaginationBar
         currentPage={page + 1}
         dataContext="tokens"
+        debouncedPageNumber={debouncedPage + 1}
+        handlePageSizeChange={handlePageSizeChange}
         isLoading={isLoading}
         itemsPerPage={size}
         onNextClick={handleNextClick}
         onPreviousClick={handlePreviousClick}
-        resetPageCount={() => setPage(0)}
         setCurrentPage={setPage}
-        setPageSize={setSize}
         totalItems={data?.total || 0}
       />
       <TokensTable data={data} isFetchedAfterMount={isFetchedAfterMount} isLoading={isLoading} />
diff --git a/frontend/src/components/base/PaginationBar/PaginationBar.test.tsx b/frontend/src/components/base/PaginationBar/PaginationBar.test.tsx
index 062460d..a282680 100644
--- a/frontend/src/components/base/PaginationBar/PaginationBar.test.tsx
+++ b/frontend/src/components/base/PaginationBar/PaginationBar.test.tsx
@@ -7,13 +7,13 @@ it("should render the PaginationBar component correctly", () => {
     <PaginationBar
       currentPage={1}
       dataContext="tokens"
+      debouncedPageNumber={1}
+      handlePageSizeChange={() => {}}
       isLoading={false}
       itemsPerPage={10}
       onNextClick={() => {}}
       onPreviousClick={() => {}}
-      resetPageCount={() => {}}
       setCurrentPage={() => {}}
-      setPageSize={() => {}}
       totalItems={50}
     />,
   );
diff --git a/frontend/src/components/base/PaginationBar/PaginationBar.tsx b/frontend/src/components/base/PaginationBar/PaginationBar.tsx
index c146da8..42d3a51 100644
--- a/frontend/src/components/base/PaginationBar/PaginationBar.tsx
+++ b/frontend/src/components/base/PaginationBar/PaginationBar.tsx
@@ -7,11 +7,11 @@ import type { AppPaginationProps } from "@/components/base/TablePagination/Table
 import TablePagination from "@/components/base/TablePagination/TablePagination";
 
 type TokensTableControlProps = AppPaginationProps & {
-  setPageSize: (size: number) => void;
-  resetPageCount: () => void;
+  handlePageSizeChange: (size: number) => void;
   dataContext: string;
   setCurrentPage: (page: number) => void;
   isLoading: boolean;
+  debouncedPageNumber: number;
 };
 
 const PaginationBar = ({
@@ -20,11 +20,11 @@ const PaginationBar = ({
   totalItems,
   onNextClick,
   onPreviousClick,
-  setPageSize,
-  resetPageCount,
+  handlePageSizeChange,
   dataContext,
   setCurrentPage,
   isLoading,
+  debouncedPageNumber,
 }: TokensTableControlProps) => {
   const pageCounts = useMemo(() => [20, 30, 50], []);
   const pageOptions = useMemo(
@@ -33,9 +33,8 @@ const PaginationBar = ({
   );
 
   const handleSizeChange = (e: ChangeEvent<HTMLSelectElement>) => {
-    resetPageCount();
     const { value } = e.target;
-    setPageSize(Number(value));
+    handlePageSizeChange(Number(value));
   };
 
   const getDisplayedDataCount = () => {
@@ -60,6 +59,7 @@ const PaginationBar = ({
       <Col medium={3} size={3} small={4}>
         <TablePagination
           currentPage={currentPage}
+          debouncedPageNumber={debouncedPageNumber}
           isLoading={isLoading}
           itemsPerPage={itemsPerPage}
           onNextClick={onNextClick}
diff --git a/frontend/src/components/base/PaginationBar/_PaginationBar.scss.scss b/frontend/src/components/base/PaginationBar/_PaginationBar.scss.scss
index 3936387..fc38e04 100644
--- a/frontend/src/components/base/PaginationBar/_PaginationBar.scss.scss
+++ b/frontend/src/components/base/PaginationBar/_PaginationBar.scss.scss
@@ -3,6 +3,7 @@
   border-bottom: 1px solid $color-mid-light;
   align-items: center;
   padding: $spv--small 0;
+  margin-bottom: $spv--large;
 
   .pagination-bar__description {
     margin: 0;
diff --git a/frontend/src/components/base/TablePagination/TablePagination.test.tsx b/frontend/src/components/base/TablePagination/TablePagination.test.tsx
index 4befc42..2b915fe 100644
--- a/frontend/src/components/base/TablePagination/TablePagination.test.tsx
+++ b/frontend/src/components/base/TablePagination/TablePagination.test.tsx
@@ -8,6 +8,7 @@ it("should render pagination component correctly", () => {
   render(
     <TablePagination
       currentPage={1}
+      debouncedPageNumber={1}
       isLoading={false}
       itemsPerPage={10}
       onNextClick={() => {}}
@@ -23,6 +24,7 @@ it("should render previous button as disabled on first page", () => {
   render(
     <TablePagination
       currentPage={1}
+      debouncedPageNumber={1}
       isLoading={false}
       itemsPerPage={10}
       onNextClick={() => {}}
@@ -38,6 +40,7 @@ it("should render next button as disabled on last page", () => {
   render(
     <TablePagination
       currentPage={1}
+      debouncedPageNumber={1}
       isLoading={false}
       itemsPerPage={10}
       onNextClick={() => {}}
@@ -55,6 +58,7 @@ it("next and previous buttons work as expected", async () => {
   render(
     <TablePagination
       currentPage={2}
+      debouncedPageNumber={1}
       isLoading={false}
       itemsPerPage={10}
       onNextClick={onNextClick}
@@ -77,6 +81,7 @@ it("should have a numeric input showing the current page", () => {
   render(
     <TablePagination
       currentPage={currentPage}
+      debouncedPageNumber={1}
       isLoading={false}
       itemsPerPage={10}
       onNextClick={() => {}}
@@ -93,6 +98,7 @@ it("disables numeric input and buttons when data is loading", () => {
   render(
     <TablePagination
       currentPage={1}
+      debouncedPageNumber={1}
       isLoading={true}
       itemsPerPage={10}
       onNextClick={() => {}}
diff --git a/frontend/src/components/base/TablePagination/TablePagination.tsx b/frontend/src/components/base/TablePagination/TablePagination.tsx
index 2dbd86f..60f2455 100644
--- a/frontend/src/components/base/TablePagination/TablePagination.tsx
+++ b/frontend/src/components/base/TablePagination/TablePagination.tsx
@@ -11,6 +11,7 @@ export type AppPaginationProps = {
   onPreviousClick: () => void;
   setCurrentPage: (x: number) => void;
   isLoading: boolean;
+  debouncedPageNumber: number;
 };
 
 const TablePagination = ({
@@ -21,10 +22,11 @@ const TablePagination = ({
   onPreviousClick,
   setCurrentPage,
   isLoading,
+  debouncedPageNumber,
 }: AppPaginationProps) => {
   const totalPages = useMemo(() => Math.ceil(totalItems / itemsPerPage), [itemsPerPage, totalItems]);
 
-  const isInputError = currentPage <= 0 || currentPage > totalPages;
+  const isInputError = debouncedPageNumber <= 0 || debouncedPageNumber > totalPages;
 
   const handlePageInput = (e: ChangeEvent<HTMLInputElement>) => {
     const { valueAsNumber } = e.target;
diff --git a/frontend/src/hooks/usePagination.test.ts b/frontend/src/hooks/usePagination.test.ts
new file mode 100644
index 0000000..2c4f6b8
--- /dev/null
+++ b/frontend/src/hooks/usePagination.test.ts
@@ -0,0 +1,41 @@
+import usePagination from "./usePagination";
+
+import { hookAct, renderHook } from "@/test-utils";
+
+describe("usePagination", () => {
+  const samplePageSize = 50;
+  const sampleTotalCount = 200;
+
+  it("initializes the page to 0 when called", () => {
+    const { result } = renderHook(() => usePagination(samplePageSize, sampleTotalCount));
+
+    expect(result.current.page).toBe(0);
+  });
+
+  it("next and previous page functions work correctly", () => {
+    const { result } = renderHook(() => usePagination(samplePageSize, sampleTotalCount));
+
+    hookAct(() => {
+      result.current.handleNextClick();
+    });
+
+    expect(result.current.page).toBe(1);
+
+    hookAct(() => {
+      result.current.handlePreviousClick();
+    });
+
+    expect(result.current.page).toBe(0);
+  });
+
+  it("should reset page count after page size is changed", () => {
+    const { result } = renderHook(() => usePagination(samplePageSize, sampleTotalCount));
+
+    hookAct(() => {
+      result.current.handleNextClick();
+      result.current.handlePageSizeChange(10);
+    });
+
+    expect(result.current.page).toBe(0);
+  });
+});
diff --git a/frontend/src/hooks/usePagination.ts b/frontend/src/hooks/usePagination.ts
new file mode 100644
index 0000000..5608eb7
--- /dev/null
+++ b/frontend/src/hooks/usePagination.ts
@@ -0,0 +1,38 @@
+import { useState } from "react";
+
+import useDebouncedValue from "./useDebouncedValue";
+
+function usePagination(pageSize: number, totalItems: number) {
+  const [page, setPage] = useState(0);
+  const [size, setSize] = useState(pageSize);
+  const debouncedPage = useDebouncedValue(page);
+
+  const handleNextClick = () => {
+    const maxPage = totalItems > 0 ? totalItems / size : 1;
+    setPage((prev) => (prev >= maxPage ? maxPage : prev + 1));
+  };
+
+  const handlePreviousClick = () => {
+    setPage((prev) => (prev === 0 ? 0 : prev - 1));
+  };
+
+  const resetPageCount = () => setPage(0);
+
+  const handlePageSizeChange = (size: number) => {
+    setSize(size);
+    resetPageCount();
+  };
+
+  return {
+    page: page,
+    size,
+    setPage,
+    debouncedPage: debouncedPage,
+    handleNextClick,
+    handlePreviousClick,
+    handlePageSizeChange,
+    totalItems,
+  };
+}
+
+export default usePagination;
diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx
index 2d72a39..cabf08d 100644
--- a/frontend/src/test-utils.tsx
+++ b/frontend/src/test-utils.tsx
@@ -71,7 +71,7 @@ const getByTextContent = (text: string | RegExp) => {
 
 export { screen, within, waitFor, act } from "@testing-library/react";
 export { customRender as render };
-export { renderHook } from "@testing-library/react-hooks";
+export { renderHook, act as hookAct } from "@testing-library/react-hooks";
 export { default as userEvent } from "@testing-library/user-event";
 export { renderWithMemoryRouter };
 export { Providers };

Follow ups