diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.test.tsx b/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.test.tsx index 640201651e..ef578c84bf 100644 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.test.tsx +++ b/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.test.tsx @@ -1,81 +1,137 @@ import MachineListPagination, { Label } from "./MachineListPagination"; +import type { Props as MachineListPaginationProps } from "./MachineListPagination"; -import { render, screen } from "testing/utils"; - -it("displays pagination if there are machines", () => { - render( - - ); - expect( - screen.getByRole("navigation", { name: Label.Pagination }) - ).toBeInTheDocument(); -}); +import { fireEvent, render, screen, userEvent, waitFor } from "testing/utils"; -it("does not display pagination if there are no machines", () => { - render( - - ); - expect( - screen.queryByRole("navigation", { name: Label.Pagination }) - ).not.toBeInTheDocument(); -}); +describe("MachineListPagination", () => { + let props: MachineListPaginationProps; + beforeEach(() => { + props = { + currentPage: 1, + itemsPerPage: 20, + machineCount: 100, + machinesLoading: false, + paginate: jest.fn(), + }; + }); -it("displays pagination while refetching machines", () => { - // Set up shared props to make it clear what's changing on rerenders. - const props = { - currentPage: 1, - itemsPerPage: 20, - machineCount: 100, - machinesLoading: false, - paginate: jest.fn(), - }; - const { rerender } = render(); - expect( - screen.getByRole("navigation", { name: Label.Pagination }) - ).toBeInTheDocument(); - rerender(); - expect( - screen.getByRole("navigation", { name: Label.Pagination }) - ).toBeInTheDocument(); -}); + it("displays pagination if there are machines", () => { + render(); + expect( + screen.getByRole("navigation", { name: Label.Pagination }) + ).toBeInTheDocument(); + }); + + it("does not display pagination if there are no machines", () => { + props.machineCount = 0; + render(); + expect( + screen.queryByRole("navigation", { name: Label.Pagination }) + ).not.toBeInTheDocument(); + }); + + it("displays pagination while refetching machines", () => { + const { rerender } = render(); + expect( + screen.getByRole("navigation", { name: Label.Pagination }) + ).toBeInTheDocument(); + rerender(); + expect( + screen.getByRole("navigation", { name: Label.Pagination }) + ).toBeInTheDocument(); + }); + + it("hides pagination if there are no refetched machines", () => { + const { rerender } = render(); + expect( + screen.getByRole("navigation", { name: Label.Pagination }) + ).toBeInTheDocument(); + props.machinesLoading = true; + rerender(); + expect( + screen.getByRole("navigation", { name: Label.Pagination }) + ).toBeInTheDocument(); + props.machinesLoading = false; + props.machineCount = 0; + rerender(); + expect( + screen.queryByRole("navigation", { name: Label.Pagination }) + ).not.toBeInTheDocument(); + }); + + it("calls a function to go to the next page when the 'Next page' button is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Next page" })); + expect(props.paginate).toHaveBeenCalledWith(2); + }); + + it("calls a function to go to the previous page when the 'Previous page' button is clicked", async () => { + props.currentPage = 2; + + render(); + await userEvent.click( + screen.getByRole("button", { name: "Previous page" }) + ); + expect(props.paginate).toHaveBeenCalledWith(1); + }); + + it("takes an input for page number and calls a function to paginate if the number is valid", async () => { + render(); + + const pageInput = screen.getByRole("spinbutton", { name: "page number" }); + + // Using userEvent to clear this first doesn't work, so we have to use fireEvent instead. + fireEvent.change(pageInput, { target: { value: "4" } }); + await userEvent.click(pageInput); + await userEvent.keyboard("{Enter}"); + + await waitFor(() => { + expect(props.paginate).toHaveBeenCalledWith(4); + }); + }); + + it("displays an error if no value is present in the page number input", async () => { + render(); + + const pageInput = screen.getByRole("spinbutton", { name: "page number" }); + + await userEvent.clear(pageInput); + expect(screen.getByText(/Enter a page number/i)).toBeInTheDocument(); + }); + + it("displays an error if an invalid page number is entered", async () => { + render(); + + const pageInput = screen.getByRole("spinbutton", { name: "page number" }); + + // Using userEvent to clear this first doesn't work, so we have to use fireEvent instead. + fireEvent.change(pageInput, { target: { value: "69" } }); + await waitFor(() => { + expect( + screen.getByText(/"69" is not a valid page number/i) + ).toBeInTheDocument(); + }); + }); + + it("reverts the value to the current page number and hides error messages if the input is blurred", async () => { + render(); + + const pageInput = screen.getByRole("spinbutton", { name: "page number" }); + + fireEvent.change(pageInput, { target: { value: "69" } }); + + await waitFor(() => { + expect( + screen.getByText(/"69" is not a valid page number/i) + ).toBeInTheDocument(); + }); + + fireEvent.blur(pageInput); + + expect( + screen.queryByText(/"69" is not a valid page number/i) + ).not.toBeInTheDocument(); -it("hides pagination if there are no refetched machines", () => { - // Set up shared props to make it clear what's changing on rerenders. - const props = { - currentPage: 1, - itemsPerPage: 20, - machineCount: 100, - machinesLoading: false, - paginate: jest.fn(), - }; - const { rerender } = render(); - expect( - screen.getByRole("navigation", { name: Label.Pagination }) - ).toBeInTheDocument(); - rerender(); - expect( - screen.getByRole("navigation", { name: Label.Pagination }) - ).toBeInTheDocument(); - rerender( - - ); - expect( - screen.queryByRole("navigation", { name: Label.Pagination }) - ).not.toBeInTheDocument(); + expect(pageInput).toHaveValue(1); + }); }); diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.tsx b/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.tsx index 9274c4e8fc..7943a6d146 100644 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.tsx +++ b/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect } from "react"; +import { useState, useRef, useEffect } from "react"; import type { PaginationProps, @@ -16,7 +16,7 @@ export enum Label { export const DEFAULT_DEBOUNCE_INTERVAL = 500; -type Props = PropsWithSpread< +export type Props = PropsWithSpread< { currentPage: PaginationProps["currentPage"]; itemsPerPage: PaginationProps["itemsPerPage"]; @@ -33,7 +33,10 @@ const MachineListPagination = ({ ...props }: Props): JSX.Element | null => { const intervalRef = useRef(null); - const [pageNumber, setPageNumber] = useState(props.currentPage); + const [pageNumber, setPageNumber] = useState( + props.currentPage + ); + const [error, setError] = useState(""); // Clear the timeout when the component is unmounted. useEffect(() => { @@ -64,20 +67,36 @@ const MachineListPagination = ({ { + setPageNumber(props.currentPage); + setError(""); + }} onChange={(e) => { - setPageNumber(e.target.valueAsNumber); - if (intervalRef.current) { - clearTimeout(intervalRef.current); - } - intervalRef.current = setTimeout(() => { - if ( - e.target.valueAsNumber > 0 && - e.target.valueAsNumber <= totalPages - ) { - props.paginate(e.target.valueAsNumber); + if (e.target.value) { + setPageNumber(e.target.valueAsNumber); + if (intervalRef.current) { + clearTimeout(intervalRef.current); } - }, DEFAULT_DEBOUNCE_INTERVAL); + intervalRef.current = setTimeout(() => { + if ( + e.target.valueAsNumber > totalPages || + e.target.valueAsNumber < 1 + ) { + setError( + `"${e.target.valueAsNumber}" is not a valid page number.` + ); + } else { + setError(""); + props.paginate(e.target.valueAsNumber); + } + }, DEFAULT_DEBOUNCE_INTERVAL); + } else { + setPageNumber(undefined); + setError("Enter a page number."); + } }} + required type="number" value={pageNumber} />{" "} diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/_index.scss b/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/_index.scss index b12cf7a587..00b8eb98b7 100644 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/_index.scss +++ b/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/_index.scss @@ -21,6 +21,12 @@ text-align: center; } + .p-form-validation__message { + position: absolute; + margin-top: $spv--small; + margin-left: $spv--small; + } + // required to get rid of buttons/arrows while keeping number-only input input::-webkit-outer-spin-button, input::-webkit-inner-spin-button { diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx b/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx index abde97e094..2188147aec 100644 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx +++ b/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx @@ -832,7 +832,7 @@ export const MachineListTable = ({ return ( <> {machineCount ? ( -
+

) : null} -
+
) : null}