diff --git a/src/components/ui/PaginationHeader/PaginationHeader.stories.tsx b/src/components/ui/PaginationHeader/PaginationHeader.stories.tsx new file mode 100644 index 0000000..b68a605 --- /dev/null +++ b/src/components/ui/PaginationHeader/PaginationHeader.stories.tsx @@ -0,0 +1,191 @@ +import React from "react"; +import { ComponentStory, ComponentMeta } from "@storybook/react"; +import { PaginationHeader } from "./PaginationHeader"; + +export default { + title: "Components/UI/PaginationHeader", + component: PaginationHeader, + parameters: { + docs: { + description: { + component: + "A pagination component that combines Ant Design Pagination with selection summary and controls.", + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => { + return ; +}; + +// Common handlers for all stories +const commonHandlers = { + onRequestPageChange: (page: number, pageSize?: number) => { + console.log(`Page changed to ${page} with size ${pageSize}`); + }, + onPageSizeChange: (pageSize: number) => { + console.log(`Page size changed to ${pageSize}`); + }, + onSelectAllGlobalRecords: async () => { + console.log("Select all records clicked"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + }, +}; + +export const NoResults = Template.bind({}); +NoResults.args = { + initialPage: 1, + initialPageSize: 10, + total: 0, + currentPageSelectedCount: 0, + totalSelectedCount: 0, + ...commonHandlers, +}; +NoResults.parameters = { + docs: { + description: { + story: "Shows the pagination state when there are no results.", + }, + }, +}; + +export const SinglePage = Template.bind({}); +SinglePage.args = { + initialPage: 1, + initialPageSize: 10, + total: 5, + currentPageSelectedCount: 0, + totalSelectedCount: 0, + ...commonHandlers, +}; +SinglePage.parameters = { + docs: { + description: { + story: "Shows the pagination when all items fit in a single page.", + }, + }, +}; + +export const MultiplePages = Template.bind({}); +MultiplePages.args = { + initialPage: 2, + initialPageSize: 10, + total: 25, + currentPageSelectedCount: 0, + totalSelectedCount: 0, + ...commonHandlers, +}; +MultiplePages.parameters = { + docs: { + description: { + story: "Shows pagination with multiple pages, currently on page 2.", + }, + }, +}; + +export const LargeDataset = Template.bind({}); +LargeDataset.args = { + initialPage: 5, + initialPageSize: 20, + total: 1000, + currentPageSelectedCount: 0, + totalSelectedCount: 0, + ...commonHandlers, +}; +LargeDataset.parameters = { + docs: { + description: { + story: "Shows pagination with a large dataset and larger page size.", + }, + }, +}; + +export const WithPartialPageSelected = Template.bind({}); +WithPartialPageSelected.args = { + initialPage: 1, + initialPageSize: 10, + total: 100, + currentPageSelectedCount: 10, + totalSelectedCount: 10, + ...commonHandlers, +}; +WithPartialPageSelected.parameters = { + docs: { + description: { + story: + "Shows when current page is fully selected but there are more records available to select globally.", + }, + }, +}; + +export const WithAllPagesSelected = Template.bind({}); +WithAllPagesSelected.args = { + initialPage: 1, + initialPageSize: 10, + total: 100, + currentPageSelectedCount: 10, + totalSelectedCount: 100, + ...commonHandlers, +}; +WithAllPagesSelected.parameters = { + docs: { + description: { + story: "Shows when all records across all pages are selected.", + }, + }, +}; + +export const CustomPageSize = Template.bind({}); +CustomPageSize.args = { + initialPage: 1, + initialPageSize: 50, + total: 200, + currentPageSelectedCount: 0, + totalSelectedCount: 0, + ...commonHandlers, +}; +CustomPageSize.parameters = { + docs: { + description: { + story: "Shows pagination with a custom page size of 50 items.", + }, + }, +}; + +export const LastPage = Template.bind({}); +LastPage.args = { + initialPage: 10, + initialPageSize: 10, + total: 100, + currentPageSelectedCount: 0, + totalSelectedCount: 0, + ...commonHandlers, +}; +LastPage.parameters = { + docs: { + description: { + story: "Shows pagination when viewing the last page of results.", + }, + }, +}; + +export const WithoutSelectionControls = Template.bind({}); +WithoutSelectionControls.args = { + initialPage: 1, + initialPageSize: 10, + total: 100, + currentPageSelectedCount: 0, + totalSelectedCount: 0, + onRequestPageChange: commonHandlers.onRequestPageChange, + onPageSizeChange: commonHandlers.onPageSizeChange, + // Omitting onSelectAllGlobalRecords to hide selection controls +}; +WithoutSelectionControls.parameters = { + docs: { + description: { + story: + "Shows pagination without the selection controls by omitting the onSelectAllGlobalRecords handler.", + }, + }, +}; diff --git a/src/components/ui/PaginationHeader/PaginationHeader.tsx b/src/components/ui/PaginationHeader/PaginationHeader.tsx new file mode 100644 index 0000000..8325501 --- /dev/null +++ b/src/components/ui/PaginationHeader/PaginationHeader.tsx @@ -0,0 +1,116 @@ +import { useLocale } from "@/context"; +import { Col, Pagination, Row } from "antd"; +import type { PaginationProps } from "antd"; +import { useMemo, useState, useCallback, memo } from "react"; +import { SelectAllRecordsRow } from "../SelectAllRecordsRow/SelectAllRecordsRow"; +import type { PaginationHeaderProps } from "./PaginationHeader.types"; +import type { SelectAllRecordsRowProps } from "../SelectAllRecordsRow/SelectAllRecordsRow.types"; + +const PaginationHeaderComponent = (props: PaginationHeaderProps) => { + const { + total, + initialPage, + initialPageSize, + currentPageSelectedCount, + totalSelectedCount, + onRequestPageChange, + onPageSizeChange, + onSelectAllGlobalRecords, + } = props; + + const { t } = useLocale(); + + const [page, setPage] = useState(initialPage); + const [pageSize, setPageSize] = useState(initialPageSize); + + const from = useMemo(() => (page - 1) * pageSize + 1, [page, pageSize]); + const to = useMemo( + () => Math.min(page * pageSize, total), + [page, pageSize, total], + ); + + const handlePageChange = useCallback( + (newPage: number, newPageSize?: number) => { + setPage(newPage); + if (newPageSize !== undefined) { + setPageSize(newPageSize); + } + onRequestPageChange(newPage, newPageSize); + }, + [onRequestPageChange], + ); + + const handlePageSizeChange = useCallback( + (newPageSize: number, newPage: number) => { + setPageSize(newPageSize); + setPage(newPage); + onPageSizeChange(newPageSize); + onRequestPageChange(newPage, newPageSize); + }, + [onPageSizeChange, onRequestPageChange], + ); + + const summary = useMemo(() => { + if (total === undefined) return null; + if (total === 0) return t("no_results"); + + return t("summary") + .replace("{from}", from?.toString()) + .replace("{to}", to?.toString()) + .replace("{total}", total?.toString()); + }, [total, from, to, t]); + + const paginationProps: PaginationProps = useMemo( + () => ({ + total, + pageSize, + current: page, + onChange: handlePageChange, + showSizeChanger: true, + onShowSizeChange: handlePageSizeChange, + showLessItems: true, + locale: { + items_per_page: t("items_per_page"), + }, + }), + [total, pageSize, page, handlePageChange, handlePageSizeChange, t], + ); + + const selectAllRecordsProps: SelectAllRecordsRowProps = useMemo( + () => ({ + currentPageSelectedCount, + currentPageSize: pageSize, + totalRecordsCount: total, + totalSelectedCount, + onSelectAllRecords: onSelectAllGlobalRecords!, + }), + [ + currentPageSelectedCount, + pageSize, + total, + totalSelectedCount, + onSelectAllGlobalRecords, + ], + ); + + const hasSelectionFeature = onSelectAllGlobalRecords !== undefined; + const columnSpan = hasSelectionFeature ? 8 : 12; + + return ( + + + + + {hasSelectionFeature && ( + + + + )} + + {summary} + + + ); +}; + +export const PaginationHeader = memo(PaginationHeaderComponent); diff --git a/src/components/ui/PaginationHeader/PaginationHeader.types.ts b/src/components/ui/PaginationHeader/PaginationHeader.types.ts new file mode 100644 index 0000000..dadf844 --- /dev/null +++ b/src/components/ui/PaginationHeader/PaginationHeader.types.ts @@ -0,0 +1,18 @@ +export type PaginationHeaderProps = { + /** Total number of records across all pages */ + total: number; + /** Initial page number */ + initialPage: number; + /** Initial number of items per page */ + initialPageSize: number; + /** Number of selected records in the current page */ + currentPageSelectedCount: number; + /** Total number of selected records across all pages */ + totalSelectedCount: number; + /** Callback when page or page size changes */ + onRequestPageChange: (page: number, pageSize?: number) => void; + /** Callback when page size changes */ + onPageSizeChange: (pageSize: number) => void; + /** Optional callback to select all records across all pages */ + onSelectAllGlobalRecords?: () => Promise; +}; diff --git a/src/components/ui/PaginationHeader/index.ts b/src/components/ui/PaginationHeader/index.ts new file mode 100644 index 0000000..91506c5 --- /dev/null +++ b/src/components/ui/PaginationHeader/index.ts @@ -0,0 +1,2 @@ +export { PaginationHeader } from "./PaginationHeader"; +export type { PaginationHeaderProps } from "./PaginationHeader.types"; diff --git a/src/components/ui/SelectAllRecordsRow/SelectAllRecordsRow.stories.tsx b/src/components/ui/SelectAllRecordsRow/SelectAllRecordsRow.stories.tsx new file mode 100644 index 0000000..4cf0708 --- /dev/null +++ b/src/components/ui/SelectAllRecordsRow/SelectAllRecordsRow.stories.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { ComponentStory, ComponentMeta } from "@storybook/react"; +import { SelectAllRecordsRow } from "./SelectAllRecordsRow"; + +export default { + title: "Components/UI/SelectAllRecordsRow", + component: SelectAllRecordsRow, + parameters: { + docs: { + description: { + component: + "A component that appears when all records in the current page are selected, offering the option to select all records across all pages.", + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => { + return ; +}; + +export const CurrentPageSelected = Template.bind({}); +CurrentPageSelected.args = { + currentPageSelectedCount: 10, // All 10 records in current page are selected + currentPageTotalCount: 10, // Current page has 10 records + totalRecordsCount: 100, // We have 100 records in total + totalSelectedCount: 10, // Only the current page (10 records) are selected + onSelectAllRecords: async () => { + console.log("Select all records clicked"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + }, +}; +CurrentPageSelected.parameters = { + docs: { + description: { + story: + "Shows when all records in the current page are selected, offering the option to select all records across all pages.", + }, + }, +}; + +export const AllPagesSelected = Template.bind({}); +AllPagesSelected.args = { + currentPageSelectedCount: 10, // All 10 records in current page are selected + currentPageTotalCount: 10, // Current page has 10 records + totalRecordsCount: 100, // We have 100 records in total + totalSelectedCount: 100, // All records across all pages are selected + onSelectAllRecords: async () => { + console.log("Select all records clicked"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + }, +}; +AllPagesSelected.parameters = { + docs: { + description: { + story: + "Shows when all records across all pages are selected, displaying the total count.", + }, + }, +}; diff --git a/src/components/ui/SelectAllRecordsRow/SelectAllRecordsRow.tsx b/src/components/ui/SelectAllRecordsRow/SelectAllRecordsRow.tsx new file mode 100644 index 0000000..93be305 --- /dev/null +++ b/src/components/ui/SelectAllRecordsRow/SelectAllRecordsRow.tsx @@ -0,0 +1,91 @@ +import styled from "styled-components"; +import { useState, useCallback, memo, useMemo } from "react"; +import { Spin } from "antd"; +import Link from "antd/es/typography/Link"; +import { useLocale } from "@/context"; +import type { SelectAllRecordsRowProps } from "./SelectAllRecordsRow.types"; + +export const Container = styled.div` + display: flex; + align-items: center; + justify-content: center; +`; + +const SelectAllRecordsRowComponent = ({ + currentPageSelectedCount, + currentPageSize, + totalRecordsCount, + totalSelectedCount, + onSelectAllRecords, +}: SelectAllRecordsRowProps) => { + const [loading, setLoading] = useState(false); + const { t } = useLocale(); + + const handleClick = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setLoading(true); + try { + await onSelectAllRecords(); + } finally { + setLoading(false); + } + }, + [onSelectAllRecords], + ); + + // Don't render anything if there are no records + if (totalRecordsCount === 0) { + return null; + } + + // Don't render if current page is not fully selected + if (currentPageSelectedCount < currentPageSize) { + return null; + } + + // Don't render if we don't have more records than current page + if (totalRecordsCount <= currentPageSize) { + return null; + } + + // If all records are selected, show the total count message + if (totalSelectedCount === totalRecordsCount) { + return ( + + + {t("allRecordsSelected").replace( + "{totalRecords}", + totalSelectedCount.toString(), + )} + + + ); + } + + // Show the select all option + return ( + + + {t("recordsSelected").replace( + "{numberOfSelectedRows}", + currentPageSelectedCount.toString(), + ) + " "} + + {loading ? ( + + ) : ( + + {t("selectAllRecords").replace( + "{totalRecords}", + totalRecordsCount.toString(), + )} + + )} + + + ); +}; + +export const SelectAllRecordsRow = memo(SelectAllRecordsRowComponent); diff --git a/src/components/ui/SelectAllRecordsRow/SelectAllRecordsRow.types.ts b/src/components/ui/SelectAllRecordsRow/SelectAllRecordsRow.types.ts new file mode 100644 index 0000000..529e109 --- /dev/null +++ b/src/components/ui/SelectAllRecordsRow/SelectAllRecordsRow.types.ts @@ -0,0 +1,12 @@ +export type SelectAllRecordsRowProps = { + /** Number of records selected in the current page */ + currentPageSelectedCount: number; + /** Size of the current page */ + currentPageSize: number; + /** Total number of records across all pages */ + totalRecordsCount: number; + /** Total number of selected records across all pages */ + totalSelectedCount: number; + /** Callback to select all records across all pages */ + onSelectAllRecords: () => Promise; +}; diff --git a/src/components/ui/SelectAllRecordsRow/index.ts b/src/components/ui/SelectAllRecordsRow/index.ts new file mode 100644 index 0000000..ac213b4 --- /dev/null +++ b/src/components/ui/SelectAllRecordsRow/index.ts @@ -0,0 +1,2 @@ +export { SelectAllRecordsRow } from "./SelectAllRecordsRow"; +export type { SelectAllRecordsRowProps } from "./SelectAllRecordsRow.types"; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 67eee60..eda3283 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -4,3 +4,5 @@ export * from "./Separator"; export * from "./FieldSet"; export * from "./Dropdown"; export * from "./FavouriteButton"; +export * from "./PaginationHeader"; +export * from "./SelectAllRecordsRow"; diff --git a/src/locales/ca_ES.json b/src/locales/ca_ES.json index a98e603..c850627 100644 --- a/src/locales/ca_ES.json +++ b/src/locales/ca_ES.json @@ -99,5 +99,9 @@ "noMatches": "No s'han trobat coincidències", "noExportFieldFound": "No s'ha trobat el camp en el model. Pot ser que s'hagi migrat o eliminat?", "exportHasFieldsUnavailable": "L'exportació té els següents camps no disponibles. Pot ser que s'hagin migrat o eliminat.", - "continueAndIgnoreFieldsUnavailable": "Vols continuar i ignorar els camps no disponibles?" + "continueAndIgnoreFieldsUnavailable": "Vols continuar i ignorar els camps no disponibles?", + "recordsSelected": "Hi ha {numberOfSelectedRows} registres seleccionats en aquesta pàgina.", + "selectAllRecords": "Seleccionar tots els {totalRecords} registres.", + "allRecordsSelected": "Hi ha {totalRecords} registres seleccionats.", + "items_per_page": "/ pàg." } diff --git a/src/locales/en_US.json b/src/locales/en_US.json index b7a8a01..991dd6e 100644 --- a/src/locales/en_US.json +++ b/src/locales/en_US.json @@ -99,5 +99,9 @@ "noMatches": "No matches found", "noExportFieldFound": "Field not found in the model. It may have been migrated or deleted?", "exportHasFieldsUnavailable": "The export has the following fields unavailable. It may have been migrated or deleted.", - "continueAndIgnoreFieldsUnavailable": "Do you want to continue and ignore the unavailable fields?" + "continueAndIgnoreFieldsUnavailable": "Do you want to continue and ignore the unavailable fields?", + "recordsSelected": "There are {numberOfSelectedRows} records selected on this page.", + "selectAllRecords": "Select all {totalRecords} records.", + "allRecordsSelected": "There are {totalRecords} records selected.", + "items_per_page": "/ page" } diff --git a/src/locales/es_ES.json b/src/locales/es_ES.json index 8194aaa..c495419 100644 --- a/src/locales/es_ES.json +++ b/src/locales/es_ES.json @@ -99,5 +99,9 @@ "noMatches": "No hay coincidencias", "noExportFieldFound": "Campo no encontrado en el modelo. Puede haber sido migrado o eliminado?", "exportHasFieldsUnavailable": "La exportación tiene los siguientes campos no disponibles. Puede ser que se hayan migrado o eliminado.", - "continueAndIgnoreFieldsUnavailable": "Quieres continuar y ignorar los campos no disponibles?" + "continueAndIgnoreFieldsUnavailable": "Quieres continuar y ignorar los campos no disponibles?", + "recordsSelected": "Hay {numberOfSelectedRows} registros seleccionados en ésta página.", + "selectAllRecords": "Seleccionar todos los {totalRecords} registros.", + "allRecordsSelected": "Hay {totalRecords} registros seleccionados.", + "items_per_page": "/ pág." }