diff --git a/packages/ui-components/src/components/Pagination/Pagination.component.tsx b/packages/ui-components/src/components/Pagination/Pagination.component.tsx new file mode 100644 index 000000000..80e3fb084 --- /dev/null +++ b/packages/ui-components/src/components/Pagination/Pagination.component.tsx @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from "react" +import { Stack } from "../Stack/Stack.component" +import { Button } from "../Button/Button.component" +import { Spinner } from "../Spinner/Spinner.component" +import { TextInput } from "../TextInput/TextInput.component" +import { Select } from "../Select/Select.component" +import { SelectOption } from "../SelectOption/SelectOption.component" + +const paginationStyles = ` + jn-flex + jn-gap-[0.375rem] + jn-items-center +` + +const spinnerStyles = ` + jn-ml-3 +` + +const inputStyles = ` + justify-normal +` + +export interface PaginationProps { + /** The variant of the Pagination component */ + variant?: "default" | "number" | "select" | "input" + /** The current page number */ + currentPage?: number + /** The total number of pages */ + totalPages?: number + /** The total number of pages (fallback for older versions) */ + pages?: number + /** Disable component */ + disabled?: boolean + /** Emulate state as being on the first page, leading to left/prev button being disabled */ + isFirstPage?: boolean + /** Emulate state as being on the last page, leading to right/next button being disabled */ + isLastPage?: boolean + /** onPress (previous) handler */ + onPressPrevious?: (newPage?: number) => void + /** onPress (next) handler */ + onPressNext?: (newPage?: number) => void + /** Select change handler (select variant) */ + onSelectChange?: (selected: number) => void + /** Input field change handler (input variant) */ + onInputChange?: (inputValue: number) => void + /** onKeyDown handler (input variant) */ + onKeyDown?: (value?: number) => void + /** onBlur handler (input variant)*/ + onBlur?: (value?: number) => void + /** Spinner loading animation + disables interactions */ + progress?: boolean + /** Additional class name */ + className?: string +} + +/** A basic Pagination component. Renders '<' and '>' buttons as a minimum/default. Keeps internal state of the current page for uncontrolled use. */ +export const Pagination: React.FC = ({ + variant = "default", + currentPage = 1, + totalPages, + pages, + disabled = false, + isFirstPage = false, + isLastPage = false, + onPressPrevious, + onPressNext, + onSelectChange, + onInputChange, + onKeyDown, + onBlur, + progress = false, + className = "", + ...props +}) => { + const [controlPage, setControlCurrentPage] = useState(currentPage) + const [controlTotalPage, setControlTotalPage] = useState(pages ?? totalPages) + + useEffect(() => { + setControlCurrentPage(currentPage) + const totalPage = pages ?? totalPages + setControlTotalPage(totalPage) + if (controlPage > (totalPage ?? Number.MAX_SAFE_INTEGER)) { + setControlCurrentPage(totalPage ?? 1) + } + }, [currentPage, totalPages, pages, controlPage]) + + const handlePrevClick = () => { + if (controlPage > 1) { + const newPage = controlPage - 1 + setControlCurrentPage(newPage) + onPressPrevious?.(newPage) + } + } + + const handleNextClick = () => { + if (controlPage && controlPage < (controlTotalPage ?? Number.MAX_SAFE_INTEGER)) { + const newPage = controlPage + 1 + setControlCurrentPage(newPage) + onPressNext?.(newPage) + } + } + + const handleSelectChange = (event: React.ChangeEvent) => { + const selected = parseInt(event.target.value, 10) + setControlCurrentPage(selected) + onSelectChange?.(selected) + } + + const handleInputChange = (event: React.ChangeEvent) => { + let inputValue = parseInt(event.target.value, 10) + if (isNaN(inputValue)) { + inputValue = controlPage // keep the current value if input is not a number + } else if (inputValue < 1) { + inputValue = 1 // set to 1 if less than 1 + } else if (controlTotalPage && inputValue > controlTotalPage) { + inputValue = controlTotalPage // set to total page limit + } + setControlCurrentPage(inputValue) + onInputChange?.(inputValue) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + onKeyDown?.(controlPage) + } + } + + const handleBlur = () => { + onBlur?.(controlPage) + } + + const getInputWidthClass = () => { + const logLength = Math.min(controlPage?.toString().length || 1, 5) + const width = `${(logLength * 0.6 + 2.1).toFixed(1)}rem` // 0.6rem per digit + 2.1rem + return { width } + } + + return ( +
+
+ ) +} diff --git a/packages/ui-components/src/components/Pagination/Pagination.stories.js b/packages/ui-components/src/components/Pagination/Pagination.stories.tsx similarity index 93% rename from packages/ui-components/src/components/Pagination/Pagination.stories.js rename to packages/ui-components/src/components/Pagination/Pagination.stories.tsx index 36bd0b41a..c8ce7a7c6 100644 --- a/packages/ui-components/src/components/Pagination/Pagination.stories.js +++ b/packages/ui-components/src/components/Pagination/Pagination.stories.tsx @@ -5,8 +5,9 @@ import React from "react" import PropTypes from "prop-types" -import { Pagination } from "./index.js" -import { PortalProvider } from "../../deprecated_js/PortalProvider/PortalProvider.component" + +import { Pagination } from "./Pagination.component" +import { PortalProvider } from "../PortalProvider/PortalProvider.component" export default { title: "Components/Pagination", diff --git a/packages/ui-components/src/components/Pagination/Pagination.test.tsx b/packages/ui-components/src/components/Pagination/Pagination.test.tsx new file mode 100644 index 000000000..95d2c338f --- /dev/null +++ b/packages/ui-components/src/components/Pagination/Pagination.test.tsx @@ -0,0 +1,429 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, test, vi } from "vitest" + +import { Pagination } from "./Pagination.component" + +describe("Pagination", () => { + test("renders a Pagination", () => { + render() + expect(screen.getByTestId("my-pagination")).toBeInTheDocument() + expect(screen.getByTestId("my-pagination")).toHaveClass("juno-pagination") + }) + + test("renders a default Pagination with only two buttons by default", () => { + render() + expect(screen.getByTestId("my-pagination")).toBeInTheDocument() + expect(screen.getByTestId("my-pagination")).toHaveClass("juno-pagination-default") + expect(screen.queryAllByRole("button")).toHaveLength(2) + expect(screen.queryAllByRole("combobox")).toHaveLength(0) + expect(screen.queryAllByRole("textbox")).toHaveLength(0) + }) + + test("renders a number variant Pagination as passed", () => { + render() + expect(screen.getByTestId("my-pagination")).toBeInTheDocument() + expect(screen.getByTestId("my-pagination")).toHaveClass("juno-pagination-number") + expect(screen.getByTestId("my-pagination")).toHaveTextContent("12") + expect(screen.queryAllByRole("button")).toHaveLength(2) + expect(screen.queryAllByRole("combobox")).toHaveLength(0) + }) + + test("renders Pagination (number) with currentPage higher than totalPages", () => { + render() + expect(screen.getByTestId("my-pagination")).toHaveTextContent("6") + }) + test("renders Pagination (number) with currentPage lower than totalPages", () => { + render() + expect(screen.getByTestId("my-pagination")).toHaveTextContent("6") + }) + test("renders Pagination (number) with currentPage and undefined totalPages", () => { + render() + expect(screen.getByTestId("my-pagination")).toHaveTextContent("6") + }) + test("renders Pagination (number) with undefined currentPage and defined totalPages", () => { + render() + expect(screen.getByTestId("my-pagination")).toHaveTextContent("") + }) + test("renders Pagination (number) with undefined currentPage and undefined totalPages", () => { + render() + expect(screen.getByTestId("my-pagination")).toHaveTextContent("") + }) + + test("renders a select variant Pagination as passed", () => { + act(() => { + render() + }) + expect(screen.getByTestId("my-pagination")).toBeInTheDocument() + expect(screen.getByTestId("my-pagination")).toHaveClass("juno-pagination-select") + expect(screen.queryAllByRole("button")).toHaveLength(3) + expect(screen.queryAllByRole("textbox")).toHaveLength(0) + expect(document.querySelector("button.juno-select-toggle")).toBeInTheDocument() + }) + + test("renders Pagination (select) with currentPage higher than totalPages", () => { + act(() => { + render() + }) + expect(screen.getByTestId("my-pagination")).toHaveTextContent("6") + }) + + test("renders an input variant Pagination as passed", () => { + render() + expect(screen.getByTestId("my-pagination")).toBeInTheDocument() + expect(screen.getByTestId("my-pagination")).toHaveClass("juno-pagination-input") + expect(screen.queryAllByRole("button")).toHaveLength(2) + expect(screen.queryAllByRole("combobox")).toHaveLength(0) + expect(screen.queryByRole("textbox")).toBeInTheDocument() + }) + + test("renders Pagination (input) with currentPage higher than totalPages", () => { + render() + expect(screen.getByTestId("my-pagination")).toHaveTextContent("6") + }) + + test("fires onPressPrevious handler as passed when Prev button is clicked", () => { + const handlePressPrev = vi.fn() + render() + act(() => { + screen.getByRole("button", { name: "Previous Page" }).click() + }) + expect(handlePressPrev).toHaveBeenCalledTimes(1) + }) + + test("fires onPressNext handler as passed when Next button is clicked", () => { + const handlePressNext = vi.fn() + render() + act(() => { + screen.getByRole("button", { name: "Next Page" }).click() + }) + expect(handlePressNext).toHaveBeenCalledTimes(1) + }) + + test("fires onPressNext handler with undefined currentPage and undefinded totalPages - passed when Next button is clicked", () => { + const handlePressNext = vi.fn() + render( + + ) + act(() => { + screen.getByRole("button", { name: "Next Page" }).click() + }) + expect(handlePressNext).toHaveBeenCalledTimes(1) + expect(screen.getByTestId("my-pagination")).toHaveTextContent("") + }) + + test("fires onPressNext handler with undefined currentPages", () => { + const handlePressNext = vi.fn() + render( + + ) + act(() => { + screen.getByRole("button", { name: "Next Page" }).click() + }) + expect(handlePressNext).toHaveBeenCalledTimes(1) + expect(screen.getByTestId("my-pagination")).toHaveTextContent("") + }) + + test("fires onPressNext handler with undefinded totalPages", () => { + const handlePressNext = vi.fn() + render( + + ) + act(() => { + screen.getByRole("button", { name: "Next Page" }).click() + }) + expect(handlePressNext).toHaveBeenCalledTimes(1) + expect(screen.getByTestId("my-pagination")).toHaveTextContent("7") + }) + + test("does not fire onPressNext handler with higher currentPage than totalPages", () => { + const handlePressNext = vi.fn() + render( + + ) + act(() => { + screen.getByRole("button", { name: "Next Page" }).click() + }) + expect(handlePressNext).toHaveBeenCalledTimes(0) + expect(screen.getByTestId("my-pagination")).toHaveTextContent("4") + }) + + test("fires onPressNext handler with lower currentPage than totalPages", () => { + const handlePressNext = vi.fn() + render( + + ) + act(() => { + screen.getByRole("button", { name: "Next Page" }).click() + }) + expect(handlePressNext).toHaveBeenCalledTimes(1) + expect(screen.getByTestId("my-pagination")).toHaveTextContent("5") + }) + + test("fires onChange handler as passed when Select changes for select variant", async () => { + const mockHandleChange = vi.fn() + act(() => { + render() + }) + const select = document.querySelector("button.juno-select-toggle") + expect(select).toBeInTheDocument() + expect(select).toHaveTextContent("1") + await waitFor(() => userEvent.click(select)) + expect(screen.getByRole("listbox")).toBeInTheDocument() + act(() => { + screen.getByRole("option", { name: "4" }).click() + }) + expect(select).toHaveTextContent("4") + expect(mockHandleChange).toHaveBeenCalledTimes(1) + }) + + test("fires onKeyPress handler on Enter as passed for input variant", async () => { + const handleKeyPress = vi.fn() + await waitFor(() => { + render() + }) + await waitFor(() => { + userEvent.type(screen.getByRole("textbox"), "{enter}") + expect(handleKeyPress).toHaveBeenCalledTimes(1) + }) + }) + + test("does not fire onKeyPress handler on Enter for input variant with undefined controlPage", async () => { + const handleKeyPress = vi.fn() + await waitFor(() => { + render() + }) + await waitFor(() => { + userEvent.type(screen.getByRole("textbox"), "{enter}") + expect(handleKeyPress).toHaveBeenCalledTimes(0) + }) + }) + + test("renders disabled Pagination (default) as passed", () => { + render() + expect(screen.getAllByRole("button", { disabled: true })).toHaveLength(2) + }) + + test("renders disabled Pagination (select) as passed", () => { + act(() => { + render() + }) + expect(screen.getAllByRole("button", { disabled: true })).toHaveLength(3) + expect(document.querySelector(".juno-select-toggle")).toBeDisabled() + }) + + test("renders disabled Pagination (input) as passed", () => { + render() + expect(screen.getAllByRole("button", { disabled: true })).toHaveLength(2) + expect(screen.getByRole("textbox")).toBeDisabled() + }) + + test("renders Pagination (default) in progress as passed", () => { + render() + expect(screen.getAllByRole("button", { disabled: true })).toHaveLength(2) + expect(document.querySelector(".juno-spinner")).toBeInTheDocument() + }) + + test("renders Pagination (select) in progress as passed", () => { + act(() => { + render() + }) + expect(screen.getAllByRole("button", { disabled: true })).toHaveLength(2) + expect(document.querySelector(".juno-select-toggle")).not.toBeInTheDocument() + expect(document.querySelector(".juno-spinner")).toBeInTheDocument() + }) + + test("renders Pagination (input) in progress as passed", () => { + render() + expect(screen.getAllByRole("button", { disabled: true })).toHaveLength(2) + expect(screen.queryByRole("textbox")).not.toBeInTheDocument() + expect(document.querySelector(".juno-spinner")).toBeInTheDocument() + }) + + test("renders Pagination (input) - fires onChange handler as passed", async () => { + const onChangeMock = vi.fn() + render() + const textinput = screen.getByRole("textbox") + await waitFor(() => { + fireEvent.change(textinput, { target: { value: "102" } }) + expect(onChangeMock).toHaveBeenCalledTimes(1) + }) + }) + + test("renders Pagination (input) - fires onBlur handler as passed", async () => { + const onBlurMock = vi.fn() + render() + const textinput = screen.getByRole("textbox") + await waitFor(() => { + fireEvent.change(textinput, { target: { value: "102" } }) + fireEvent.blur(textinput) + expect(onBlurMock).toHaveBeenCalledTimes(1) + }) + }) + + test("renders Pagination (input) - value corrected to the highest possible - as passed", async () => { + render() + const textInput = screen.getByRole("textbox") + await waitFor(() => { + fireEvent.change(textInput, { target: { value: "20" } }) + // userEvent.type(textInput, "102") + // fireEvent.keyPress(textinput, { key: "Enter", code: 13, charCode: 13 }) + }) + expect(textInput).toHaveValue("12") + }) + + test("renders Pagination (input) - value that is too low is corrected to '1' - as passed", async () => { + render() + const textInput = screen.getByRole("textbox") + + await waitFor(() => { + fireEvent.change(textInput, { target: { value: "0" } }) + fireEvent.blur(textInput) + }) + expect(textInput).toHaveValue("1") + }) + + test("renders Pagination (input) - checks width of textfield based on the entry (2 digits) as passed", async () => { + render() + const textinput = screen.getByRole("textbox") + + await waitFor(() => { + fireEvent.change(textinput, { target: { value: "22" } }) + const computedStyle = window.getComputedStyle(document.querySelector(".juno-pagination-wrapper")) + expect(computedStyle.width).toBe("3.3rem") + }) + }) + + test("renders Pagination (input) - checks width of textfield based on the entry (3 digits) as passed", async () => { + render() + const textinput = screen.getByRole("textbox") + + await waitFor(() => { + fireEvent.change(textinput, { target: { value: "22" } }) + const computedStyle = window.getComputedStyle(document.querySelector(".juno-pagination-wrapper")) + expect(computedStyle.width).toBe("3.3rem") + }) + }) + + test("renders Pagination (input) - checks width of textfield based on the entry (4 digits) as passed", async () => { + render() + const textinput = screen.getByRole("textbox") + await waitFor(() => { + fireEvent.change(textinput, { target: { value: "4444" } }) + const computedStyle = window.getComputedStyle(document.querySelector(".juno-pagination-wrapper")) + expect(computedStyle.width).toBe("4.5rem") + }) + }) + + test("renders Pagination (input) - checks width of textfield based on the entry (5 digits) as passed", async () => { + render() + const textinput = screen.getByRole("textbox") + await waitFor(() => { + fireEvent.change(textinput, { target: { value: "55555" } }) + const computedStyle = window.getComputedStyle(document.querySelector(".juno-pagination-wrapper")) + expect(computedStyle.width).toBe("5.1rem") + }) + }) + + test("renders Pagination (input) - checks width of textfield based on the entry (7 digits) as passed", async () => { + render() + const textinput = screen.getByRole("textbox") + await waitFor(() => { + fireEvent.change(textinput, { target: { value: "777777" } }) + const computedStyle = window.getComputedStyle(document.querySelector(".juno-pagination-wrapper")) + expect(computedStyle.width).toBe("5.1rem") + }) + }) + + test("rerenders the active item as passed to the parent if conflicting with new state of active prop passed to child item", () => { + const { rerender } = render() + expect(document.querySelector(".juno-stack")).toHaveTextContent("12") + rerender() + expect(document.querySelector(".juno-stack")).toHaveTextContent("33") + }) + + test("renders Pagination (input) with undefined currentPage prop as passed", () => { + render() + expect(screen.getByRole("textbox")).toHaveValue("") + }) + + test("renders Pagination (select) with undefined currentPage prop as passed", () => { + act(() => { + render() + }) + expect(document.querySelector("button.juno-select-toggle")).toHaveValue("") + }) + + test("renders Pagination (input) with undefined totalPages prop as passed", () => { + render() + expect(screen.getByRole("textbox")).toHaveValue("") + }) + + test("renders Pagination (input) with undefined currentPage but with totalPages as passed", () => { + render() + expect(screen.getByRole("textbox")).toHaveValue("") + }) + + test("renders Pagination (input) with undefined currentPage but with totalPages after clicking previous page button", () => { + render() + act(() => { + screen.getByRole("button", { name: "Previous Page" }).click() + }) + expect(screen.getByRole("textbox")).toHaveValue("") + }) + + test("renders Pagination (select) with undefined totalPages prop as passed", () => { + act(() => { + render() + }) + expect(document.querySelector("button.juno-select-toggle")).toHaveValue("") + }) + + test("renders a custom className as passed", () => { + render() + expect(screen.getByTestId("my-pagination")).toBeInTheDocument() + expect(screen.getByTestId("my-pagination")).toHaveClass("my-class") + }) + + test("renders all props as passed", () => { + render() + expect(screen.getByTestId("my-pagination")).toBeInTheDocument() + expect(screen.getByTestId("my-pagination")).toHaveAttribute("data-lolol", "123-456") + }) +}) diff --git a/packages/ui-components/src/components/Pagination/index.js b/packages/ui-components/src/components/Pagination/index.ts similarity index 100% rename from packages/ui-components/src/components/Pagination/index.js rename to packages/ui-components/src/components/Pagination/index.ts diff --git a/packages/ui-components/src/components/Pagination/Pagination.component.js b/packages/ui-components/src/deprecated_js/Pagination/Pagination.component.js similarity index 100% rename from packages/ui-components/src/components/Pagination/Pagination.component.js rename to packages/ui-components/src/deprecated_js/Pagination/Pagination.component.js diff --git a/packages/ui-components/src/components/Pagination/Pagination.test.js b/packages/ui-components/src/deprecated_js/Pagination/Pagination.test.js similarity index 100% rename from packages/ui-components/src/components/Pagination/Pagination.test.js rename to packages/ui-components/src/deprecated_js/Pagination/Pagination.test.js diff --git a/packages/ui-components/src/deprecated_js/Pagination/index.js b/packages/ui-components/src/deprecated_js/Pagination/index.js new file mode 100644 index 000000000..e5df39820 --- /dev/null +++ b/packages/ui-components/src/deprecated_js/Pagination/index.js @@ -0,0 +1,6 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { Pagination } from "./Pagination.component" diff --git a/packages/ui-components/src/index.js b/packages/ui-components/src/index.js index 62007977c..b1c7d5250 100644 --- a/packages/ui-components/src/index.js +++ b/packages/ui-components/src/index.js @@ -71,7 +71,7 @@ export { PanelBody } from "./components/PanelBody/index.js" export { PanelFooter } from "./components/PanelFooter/index.js" export { PageFooter } from "./components/PageFooter/index.js" export { PageHeader } from "./components/PageHeader/index.js" -export { Pagination } from "./components/Pagination/index.js" +export { Pagination } from "./components/Pagination/Pagination.component" export { Pill } from "./components/Pill/index.js" export { PortalProvider, usePortalRef } from "./components/PortalProvider/index.js" export { Radio } from "./components/Radio/index.js"