Делаем пагинацию в React приложении

1683aeacc67608da81d330c2aecd247b.png

Пишем на typescript простой, переиспользуемый пагинатор для React приложения. Покрываем его тестам на Jest.

План действий

Весь план действий будет состоять из 5 последовательных этапов:

  1. Инициализируем приложение

  2. Пишем компонент контейнер и определяем логику получения данных

  3. Пишем сам пагинатор

  4. Соединяем все вместе

  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(null);
  const [page, setPage] = useState(1);
  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState(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 
...
; } 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';
  ...

  
...

Вёрстка

Сам же 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 (
    
{nav && ( {nav.current} / {nav.total} )}
); }; 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(null);
  const [page, setPage] = useState(1);
  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState(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 (
    
{data?.list ? (
    {data.list.map((item, index) => (
  • {`${item.name}`}
  • ))}
) : ( 'no data' )} {data && ( )}
); } 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 (
    
{nav && ( {nav.current} / {nav.total} )}
); }; 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(
      
    );

    const prevButton = screen.getByTestId('pagination-prev-button');
    expect(prevButton).toHaveAttribute('disabled');
  });

  it('Должен проставляться атрибут [disabled] для кнопки "вперёд", если выбрана последняя страница', async () => {
    render(
      
    );

    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(
      
    );

    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(
      
    );

    const prevButton = screen.getByTestId('pagination-prev-button');
    fireEvent.click(prevButton);

    expect(onPrevPageClick).toHaveBeenCalledTimes(1);
  });

  it('Должен вызываться обработчик "onNextPageClick" при клике на кнопку "вперёд"', async () => {
    const onNextPageClick = jest.fn();

    render(
      
    );

    const nextButton = screen.getByTestId('pagination-next-button');
    fireEvent.click(nextButton);

    expect(onNextPageClick).toHaveBeenCalledTimes(1);
  });
});

Итого

Спасибо за чтение и удачи в реализации фичи пагинации)

PS: Ссылки из статьи:

© Habrahabr.ru