From 077cd6ad5f557c1662cdb9f0c8c63d42bd6f444e Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Thu, 9 Apr 2026 10:43:45 +0300 Subject: [PATCH] feat(client): add Pagination component and integrate with Table and pages - add Pagination component with prev/next, ellipsis page range, and optional page-size selector - add Pagination and Table pagination stories (WithPagination, WithPaginationAndSizeSelector) - extend Table with optional pageSize and pageSizeOptions props for built-in client-side pagination - add Timestamp column to Table story fixtures - enable pagination on all four pages with pageSize=10 and size selector - add pagination i18n keys to en.json --- .../.storybook/stories/Pagination.stories.tsx | 56 ++++++++++ client/.storybook/stories/Table.stories.tsx | 86 ++++++++------ client/src/i18n/locales/en.json | 7 ++ client/src/pages/EnvironmentsPage.tsx | 2 + client/src/pages/KeysPage.tsx | 2 + client/src/pages/ScenariosPage.tsx | 2 + client/src/pages/SessionsPage.tsx | 2 + .../src/ui/Pagination/Pagination.module.css | 88 +++++++++++++++ client/src/ui/Pagination/Pagination.tsx | 105 ++++++++++++++++++ client/src/ui/Table/Table.tsx | 40 ++++++- client/src/ui/index.ts | 3 + 11 files changed, 358 insertions(+), 35 deletions(-) create mode 100644 client/.storybook/stories/Pagination.stories.tsx create mode 100644 client/src/ui/Pagination/Pagination.module.css create mode 100644 client/src/ui/Pagination/Pagination.tsx diff --git a/client/.storybook/stories/Pagination.stories.tsx b/client/.storybook/stories/Pagination.stories.tsx new file mode 100644 index 0000000..831eb2a --- /dev/null +++ b/client/.storybook/stories/Pagination.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { Pagination } from '../../src/ui/Pagination/Pagination'; + +const meta: Meta = { + title: 'UI/Pagination', + component: Pagination, + parameters: { layout: 'padded' }, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +function Controlled(args: React.ComponentProps) { + const [page, setPage] = useState(args.page); + const [pageSize, setPageSize] = useState(args.pageSize); + return ( + + ); +} + +export const FewPages: Story = { + render: (args) => , + args: { page: 1, pageSize: 10, total: 30 }, +}; + +export const ManyPages: Story = { + render: (args) => , + args: { page: 5, pageSize: 10, total: 200 }, +}; + +export const LastPage: Story = { + render: (args) => , + args: { page: 20, pageSize: 10, total: 200 }, +}; + +export const SinglePage: Story = { + render: (args) => , + args: { page: 1, pageSize: 10, total: 7 }, +}; + +export const WithPageSizeSelector: Story = { + render: (args) => , + args: { + page: 1, + pageSize: 10, + total: 150, + onPageSizeChange: () => {}, + }, +}; diff --git a/client/.storybook/stories/Table.stories.tsx b/client/.storybook/stories/Table.stories.tsx index d733e62..f45ecd6 100644 --- a/client/.storybook/stories/Table.stories.tsx +++ b/client/.storybook/stories/Table.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Table } from '../../src/ui/Table/Table'; import { Badge } from '../../src/ui/Badge/Badge'; +import { Timestamp } from '../../src/ui/Timestamp/Timestamp'; const meta: Meta = { title: 'UI/Table', @@ -15,53 +16,74 @@ interface User { name: string; email: string; status: 'active' | 'inactive'; + updatedAt: string; } +const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString(); + const users: User[] = [ - { id: 1, name: 'Alice Müller', email: 'alice@example.com', status: 'active' }, - { id: 2, name: 'Bob Nguyen', email: 'bob@example.com', status: 'inactive' }, - { id: 3, name: 'Carol Santos', email: 'carol@example.com', status: 'active' }, + { id: 1, name: 'Alice Müller', email: 'alice@example.com', status: 'active', updatedAt: daysAgo(1) }, + { id: 2, name: 'Bob Nguyen', email: 'bob@example.com', status: 'inactive', updatedAt: daysAgo(5) }, + { id: 3, name: 'Carol Santos', email: 'carol@example.com', status: 'active', updatedAt: daysAgo(30) }, ]; +const manyUsers: User[] = Array.from({ length: 47 }, (_, i) => ({ + id: i + 1, + name: `User ${i + 1}`, + email: `user${i + 1}@example.com`, + status: i % 3 === 0 ? 'inactive' : 'active', + updatedAt: daysAgo(i), +})); + +const columns: StoryObj>['args'] = { + rowKey: (u: User) => u.id, + emptyMessage: 'No users found.', + columns: [ + { key: 'id', header: 'ID', render: (u: User) => u.id, width: 60 }, + { key: 'name', header: 'Name', render: (u: User) => u.name }, + { key: 'email', header: 'Email', render: (u: User) => u.email }, + { + key: 'status', + header: 'Status', + width: 110, + render: (u: User) => ( + {u.status} + ), + }, + { + key: 'updated', + header: 'Updated', + width: 140, + render: (u: User) => , + }, + ], +}; + export const Default: StoryObj> = { - args: { - data: users, - rowKey: (u) => u.id, - emptyMessage: 'No users found.', - columns: [ - { key: 'id', header: 'ID', render: (u) => u.id, width: 60 }, - { key: 'name', header: 'Name', render: (u) => u.name }, - { key: 'email', header: 'Email', render: (u) => u.email }, - { - key: 'status', - header: 'Status', - width: 110, - render: (u) => ( - {u.status} - ), - }, - ], - }, + args: { ...columns, data: users }, }; export const Loading: StoryObj> = { - args: { - ...Default.args, - loading: true, - }, + args: { ...columns, data: users, loading: true }, }; export const Empty: StoryObj> = { - args: { - ...Default.args, - data: [], - emptyMessage: 'No users found.', - }, + args: { ...columns, data: [] }, }; export const SingleRow: StoryObj> = { + args: { ...columns, data: [users[0]] }, +}; + +export const WithPagination: StoryObj> = { + args: { ...columns, data: manyUsers, pageSize: 10 }, +}; + +export const WithPaginationAndSizeSelector: StoryObj> = { args: { - ...Default.args, - data: [users[0]], + ...columns, + data: manyUsers, + pageSize: 10, + pageSizeOptions: [10, 25, 50], }, }; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index c49801c..93fe7f5 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -41,5 +41,12 @@ "switch_to_dark": "Switch to dark theme", "light_mode": "Light mode", "dark_mode": "Dark mode" + }, + "pagination": { + "prev": "Previous page", + "next": "Next page", + "range": "{{from}}–{{to}} of {{total}}", + "per_page": "{{count}} per page", + "page_size": "Items per page" } } diff --git a/client/src/pages/EnvironmentsPage.tsx b/client/src/pages/EnvironmentsPage.tsx index 7c0681c..e8a30f6 100644 --- a/client/src/pages/EnvironmentsPage.tsx +++ b/client/src/pages/EnvironmentsPage.tsx @@ -49,6 +49,8 @@ export function EnvironmentsPage() { rowKey={(e) => e.id} loading={loading} emptyMessage={t('environments.empty')} + pageSize={10} + pageSizeOptions={[10, 25, 50]} /> ); diff --git a/client/src/pages/KeysPage.tsx b/client/src/pages/KeysPage.tsx index ba06694..c45cce3 100644 --- a/client/src/pages/KeysPage.tsx +++ b/client/src/pages/KeysPage.tsx @@ -36,6 +36,8 @@ export function KeysPage() { rowKey={(k) => k.name} loading={loading} emptyMessage={t('keys.empty')} + pageSize={10} + pageSizeOptions={[10, 25, 50]} /> ); diff --git a/client/src/pages/ScenariosPage.tsx b/client/src/pages/ScenariosPage.tsx index a168e8e..61f38e7 100644 --- a/client/src/pages/ScenariosPage.tsx +++ b/client/src/pages/ScenariosPage.tsx @@ -70,6 +70,8 @@ export function ScenariosPage() { rowKey={(s) => s.id} loading={loading} emptyMessage={t('scenarios.empty')} + pageSize={10} + pageSizeOptions={[10, 25, 50]} /> ); diff --git a/client/src/pages/SessionsPage.tsx b/client/src/pages/SessionsPage.tsx index d25a05b..2c24e89 100644 --- a/client/src/pages/SessionsPage.tsx +++ b/client/src/pages/SessionsPage.tsx @@ -69,6 +69,8 @@ export function SessionsPage() { rowKey={(s) => s.id} loading={loading} emptyMessage={t('sessions.empty')} + pageSize={10} + pageSizeOptions={[10, 25, 50]} /> ); diff --git a/client/src/ui/Pagination/Pagination.module.css b/client/src/ui/Pagination/Pagination.module.css new file mode 100644 index 0000000..bd0f725 --- /dev/null +++ b/client/src/ui/Pagination/Pagination.module.css @@ -0,0 +1,88 @@ +.root { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-4); + border-top: var(--border-width) solid var(--color-border); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + flex-wrap: wrap; +} + +.info { + flex: 1; + white-space: nowrap; +} + +.pages { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.pageBtn, +.arrow { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 var(--space-1); + border: var(--border-width) solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + font-size: var(--font-size-xs); + font-family: var(--font-family); + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition); + line-height: 1; +} + +.pageBtn:hover:not(:disabled), +.arrow:hover:not(:disabled) { + background: var(--color-bg); + border-color: var(--color-border); +} + +.pageBtn.active { + background: var(--color-primary); + color: var(--color-primary-fg); + border-color: var(--color-primary); + font-weight: 700; + cursor: default; +} + +.arrow:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 28px; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + user-select: none; +} + +.sizeSelect { + height: 28px; + padding: 0 var(--space-2); + border: var(--border-width) solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-subtle); + color: var(--color-text); + font-size: var(--font-size-xs); + font-family: var(--font-family); + cursor: pointer; + outline: none; + transition: border-color var(--transition); +} + +.sizeSelect:focus { + border-color: var(--color-primary); +} diff --git a/client/src/ui/Pagination/Pagination.tsx b/client/src/ui/Pagination/Pagination.tsx new file mode 100644 index 0000000..74b7aaf --- /dev/null +++ b/client/src/ui/Pagination/Pagination.tsx @@ -0,0 +1,105 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import styles from './Pagination.module.css'; + +export interface PaginationProps { + /** 1-indexed current page */ + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; + onPageSizeChange?: (size: number) => void; + pageSizeOptions?: number[]; +} + +function buildPages(current: number, last: number): (number | '…')[] { + if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1); + + const pages: (number | '…')[] = [1]; + + if (current > 3) pages.push('…'); + + const start = Math.max(2, current - 1); + const end = Math.min(last - 1, current + 1); + for (let i = start; i <= end; i++) pages.push(i); + + if (current < last - 2) pages.push('…'); + + pages.push(last); + return pages; +} + +export function Pagination({ + page, + pageSize, + total, + onPageChange, + onPageSizeChange, + pageSizeOptions = [10, 25, 50], +}: PaginationProps) { + const { t } = useTranslation(); + const lastPage = Math.max(1, Math.ceil(total / pageSize)); + const from = Math.min(total, (page - 1) * pageSize + 1); + const to = Math.min(total, page * pageSize); + + return ( +
+ {t('pagination.range', { from, to, total })} + +
+ + + {buildPages(page, lastPage).map((p, i) => + p === '…' ? ( + + … + + ) : ( + + ), + )} + + +
+ + {onPageSizeChange && ( + + )} +
+ ); +} diff --git a/client/src/ui/Table/Table.tsx b/client/src/ui/Table/Table.tsx index e34e05f..af06817 100644 --- a/client/src/ui/Table/Table.tsx +++ b/client/src/ui/Table/Table.tsx @@ -1,4 +1,5 @@ -import type { ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; +import { Pagination } from '../Pagination/Pagination'; import styles from './Table.module.css'; export interface TableColumn { @@ -16,6 +17,10 @@ export interface TableProps { loading?: boolean; emptyMessage?: string; className?: string; + /** Enable built-in client-side pagination with this default page size */ + pageSize?: number; + /** Offer a page-size selector when pagination is enabled */ + pageSizeOptions?: number[]; } export function Table({ @@ -25,7 +30,19 @@ export function Table({ loading = false, emptyMessage = 'No data.', className, + pageSize: defaultPageSize, + pageSizeOptions, }: TableProps) { + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(defaultPageSize ?? 0); + + const paginated = defaultPageSize !== undefined && !loading; + const visibleData = paginated ? data.slice((page - 1) * pageSize, page * pageSize) : data; + + // Reset to page 1 when data changes and current page would be empty + const lastPage = pageSize > 0 ? Math.ceil(data.length / pageSize) : 1; + const safePage = Math.min(page, Math.max(1, lastPage)); + return (
@@ -52,14 +69,14 @@ export function Table({ Loading… - ) : data.length === 0 ? ( + ) : visibleData.length === 0 ? ( ) : ( - data.map((row) => ( + visibleData.map((row) => ( {columns.map((col) => (
{emptyMessage}
({ )}
+ {paginated && data.length > 0 && ( + { + setPageSize(size); + setPage(1); + } + : undefined + } + pageSizeOptions={pageSizeOptions} + /> + )}
); } diff --git a/client/src/ui/index.ts b/client/src/ui/index.ts index 56165ba..aaa5196 100644 --- a/client/src/ui/index.ts +++ b/client/src/ui/index.ts @@ -24,5 +24,8 @@ export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher'; export { Timestamp } from './Timestamp/Timestamp'; export type { TimestampProps } from './Timestamp/Timestamp'; +export { Pagination } from './Pagination/Pagination'; +export type { PaginationProps } from './Pagination/Pagination'; + export { Table } from './Table/Table'; export type { TableProps, TableColumn } from './Table/Table';