Skip to content

Commit

Permalink
chore(ui): migrate Pagination component to Typescript (#616)
Browse files Browse the repository at this point in the history
* chore(ui): initially convert Pagination to Typescript, update index

* chore(ui): changeset

* chore(ui): fix text input type

* chore(ui): fix story, pagination component type errors and extend Select component with instrinsic types

* chore(ui): fix tests and component logic

* chore(ui): fix casting

* chore(ui): tidy change handler

* chore(ui): improve comments

* chore(ui): extend intrinsic props

* chore(ui): change to React.FC
  • Loading branch information
guoda-puidokaite authored Nov 27, 2024
1 parent f51318d commit 8e6e94d
Show file tree
Hide file tree
Showing 11 changed files with 751 additions and 33 deletions.
5 changes: 5 additions & 0 deletions .changeset/calm-apes-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudoperators/juno-ui-components": minor
---

Migrate the Pagination component to TypeScript
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
/*
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors
* SPDX-License-Identifier: Apache-2.0
*/

/* eslint-disable no-unused-vars */

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 type PaginationProps = Omit<React.HTMLAttributes<HTMLDivElement>, "onBlur"> & {
/**
* The variant determines the style and user interaction method of the Pagination component:
* - "default": Renders previous and next buttons only.
* - "number": Displays the current page number between next and previous buttons.
* - "select": Provides a dropdown menu between next and previous buttons for specific page selection.
* - "input": Provides an input field for specific page entry.
*/
variant?: "default" | "number" | "select" | "input"
/**
* The current page number.
*/
currentPage?: number
/**
* The total number of pages available.
*/
totalPages?: number
/**
* A fallback property for providing the total number of pages, maintained for backward compatibility.
*/
pages?: number
/**
* When true, disables the entire pagination component, preventing any interaction.
*/
disabled?: boolean
/**
* When true, simulates the component being on the first page, disabling the previous button.
*/
isFirstPage?: boolean
/**
* When true, simulates the component being on the last page, disabling the next button.
*/
isLastPage?: boolean
/**
* Callback function triggered when the previous button is pressed.
*/
onPressPrevious?: (newPage?: number) => void
/**
* Callback function triggered when the next button is pressed.
*/
onPressNext?: (newPage?: number) => void
/**
* Callback function triggered when a new page is selected from the dropdown, applicable when variant is "select".
*/
onSelectChange?: (selected: number) => void
/**
* Callback function triggered when the input field value changes, applicable when variant is "input".
*/
onInputChange?: (inputValue?: number) => void
/**
* Callback function triggered when a key is pressed in the input field, applicable when variant is "input".
*/
onKeyPress?: (controlCurrentPage?: number) => void
/**
* Callback function triggered when the input field loses focus, applicable when variant is "input".
*/
onBlur?: (controlCurrentPage?: number) => void
/**
* Displays a loading spinner and disables interaction when true.
*/
progress?: boolean
/**
* Additional CSS classes for custom styling.
*/
className?: string
}

/**
* The Pagination component provides navigation controls for paginated content.
*/
export const Pagination: React.FC<PaginationProps> = ({
variant = "default",
currentPage,
totalPages,
pages,
disabled = false,
isFirstPage,
isLastPage,
onPressPrevious,
onPressNext,
onSelectChange,
onInputChange,
onKeyPress,
onBlur,
progress = false,
className = "",
...props
}) => {
const [controlCurrentPage, setControlCurrentPage] = useState<number | undefined>(currentPage)
const [controlTotalPage, setControlTotalPage] = useState<number | undefined>(pages ? pages : totalPages)

// Synchronize internal state with props whenever they change
useEffect(() => {
setControlCurrentPage(currentPage)
// Fallback for the “pages” prop supported for backward compatibility
pages ? setControlTotalPage(pages) : setControlTotalPage(totalPages)
// Check that the current page does not exceed total pages
if (controlCurrentPage !== undefined && controlTotalPage !== undefined && controlCurrentPage > controlTotalPage) {
setControlCurrentPage(controlTotalPage)
}
}, [currentPage, totalPages, pages])

const handlePrevClick = () => {
let newPage
if (controlCurrentPage !== undefined && controlCurrentPage > 1) {
newPage = controlCurrentPage - 1
setControlCurrentPage(newPage)
}

onPressPrevious && onPressPrevious(newPage)
}

const handleNextClick = () => {
// Increment controlCurrentPage if it exists and is less than controlTotalPage, or if controlTotalPage is undefined
let newPage
if (controlCurrentPage !== undefined) {
if (controlTotalPage === undefined || controlCurrentPage < controlTotalPage) {
newPage = controlCurrentPage + 1
setControlCurrentPage(newPage)
}
}

onPressNext && onPressNext(newPage)
}

/**
* Handler for select dropdown change event
* - Converts the selected value to a number and updates the control page state.
* - Invokes the onSelectChange callback, if defined.
*/
const handleSelectChange = (selectedValue?: string | number | string[]): void => {
if (selectedValue === undefined) return

let convertedNumber: number | undefined

// Convert the selected value to a number based on its type
if (typeof selectedValue === "string" || Array.isArray(selectedValue)) {
convertedNumber = parseInt(Array.isArray(selectedValue) ? selectedValue[0] : selectedValue, 10)
} else {
// If selectedValue is a number, assign it directly
convertedNumber = selectedValue
}

// Ignore invalid conversion results
if (convertedNumber === undefined || isNaN(convertedNumber)) return

// Update local state and invoke callback
setControlCurrentPage(convertedNumber)
onSelectChange?.(convertedNumber)
}

const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Ensure that the input value is a valid integer
let inputValue = event.target.value ? parseInt(event.target.value, 10) : undefined

if (inputValue !== undefined) {
// Enforce minimum and maximum limits
if (inputValue < 1) {
inputValue = 1
} else if (controlTotalPage !== undefined && inputValue > controlTotalPage) {
inputValue = controlTotalPage
}
}
// Update local state and invoke callback
setControlCurrentPage(inputValue)
onInputChange && onInputChange(inputValue)
}

const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" && controlCurrentPage !== undefined) {
onKeyPress && onKeyPress(controlCurrentPage)
}
}

// Handler for input field losing focus
const handleBlur = () => {
onBlur && onBlur(controlCurrentPage)
}

// Calculates the width of input fields dynamically based on the number of characters
const getInputWidthClass = () => {
let logLength =
controlCurrentPage !== undefined && !isNaN(controlCurrentPage) ? controlCurrentPage.toString().length : 1
logLength = logLength > 5 ? 5 : logLength
const width = `${(logLength * 0.6 + 2.1).toFixed(1)}rem` // 0.6rem per digit + 2.1rem for padding
return { width: width }
}

return (
<div
className={`juno-pagination juno-pagination-${variant || "default"} ${paginationStyles} ${className}`}
{...props}
>
<Button
icon="chevronLeft"
disabled={!!isFirstPage || !!disabled || !!progress || controlCurrentPage === 1}
onClick={handlePrevClick}
title="Previous Page"
/>
{progress ? <Spinner size="small" color="default" className={spinnerStyles} /> : ""}
{variant && !progress
? (() => {
switch (variant) {
case "number":
// Displays the current page number if defined
return controlCurrentPage !== undefined ? <div className="page-value"> {controlCurrentPage}</div> : ""

case "select":
// Renders a dropdown to select specific page numbers
return (
<Select
name="totalPages"
width="auto"
value={controlCurrentPage?.toString()} // Select expects string values
onChange={handleSelectChange}
disabled={disabled}
>
{(() => {
const opts = []
if (controlTotalPage !== undefined) {
// Generate options for each page
for (let i = 0; i < controlTotalPage; i++) {
const p = (i + 1).toString() // SelectOption requires strings for value and label
opts.push(<SelectOption value={p} label={p} key={p} />)
}
}
return opts
})()}
</Select>
)

case "input":
// Renders an input field to allow manual page entry
return (
<Stack gap="2" alignment="center">
<div className={`juno-pagination-wrapper`} style={getInputWidthClass()}>
<TextInput
value={controlCurrentPage !== undefined ? controlCurrentPage : ""}
onChange={handleInputChange}
onBlur={handleBlur}
onKeyPress={handleKeyPress}
disabled={!!disabled}
className={inputStyles}
maxLength={6}
/>
</div>
{controlTotalPage !== undefined ? <span>of {controlTotalPage}</span> : ""}
</Stack>
)
default:
return ""
}
})()
: ""}
<Button
icon="chevronRight"
disabled={
!!isLastPage ||
!!disabled ||
!!progress ||
(controlCurrentPage !== undefined &&
controlTotalPage !== undefined &&
controlCurrentPage === controlTotalPage)
}
onClick={handleNextClick}
title="Next Page"
/>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
*/

import React from "react"
import PropTypes from "prop-types"
import { Pagination } from "./index.js"
import { PortalProvider } from "../../deprecated_js/PortalProvider/PortalProvider.component"
import { Meta, StoryFn } from "@storybook/react"
import { Pagination, PaginationProps } from "./Pagination.component"
import { PortalProvider } from "../PortalProvider/PortalProvider.component"

export default {
title: "Components/Pagination",
Expand All @@ -18,32 +18,17 @@ export default {
},
},
decorators: [
(Story) => (
(Story: StoryFn) => (
<div className="jn-pb-12">
<PortalProvider>
<Story />
</PortalProvider>
</div>
),
],
}

const Template = (args) => {
return <Pagination {...args} />
}

const UncontrolledTemplate = ({ currentPage, pages, ...args }) => {
const [page, setPage] = React.useState(currentPage)
const prev = React.useCallback(() => setPage(page > 1 ? page - 1 : 1), [page])
const next = React.useCallback(() => setPage(!pages || page < pages ? page + 1 : pages), [page])

return <Pagination {...args} totalPages={pages} currentPage={page} onPressPrevious={prev} onPressNext={next} />
}
} as Meta<typeof Pagination>

UncontrolledTemplate.propTypes = {
currentPage: PropTypes.number,
pages: PropTypes.number,
}
const Template: StoryFn<PaginationProps> = (args) => <Pagination {...args} />

export const Default = {
render: Template,
Expand All @@ -56,7 +41,6 @@ export const Default = {

export const PaginationWithNumber = {
render: Template,

args: {
variant: "number",
currentPage: 12,
Expand All @@ -66,7 +50,6 @@ export const PaginationWithNumber = {

export const PaginationWithSelect = {
render: Template,

args: {
variant: "select",
currentPage: 2,
Expand All @@ -76,7 +59,6 @@ export const PaginationWithSelect = {

export const PaginationWithInput = {
render: Template,

args: {
variant: "input",
currentPage: 3,
Expand All @@ -86,15 +68,13 @@ export const PaginationWithInput = {

export const DisabledPagination = {
render: Template,

args: {
disabled: true,
},
}

export const ProgressPagination = {
render: Template,

args: {
progress: true,
},
Expand Down
Loading

0 comments on commit 8e6e94d

Please sign in to comment.