Skip to content

Commit

Permalink
Add pagination providers and components (#187)
Browse files Browse the repository at this point in the history
* refs #178 Add pagination providers and components

* Use PaginationResponse on the paginator

* Improve docs

* Use styled components

* Export missing

* Update paginator

* Update docs

* refs #178 Run prettier

* Use inner page state on routed paginator

On this way it will update selected page faster

---------

Co-authored-by: selankon <selankon@selankon.xyz>
  • Loading branch information
elboletaire and selankon authored Sep 6, 2024
1 parent 12206e4 commit 89a5eaf
Show file tree
Hide file tree
Showing 12 changed files with 462 additions and 0 deletions.
120 changes: 120 additions & 0 deletions packages/chakra-components/docs/04-Pagination.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
title: Pagination components
description: Vocdoni Chakra components pagination
---

## Pagination and RoutedPagination

You can easily add pagination to any method by using the included PaginationProvider (or RoutedPaginationProvider in
case you want to use it with react-router-dom).

### RoutedPaginationProvider Example

If you're using `react-router-dom`, you can integrate pagination with URL routing using the `RoutedPaginationProvider`.
Here's how to set it up:

```jsx
const MyProvidedRoutedPaginatedComponent = () => {
return (
<RoutedPaginationProvider path='/my-paginated-route-path'>
<MyRoutedPaginatedComponent />
</RoutedPaginationProvider>
)
}

const MyRoutedPaginatedComponent = () => {
// Use the page from the pagination provider instead of the `useParams` hook.
// Note: The displayed page in the URL is 1-based, but the internal page indexing starts at 0.
const { page } = useRoutedPagination()

// retrieve your data, specifying the desired page.
const { data } = useVocdoniSDKPaginatedCall({
page,
})

return (
<>
{data.items.map((item) => (
<MyItem key={item.id} item={item} />
))}
{/* Render the pagination component */}
<Pagination pagination={data.pagination} />
</>
)
}
```

In this setup, the `Pagination` component will automatically manage the pagination state and update the URL to reflect
the current page.

If we want to programatically set the page, we can use the `usePagination` hook, which provide other util methods:

```ts
const {
getPathForPage, // Get the path for a specific page
setPage, // Navigate to page
page, // Current page
} = useRoutedPagination()
```

### PaginationProvider Example (Non-Routed)

The `PaginationProvider` (non routed) uses an internal state to handle the current page, rather than taking it from the URL.

```jsx
const MyProvidedPaginatedComponent = () => {
return (
<PaginationProvider>
<MyPaginatedComponent />
</PaginationProvider>
)
}
const MyPaginatedComponent = () => {
const { page } = usePagination()

// Retrieve your data, specifying the desired page.
const { data } = useVocdoniSDKPaginatedCall({
page,
})

return (
<>
{data.items.map((item) => (
<MyItem key={item.id} item={item} />
))}
{/* load the pagination itself */}
<Pagination pagination={data.pagination} />
</>
)
}
```

In this example, the `PaginationProvider` manages the pagination state internally without affecting the URL.

### PaginationResponse Interface

The `<Pagination />` component consumes a `PaginationResponse` object provided by the VocdoniSDK.
This object contains the necessary pagination data to manage the state effectively.

```ts
interface PaginationResponse {
pagination: {
totalItems: number
previousPage: number | null
currentPage: number
nextPage: number | null
lastPage: number
}
}
```

When using the VocdoniSDK to retrieve paginated data, this PaginationResponse object will be included in the response,
allowing for easy integration with the `<Pagination />` component.

### Summary

- `RoutedPaginationProvider`: Use this when you want to synchronize pagination with the URL (e.g., when using `react-router-dom`).
- `PaginationProvider`: Use this when URL synchronization is not needed, and you want to manage pagination state internally.

Both providers work seamlessly with the `Pagination` component and the `PaginationResponse` object from the VocdoniSDK,
making it simple to add pagination to any data-driven component.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useState } from 'react'
import { useStyleConfig } from '@chakra-ui/system'
import { Button, ButtonProps, Input, InputProps } from '@chakra-ui/react'

type EllipsisButtonProps = ButtonProps & {
gotoPage: (page: number) => void
inputProps?: InputProps
}

export const EllipsisButton = ({ gotoPage, inputProps, ...rest }: EllipsisButtonProps) => {
const [ellipsisInput, setEllipsisInput] = useState(false)
const styles = useStyleConfig('EllipsisButton', rest)

if (ellipsisInput) {
return (
<Input
placeholder='Page #'
width='50px'
sx={styles}
{...inputProps}
onKeyDown={(e) => {
if (e.target instanceof HTMLInputElement && e.key === 'Enter') {
const pageNumber = Number(e.target.value)
gotoPage(pageNumber)
setEllipsisInput(false)
}
}}
onBlur={() => setEllipsisInput(false)}
autoFocus
/>
)
}

return (
<Button
as='a'
href='#goto-page'
sx={styles}
{...rest}
onClick={(e) => {
e.preventDefault()
setEllipsisInput(true)
}}
>
...
</Button>
)
}
187 changes: 187 additions & 0 deletions packages/chakra-components/src/components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { ButtonGroup, ButtonGroupProps, ButtonProps, InputProps, Text } from '@chakra-ui/react'
import { ReactElement, useMemo } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import { useLocalize, usePagination, useRoutedPagination } from '@vocdoni/react-providers'
import { PaginationResponse } from '@vocdoni/sdk'
import { useMultiStyleConfig, chakra } from '@chakra-ui/system'
import { EllipsisButton } from './EllipsisButton'
import { PaginatorButton } from './PaginatorButton'

export type PaginationProps = ButtonGroupProps & {
maxButtons?: number | false
buttonProps?: ButtonProps
inputProps?: InputProps
} & PaginationResponse

type PaginatorButtonProps = {
page: number
currentPage: number
} & ButtonProps

const PageButton = ({ page, currentPage, ...rest }: PaginatorButtonProps) => (
<PaginatorButton isActive={currentPage === page} {...rest}>
{page + 1}
</PaginatorButton>
)

const RoutedPageButton = ({ page, currentPage, to, ...rest }: PaginatorButtonProps & { to: string }) => (
<PaginatorButton as={RouterLink} to={to} isActive={currentPage === page} {...rest}>
{page + 1}
</PaginatorButton>
)

type CreatePageButtonType = (i: number) => ReactElement
type GotoPageType = (page: number) => void

const usePaginationPages = (
currentPage: number,
totalPages: number | undefined,
maxButtons: number | undefined | false,
gotoPage: GotoPageType,
createPageButton: CreatePageButtonType,
inputProps?: InputProps,
buttonProps?: ButtonProps
) => {
return useMemo(() => {
if (totalPages === undefined) return []

let pages: ReactElement[] = []

// Create an array of all page buttons
for (let i = 0; i < totalPages; i++) {
pages.push(createPageButton(i))
}

if (!maxButtons || totalPages <= maxButtons) {
return pages
}

const startEllipsis = (
<EllipsisButton key='start-ellipsis' gotoPage={gotoPage} inputProps={inputProps} {...buttonProps} />
)
const endEllipsis = (
<EllipsisButton key='end-ellipsis' gotoPage={gotoPage} inputProps={inputProps} {...buttonProps} />
)

// Add ellipsis and slice the array accordingly
const sideButtons = 2 // First and last page
const availableButtons = maxButtons - sideButtons // Buttons we can distribute around the current page

if (currentPage <= availableButtons / 2) {
// Near the start
return [...pages.slice(0, availableButtons), endEllipsis, pages[totalPages - 1]]
} else if (currentPage >= totalPages - 1 - availableButtons / 2) {
// Near the end
return [pages[0], startEllipsis, ...pages.slice(totalPages - availableButtons, totalPages)]
} else {
// In the middle
const startPage = currentPage - Math.floor((availableButtons - 1) / 2)
const endPage = currentPage + Math.floor(availableButtons / 2)
return [pages[0], startEllipsis, ...pages.slice(startPage, endPage - 1), endEllipsis, pages[totalPages - 1]]
}
}, [currentPage, totalPages, maxButtons, gotoPage])
}

const PaginationButtons = ({
totalPages,
totalItems,
currentPage,
goToPage,
createPageButton,
maxButtons = 10,
buttonProps,
...rest
}: {
totalPages: number | undefined
totalItems: number | undefined
currentPage: number
createPageButton: CreatePageButtonType
goToPage: GotoPageType
} & ButtonGroupProps &
Pick<PaginationProps, 'maxButtons' | 'buttonProps'>) => {
const styles = useMultiStyleConfig('Pagination')
const t = useLocalize()

const pages = usePaginationPages(
currentPage,
totalPages,
maxButtons ? Math.max(5, maxButtons) : false,
(page) => {
if (page >= 0 && totalPages && page < totalPages) {
goToPage(page)
}
},
createPageButton
)

return (
<chakra.div __css={styles.wrapper}>
<ButtonGroup sx={styles.buttonGroup} {...rest}>
{totalPages === undefined ? (
<>
<PaginatorButton
key='previous'
onClick={() => goToPage(currentPage - 1)}
isDisabled={currentPage === 0}
{...buttonProps}
>
Previous
</PaginatorButton>
<PaginatorButton key='next' onClick={() => goToPage(currentPage + 1)} {...buttonProps}>
Next
</PaginatorButton>
</>
) : (
pages
)}
</ButtonGroup>
{totalItems && (
<Text sx={styles.totalResults}>
{t('pagination.total_results', {
count: totalItems,
})}
</Text>
)}
</chakra.div>
)
}

export const Pagination = ({ maxButtons = 10, buttonProps, inputProps, pagination, ...rest }: PaginationProps) => {
const { setPage } = usePagination()
const totalPages = pagination.lastPage + 1
const page = pagination.currentPage

return (
<PaginationButtons
goToPage={(page) => setPage(page)}
createPageButton={(i) => (
<PageButton key={i} page={i} currentPage={page} onClick={() => setPage(i)} {...buttonProps} />
)}
currentPage={page}
totalPages={totalPages}
totalItems={pagination.totalItems}
maxButtons={maxButtons}
{...rest}
/>
)
}

export const RoutedPagination = ({ maxButtons = 10, buttonProps, pagination, ...rest }: PaginationProps) => {
const { getPathForPage, setPage, page } = useRoutedPagination()

const totalPages = pagination.lastPage + 1
const currentPage = page

return (
<PaginationButtons
goToPage={(page) => setPage(page)}
createPageButton={(i) => (
<RoutedPageButton key={i} to={getPathForPage(i + 1)} page={i} currentPage={page} {...buttonProps} />
)}
currentPage={currentPage}
totalPages={totalPages}
totalItems={pagination.totalItems}
{...rest}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { useStyleConfig } from '@chakra-ui/system'
import { Button, ButtonProps, forwardRef } from '@chakra-ui/react'

export const PaginatorButton = forwardRef<ButtonProps, 'div'>((props, ref) => {
const styles = useStyleConfig('PageButton', props)
return <Button sx={styles} {...props} />
})
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Pagination'
2 changes: 2 additions & 0 deletions packages/chakra-components/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export * from './Election'
export * from './layout'
// Organization components
export * from './Organization'
// Pagination components
export * from './Pagination'
3 changes: 3 additions & 0 deletions packages/chakra-components/src/i18n/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export const locales = {
not_voted_in_ended_election: "You did not vote in this election and it's already finished",
},
loading: 'Loading...',
pagination: {
total_results: 'Showing a total of {{ count }} results',
},
question_types: {
approval_tooltip:
"Approval voting lets you vote for as many options as you like. The one with the most votes wins. It's a simple way to show your support for all the choices you approve of.",
Expand Down
Loading

0 comments on commit 89a5eaf

Please sign in to comment.