Skip to content

Commit

Permalink
feat: add pagination model ( #104 )
Browse files Browse the repository at this point in the history
  • Loading branch information
siloneco committed Nov 21, 2023
1 parent 983c500 commit 4945d03
Show file tree
Hide file tree
Showing 6 changed files with 321 additions and 0 deletions.
92 changes: 92 additions & 0 deletions src/components/model/Pagination/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { PaginationPresentation } from './presentations';
import { PaginationErrorPresentation } from './presentations/error';

import type { Meta, StoryObj } from '@storybook/react';

const meta: Meta<typeof PaginationPresentation> = {
component: PaginationPresentation,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};

export default meta;

type Story = StoryObj<typeof PaginationPresentation>;

export const Default: Story = {
args: {
totalPage: 10,
currentPage: 5,
displayRange: 1,
},
};

export const Error: Story = {
render: () => <PaginationErrorPresentation />,
};

export const DisplayRangeZero: Story = {
args: {
totalPage: 10,
currentPage: 5,
displayRange: 0,
},
};

export const DisplayRangeBig: Story = {
args: {
totalPage: 10,
currentPage: 5,
displayRange: 5,
},
};

export const One: Story = {
args: {
totalPage: 10,
currentPage: 1,
displayRange: 1,
},
};

export const Mid: Story = {
args: {
totalPage: 10,
currentPage: 5,
displayRange: 1,
},
};

export const Total: Story = {
args: {
totalPage: 10,
currentPage: 10,
displayRange: 1,
},
};

export const LeftEdge: Story = {
args: {
totalPage: 10,
currentPage: 3,
displayRange: 1,
},
};

export const RightEdge: Story = {
args: {
totalPage: 10,
currentPage: 8,
displayRange: 1,
},
};

export const OnePageOnly: Story = {
args: {
totalPage: 1,
currentPage: 1,
displayRange: 1,
},
};
91 changes: 91 additions & 0 deletions src/components/model/Pagination/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';

import { Pagination } from '.';

const testData = {
totalPage: 10,
currentPage: 5,
displayRange: 1,
};

describe('model/Pagination', () => {
it('renders button correctly', () => {
render(<Pagination {...testData} handleClick={jest.fn()} />);

// renders all buttons
expect(screen.getByText(1)).toBeInTheDocument();
expect(screen.getByText(testData.currentPage - 1)).toBeInTheDocument();
expect(screen.getByText(testData.currentPage)).toBeInTheDocument();
expect(screen.getByText(testData.currentPage + 1)).toBeInTheDocument();
expect(screen.getByText(testData.totalPage)).toBeInTheDocument();

// not renders out of display range
expect(() => screen.getByText(testData.currentPage - 2)).toThrow();
expect(() => screen.getByText(testData.currentPage + 2)).toThrow();
});

it('renders and works arrow button correctly', async () => {
const mockHandleClick = jest.fn();
const { container } = render(
<Pagination {...testData} handleClick={mockHandleClick} />
);

const leftArrow = container.getElementsByClassName(
'lucide-chevron-left'
)[0];
expect(leftArrow).toBeInTheDocument();
expect(leftArrow).toBeEnabled();

const rightArrow = container.getElementsByClassName(
'lucide-chevron-right'
)[0];
expect(rightArrow).toBeInTheDocument();
expect(rightArrow).toBeEnabled();

await userEvent.click(leftArrow as Element);
await userEvent.click(rightArrow as Element);

expect(mockHandleClick).toHaveBeenCalledTimes(2);
});

it('renders disabled left arrow button when currentPage is 1', () => {
const { container } = render(
<Pagination {...testData} currentPage={1} handleClick={jest.fn()} />
);

const leftArrowSvg = container.getElementsByClassName(
'lucide-chevron-left'
)[0];
const leftArrowButton = leftArrowSvg?.parentElement;
expect(leftArrowButton).toBeDisabled();
});

it('renders disabled right arrow button when currentPage equals totalPage', () => {
const { container } = render(
<Pagination
{...testData}
currentPage={testData.totalPage}
handleClick={jest.fn()}
/>
);

const rightArrowSvg = container.getElementsByClassName(
'lucide-chevron-right'
)[0];
const rightArrowButton = rightArrowSvg?.parentElement;
expect(rightArrowButton).toBeDisabled();
});

it('works correctly when button clicked', async () => {
const mockHandleClick = jest.fn();

render(<Pagination {...testData} handleClick={mockHandleClick} />);

const button = screen.getByText(testData.currentPage - 1);
await userEvent.click(button);

expect(mockHandleClick).toHaveBeenCalled();
});
});
19 changes: 19 additions & 0 deletions src/components/model/Pagination/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { FC } from 'react';

import { PaginationPresentation } from './presentations';
import { PaginationErrorPresentation } from './presentations/error';

import { ErrorBoundary } from '@/libs/ErrorBoundary';

export type Props = {
handleClick: (page: number) => void;
totalPage: number;
currentPage: number;
displayRange?: number;
};

export const Pagination: FC<Props> = (props) => (
<ErrorBoundary fallback={<PaginationErrorPresentation />}>
<PaginationPresentation {...props} />
</ErrorBoundary>
);
38 changes: 38 additions & 0 deletions src/components/model/Pagination/logic/CheckElementRequired.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
function range(start: number, end: number): number[] {
if (start > end) {
return [];
}
return [...Array<number>(end - start + 1)].map((_, i) => start + i);
}

export const isPageOneRequired = (
currentPage: number,
displayRange: number
): boolean => currentPage > displayRange + 1;

export const isLeftDotsRequired = (
currentPage: number,
displayRange: number
): boolean => currentPage > displayRange + 2;

export const isTotalPageRequired = (
currentPage: number,
totalPage: number,
displayRange: number
): boolean => currentPage < totalPage - displayRange;

export const isRightDotsRequired = (
currentPage: number,
totalPage: number,
displayRange: number
): boolean => currentPage < totalPage - displayRange - 1 && displayRange >= 0;

export const getMiddleRange = (
currentPage: number,
totalPage: number,
displayRange: number
): number[] =>
range(
Math.max(currentPage - displayRange, 1),
Math.min(totalPage, currentPage + displayRange)
);
7 changes: 7 additions & 0 deletions src/components/model/Pagination/presentations/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { FC } from 'react';

export const PaginationErrorPresentation: FC = () => (
<div className="w-full h-12 items-center justify-center flex">
ページ切り替えボタンの表示に失敗しました
</div>
);
74 changes: 74 additions & 0 deletions src/components/model/Pagination/presentations/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { FC } from 'react';

import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';

import {
getMiddleRange,
isLeftDotsRequired,
isPageOneRequired,
isRightDotsRequired,
isTotalPageRequired,
} from '../logic/CheckElementRequired';

import type { Props } from '..';

import { Button } from '@/components/ui/Button';

function getDots(): JSX.Element {
return <MoreHorizontal size={24} />;
}

function getPageButton(
page: number,
handleClick: (page: number) => void,
isCurrentPage: boolean = false
): JSX.Element | null {
return (
<Button
key={page}
variant={isCurrentPage ? 'secondary' : 'outline'}
className="rounded-full aspect-square p-2 text-lg font-bold"
onClick={(): void => handleClick(page)}
>
{page}
</Button>
);
}

export const PaginationPresentation: FC<Props> = (props) => {
const { currentPage, handleClick, totalPage, displayRange = 1 } = props;

return (
<div className="flex flex-row items-center space-x-5">
<Button
variant="outline"
disabled={currentPage === 1}
className="rounded-full aspect-square p-2"
onClick={(): void => handleClick(currentPage - 1)}
>
<ChevronLeft />
</Button>

{isPageOneRequired(currentPage, displayRange) &&
getPageButton(1, handleClick)}
{isLeftDotsRequired(currentPage, displayRange) && getDots()}

{getMiddleRange(currentPage, totalPage, displayRange).map((page) =>
getPageButton(page, handleClick, page === currentPage)
)}

{isRightDotsRequired(currentPage, totalPage, displayRange) && getDots()}
{isTotalPageRequired(currentPage, totalPage, displayRange) &&
getPageButton(totalPage, handleClick)}

<Button
variant="outline"
disabled={currentPage === totalPage}
className="rounded-full aspect-square p-2"
onClick={(): void => handleClick(currentPage + 1)}
>
<ChevronRight />
</Button>
</div>
);
};

0 comments on commit 4945d03

Please sign in to comment.