Пишем на 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} data-testid="pagination-prev-button" > {'<'} </button> {nav && ( <span className={Styles.navigation} data-testid="pagination-navigation"> {nav.current} / {nav.total} </span> )} <button className={Styles.arrow} type="button" onClick={handleNextPageClick} disabled={disable.right} data-testid="pagination-next-button" > {'>'} </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
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
ссылка на оригинал статьи https://habr.com/ru/articles/734980/
Добавить комментарий