Пишем на typescript простой, переиспользуемый пагинатор для React приложения. Покрываем его тестам на Jest.
План действий
Весь план действий будет состоять из 5 последовательных этапов:
Инициализируем приложение
Пишем компонент контейнер и определяем логику получения данных
Пишем сам пагинатор
Соединяем все вместе
Пишем тесты на наш компонент
Итак, поехали!
Инициализация приложения
Минимум действий: берём create-react-app с шаблоном typescript и разворачиваем приложение.
npx create-react-app my-app --template typescript
Как ходим за данными
Данные будем хранить в компоненте контейнере. Он будет следить за состоянием, вызывать метод api и прокидывать обновлённые данные вниз (в наш будущий компонент).
Подтягивать данные будем традиционно с использованием хука useEffect, а сохранять данные с помощью useState.
import React, { useEffect, useState, useCallback } from 'react';
import api from './api';
import type { RESPONSE_DATA } from './api';
import './App.css';
function App() {
const [data, setData] = useState<RESPONSE_DATA | null>(null);
const [page, setPage] = useState(1);
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await api.get.data(page);
setData(response);
} catch (err) {
setError(
err instanceof Error ? err.message : 'Unknown Error: api.get.data'
);
setData(null);
} finally {
setLoading(false);
}
};
fetchData();
}, [page]);
return <div className='App'>...</div>;
}
export default App;
Api модуль может быть любым, но если лень придумывать, то ориентировочную реализацию можно посмотреть в другой моей статье: Github pages для pet проектов в разделе API модуль.
А про типизацию catch блока в typescript можно почитать здесь.
Пишем компонент
Контейнер у нас уже есть, теперь напишем простой визуальный Stateless компонент.
Properties
Для начала опредлим, что именно должен делать наш пагинатор.
Наш компонент должен:
уметь уведомлять родительский компонент о том, что произошло событие пагинации
уметь отключать кнопки переключения в граничных условиях
уметь отображать наше текущее положение среди всех доступных страниц
Последний пункт становится актуальным в случае если, api предоставляет информацию о конечном количестве элементов. Однако некоторые api такой возможности не имеют (например, когда содержимое базы данных постоянно изменяется).
Переведём все наши требования на typescript и опишем интерфейс взаимодействия с нашим компонентом:
type PaginationProps = {
onNextPageClick: () => void;
onPrevPageClick: () => void;
disable: {
left: boolean;
right: boolean;
};
nav?: {
current: number;
total: number;
};
};
Стилизация
Для стилизации будем использовать css modules. Поскольку в основе приложения лежит react-create-app с шаблоном ts, то поддержка css modules у нас уже реализована из коробки. Нам достаточно только импортировать стили в компонент и применять к элементам:
import Styles from './index.module.css';
...
<div className={Styles.paginator}>...</div>
Вёрстка
Сам же render компонента будет представлять из себя весьма тривиальный набор из двух кнопок и блока навигации. Навигация будет "спрятана" за условным рендерингом.
Для оптимизации обернём компонент в React.memo
import React from 'react';
import Styles from './index.module.css';
type PaginationProps = {
onNextPageClick: () => void;
onPrevPageClick: () => void;
disable: {
left: boolean;
right: boolean;
};
nav?: {
current: number;
total: number;
};
};
const Pagination = (props: PaginationProps) => {
const { nav = null, disable, onNextPageClick, onPrevPageClick } = props;
const handleNextPageClick = () => {
onNextPageClick();
};
const handlePrevPageClick = () => {
onPrevPageClick();
};
return (
<div className={Styles.paginator}>
<button
className={Styles.arrow}
type="button"
onClick={handlePrevPageClick}
disabled={disable.left}
>
{'<'}
</button>
{nav && (
<span className={Styles.navigation} >
{nav.current} / {nav.total}
</span>
)}
<button
className={Styles.arrow}
type="button"
onClick={handleNextPageClick}
disabled={disable.right}
>
{'>'}
</button>
</div>
);
};
export default React.memo(Pagination);
Соединяем контейнер и пагинатор
Пишем обработчики и прокидываем состояние в компонент пагинатора.
const ROWS_PER_PAGE = 10;
const getTotalPageCount = (rowCount: number): number =>
Math.ceil(rowCount / ROWS_PER_PAGE);
const handleNextPageClick = useCallback(() => {
const current = page;
const next = current + 1;
const total = data ? getTotalPageCount(data.count) : current;
setPage(next <= total ? next : current);
}, [page, data]);
const handlePrevPageClick = useCallback(() => {
const current = page;
const prev = current - 1;
setPage(prev > 0 ? prev : current);
}, [page]);
В обработчиках находится логика, которая и будет в конечном счёте определять, какую именно страницу будем рендерить. Это в свою очередь будет уже тригерить запрос данных и изменение состояния пагинатора.
Итого
Осталось только подключить наш компонент Pagination и наш компонент контейнер:
import React, { useEffect, useState, useCallback } from 'react';
import api from './api';
import type { RESPONSE_DATA } from './api';
import Pagination from './components/pagination';
import './App.css';
const ROWS_PER_PAGE = 10;
const getTotalPageCount = (rowCount: number): number =>
Math.ceil(rowCount / ROWS_PER_PAGE);
function App() {
const [data, setData] = useState<RESPONSE_DATA | null>(null);
const [page, setPage] = useState(1);
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await api.get.data(page);
setData(response);
} catch (err) {
setError(
err instanceof Error ? err.message : 'Unknown Error: api.get.data'
);
setData(null);
} finally {
setLoading(false);
}
};
fetchData();
}, [page]);
const handleNextPageClick = useCallback(() => {
const current = page;
const next = current + 1;
const total = data ? getTotalPageCount(data.count) : current;
setPage(next <= total ? next : current);
}, [page, data]);
const handlePrevPageClick = useCallback(() => {
const current = page;
const prev = current - 1;
setPage(prev > 0 ? prev : current);
}, [page]);
return (
<div className='App'>
{data?.list ? (
<ul>
{data.list.map((item, index) => (
<li key={index}>{`${item.name}`}</li>
))}
</ul>
) : (
'no data'
)}
{data && (
<Pagination
onNextPageClick={handleNextPageClick}
onPrevPageClick={handlePrevPageClick}
disable={{
left: page === 1,
right: page === getTotalPageCount(data.count),
}}
nav={{ current: page, total: getTotalPageCount(data.count) }}
/>
)}
</div>
);
}
export default App;
Мы закончили с логикой. Наш компонент может как изменять состояние контейнера, так и реагировать на изменение этого состояния. Так же мы предусмотрели режим работы без навигации.
Дело осталось за малым - написать парочку тестов и приобрести окончательную уверенность в нашем компоненте при его повторном использовании)
Покрываем тестами
Компонент у нас достаточно простой, поэтому тестировать будем только 3 аспекта работы нашего компонента:
вызов onClick обработчиков при нажатии на стрелки
простановку disable атрибутов на стрелках пагинатора в граничных состояниях
коректуню работу условного рендеринга навигации
Структура теста
В целом каждый тест будет организован по следующему алгоритму:
рендерим компонент
ищем нужный нам элемент компонента
производим действие: клик, вызов функции или что-то ещё
проводим проверку
За рендеринг отвечает метод render. Метод screen поможет нам найти элементы после рендера. В нашем случае будем использовать screen.getByTestId()
А методы fireEvent дадут нам возможность имитировать события реального пользователя.
Все эти объекты мы берём из @testing-library:
import { render, fireEvent, screen } from '@testing-library/react';
Подробнее можно посмотреть на примерах и в документации @testing-library/react
PS:
Всё первоначальные настройки для запуска тестов у нас уже есть из коробки create-react-app
Добавляем тестовые атрибуты
Для того, чтобы мы могли идентифицировать в тесте наши элементы есть хороший способ - поиск по атрибуту.
На самом деле способов очень много (поиск по роли, тексту и т.д), но для простоты и наглядности будем использовать именно атрибуты.
Итак, добавляем на нужные нам элементы атрибут data-testid с уникальным значением.
Желательно, чтобы значение атрибута было уникально не только в рамках компонента, но и в рамках любого контекста, где он (компонент) будет применятся.
...
const Pagination = (props: PaginationProps) => {
...
return (
<div className={Styles.paginator}>
<button
className={Styles.arrow}
...
data-testid="pagination-prev-button"
>
{'<'}
</button>
{nav && (
<span className={Styles.navigation} data-testid="pagination-navigation">
{nav.current} / {nav.total}
</span>
)}
<button
className={Styles.arrow}
...
data-testid="pagination-next-button"
>
{'>'}
</button>
</div>
);
};
export default React.memo(Pagination);
Тестируем простановку атрибутов disabled
Здесь нам нужно воспользоваться методом toHaveAttribute.
import '@testing-library/jest-dom';
import { render, fireEvent, screen } from '@testing-library/react';
import Pagination from '../../src/components/pagination';
describe('React component: Pagination', () => {
it('Должен проставляться атрибут [disabled] для кнопки "назад", если выбрана первая страница', async () => {
render(
<Pagination
disable={{
left: true,
right: false,
}}
onPrevPageClick={jest.fn()}
onNextPageClick={jest.fn()}
/>
);
const prevButton = screen.getByTestId('pagination-prev-button');
expect(prevButton).toHaveAttribute('disabled');
});
it('Должен проставляться атрибут [disabled] для кнопки "вперёд", если выбрана последняя страница', async () => {
render(
<Pagination
disable={{
left: false,
right: true,
}}
onPrevPageClick={jest.fn()}
onNextPageClick={jest.fn()}
/>
);
const nextButton = screen.getByTestId('pagination-next-button');
expect(nextButton).toHaveAttribute('disabled');
});
});
Тестируем условный рендеринг навигации
Нам понадобится метод toThrow, а в сам expect мы передадим функцию, а не переменную.
describe('React component: Pagination', () => {
it('Должен проставляться атрибут [disabled] для кнопки "назад", если выбрана первая страница', async () => {...});
it('Должен проставляться атрибут [disabled] для кнопки "вперёд", если выбрана последняя страница', async () => {...});
it('Не должна отображаться навигация "<текущая страница>/<все страницы>" если не предоставлен соответствующий пропс "nav"', async () => {
render(
<Pagination
disable={{
left: false,
right: false,
}}
onPrevPageClick={jest.fn()}
onNextPageClick={jest.fn()}
/>
);
expect(() => screen.getByTestId('pagination-navigation')).toThrow();
});
});
Тестируем работу коллбэков
Здесь нам нужно воспользоваться методом toHaveBeenCalledTimes.
import '@testing-library/jest-dom';
import { render, fireEvent, screen } from '@testing-library/react';
import Pagination from '../../src/components/pagination';
describe('React component: Pagination', () => {
it('Должен проставляться атрибут [disabled] для кнопки "назад", если выбрана первая страница', async () => {...});
it('Должен проставляться атрибут [disabled] для кнопки "вперёд", если выбрана последняя страница', async () => {...});
it('Не должна отображаться навигация "<текущая страница>/<все страницы>" если не предоставлен соответствующий пропс "nav"', async () => {...});
it('Должен вызываться обработчик "onPrevPageClick" при клике на кнопку "назад"', async () => {
const onPrevPageClick = jest.fn();
render(
<Pagination
disable={{
left: false,
right: false,
}}
onPrevPageClick={onPrevPageClick}
onNextPageClick={jest.fn()}
/>
);
const prevButton = screen.getByTestId('pagination-prev-button');
fireEvent.click(prevButton);
expect(onPrevPageClick).toHaveBeenCalledTimes(1);
});
it('Должен вызываться обработчик "onNextPageClick" при клике на кнопку "вперёд"', async () => {
const onNextPageClick = jest.fn();
render(
<Pagination
disable={{
left: false,
right: false,
}}
onPrevPageClick={jest.fn()}
onNextPageClick={onNextPageClick}
/>
);
const nextButton = screen.getByTestId('pagination-next-button');
fireEvent.click(nextButton);
expect(onNextPageClick).toHaveBeenCalledTimes(1);
});
});
Итого
Спасибо за чтение и удачи в реализации фичи пагинации)
PS: Ссылки из статьи:
про типизацию catch блока
про create-react-app
про React.memo
Ionenice
Но не умеет же :)
тоже чуток сомнительно, просто вывод текущей страницы слэш количество страниц?
robzarel Автор
умеет же))
в компоненте проставляются атрибуты disabled для button элементов (кнопок), что и делает их не интерактивными: https://www.w3schools.com/tags/att_button_disabled.asp. В дополнение в css проставляется cursor: not-allowed; при hover в кейсе наличия disabled атрибута, что помогает сориентироваться пользователю)
Сами же граничные условия задаются в контейнере и передаются через props - disable: { left: boolean; right: boolean; };
да, просто вывод текущего положения.
Если хочется, можно с небольшими доработками сделать любую логику:
- любое кол-во доступных страниц выводить (показывать сколько-то предыдущих/последующих, выводить общее и т.д и т.п
- вообще убрать навигацию
Ionenice
Неа, всю работу делает родительский элемент, пагинатор только пропсы выводит, в итоге слишком много некрасивого «оверхэда»
robzarel Автор
вы правы - в данной реализации пагинатор это стейтлесс компонент. Его задача просто предоставлять контролы и уметь в 2 режима отображения (с навигацией и без). Контейнер же содержит в себе состояние и на основе этого состояния ходит за данными (через useEffect). Мне кажется, что убирать логику работы с данными в сам пагинатор - это означает, что мы сможем переиспользовать компонент только в местах с такой же логикой подтягивания данных. В случае же с вынесением состояния в контейнер пагинатор становится переиспользуемым в большем количестве кейсов.
Плюс подобная реализация это весьма удобно, так как очень часто, помимо простой пагинации ещё на страницах поиска применяются фильтры. И данная реализация, когда состояние и логика подтягивания данных, находится в контейнере, позволяет легко покрывать и эти кейсы. Например: