Skip to content

Koryun21/test_table

Repository files navigation

Units Explorer

Экран списка юнитов с фильтрами, сортировкой, поиском и бесконечным скроллом поверх mock API.

Live demo: https://units-explorer.vercel.app

Запуск локально

pnpm install
pnpm dev

Приложение откроется на http://localhost:5173.

Base URL API настраивается через переменную окружения VITE_API_URL (см. .env.example). Если переменная не задана, используется дефолт https://test-api.ltouch.ai — прописан в src/api/config/instance.ts, поэтому проект работает из чистого клона без единого env-файла.

Остальные команды:

pnpm build         # tsc + прод-сборка
pnpm preview       # локальный просмотр прод-сборки
pnpm typecheck     # строгий tsc без эмита
pnpm lint          # oxlint (правила кода)
pnpm format        # biome: форматирование + сортировка импортов

Стек

React 19, TypeScript (strict), Tailwind CSS 4, TanStack Router / Query / Table / Virtual, axios, zod, Vite.

Как устроен проект

src/
  api/
    config/        axios-инстанс, интерсепторы ошибок, query-client
    units/         эндпоинты домена: units.api.ts + units.types.ts (zod-схемы)
  components/      переиспользуемые примитивы, не знающие про домен:
                   Icon, ToggleChip, NumberRangeInput, SearchInput, StatusBadge
  widgets/         доменные сборки из примитивов: UnitsTable, UnitsFilters
  pages/           страницы: Units (список), UnitDetails (карточка юнита)
  routes/          тонкие file-based роуты TanStack Router
  hooks/           общие хуки (useDebouncedCallback)
  utils/           cn, форматтеры (цена/комнаты/дата), uniqueById
  constants/       словари для UI (подписи статусов)

Слои читаются так: components — кубики, widgets — сборки из кубиков, pages — композиция сборок. Данные загружаются хуками на уровне страницы и спускаются вниз только через props.

Ключевые решения и почему

URL — единственный источник правды для состояния экрана. Фильтры, сортировка и поиск живут в query-параметрах (validateSearch + zod на роуте). Это даёт reload, back/forward и шаринг ссылки без единой строчки кода синхронизации: /?rooms=[1,2]&sortBy=price&sortDir=asc воспроизводит экран у любого получателя. Невалидные параметры не роняют страницу — каждое поле схемы с .catch(undefined) молча отбрасывает мусор, как это делает и сам API. Отдельного стейт-менеджера в проекте нет намеренно: серверное состояние — в кэше TanStack Query, состояние экрана — в URL, локальные мелочи — в useState. Хранить было бы нечего.

Бесконечный скролл, а не пагинация. Сценарий пользователя — «листать квартиры, пока не понравится», а не «перейти на страницу 47»: подбор жилья — это браузинг, номер страницы здесь ничего не значит. Минус скролла (позиция не шарится ссылкой) компенсирован тем, что фильтры и сортировка в URL сохраняются. Реализация: useInfiniteQuery, догрузка за 5 строк до конца списка.

Виртуализация. В базе 5000 юнитов; без виртуализации после десятка догрузок DOM разросся бы до тысяч строк. @tanstack/react-virtual рисует только видимые ~25 строк, сверху и снизу — два спейсер-<tr>, так что таблица остаётся семантичной. Заголовок sticky.

Честные состояния на каждый случай. Скелетон при первой загрузке; при смене фильтра старые данные остаются на экране приглушёнными (placeholderData: keepPreviousData), а не мигают скелетоном; пустая выдача — отдельное состояние с подсказкой; ошибка первой загрузки — карточка с ретраем (кнопка показывает «Повторяем…» и блокируется); ошибка догрузки — инлайн-строка с ретраем под таблицей, уже загруженные данные не выбрасываются; неудачный фоновый рефетч не стирает таблицу. Устаревшие ответы отменяются: AbortSignal проложен от TanStack Query через axios до реального запроса, поиск дополнительно задебаунсен (400 мс), причём висящий таймер отменяется при сбросе фильтров и back-навигации.

axios-инстанс с двумя интерсепторами. Успешный: API умеет отвечать HTTP 200 с телом {"error": "internal"} — интерсептор ловит это и превращает в исключение, иначе UI показал бы «успех» без данных. Ошибочный: любая сетевая беда нормализуется в единый ApiError { message, status } — таймаут, обрыв, HTTP-код; UI работает с одним предсказуемым типом ошибки.

zod на границе с API. Все ответы парсятся схемами. Толерантность заложена в схему: status может быть null или неизвестной строкой — нормализуется в 'unknown', и таблица честно показывает «Не указан» вместо падения.

react-table с ручной сортировкой. Колонки описаны декларативно через columnHelper, выравнивание и сортируемость — данными в meta колонки. Встроенный sorting-механизм библиотеки не используется: сортирует сервер, а конвертация форматов туда-обратно добавила бы кода больше, чем весь наш вариант. Клик по заголовку циклит asc → desc → сброс и пишет в URL; aria-sort и scope="col" для скринридеров.

Цены в минорных единицах до самого края. API отдаёт копейки (priceMinorUnits: 100 из /api/meta) — в состоянии, URL и запросах цены остаются в копейках, конверсия в рубли происходит только в точках контакта с человеком: форматирование ячейки и поля фильтра. Одно место конверсии — ноль рассинхронов.

Причуды API и как они обработаны

Причуда Обработка
status бывает null и недокументированный on_hold толерантная zod-схема → 'unknown'; бейджи на все 5 значений; чип фильтра on_hold появляется, если статус пришёл в URL
Дубликаты id между страницами дедуп uniqueById при склейке страниц — без него React-ключи и «двойные» квартиры
Страница за пределами данных → HTTP 200 с {"error":"internal"} интерсептор превращает в ошибку; getNextPageParam дополнительно не шагает за total и на пустую страницу
Невалидные query-параметры молча игнорируются зеркальное поведение на клиенте: .catch(undefined) в схеме URL
X-Total-Count только в заголовке берётся из заголовка с fallback на длину выдачи
Цена в минорных единицах делитель и валюта из /api/meta, форматирование Intl.NumberFormat, защита от невалидного кода валюты
pageSize молча обрезается до 100 размер страницы фиксирован в API-слое (50)
rooms: 0 означает студию единый formatRooms в таблице, фильтрах и карточке

Что сознательно не сделано

  • Тесты. Инфраструктура готова (vitest + jsdom в конфиге), но за отведённое время я предпочёл вложиться в обработку краевых случаев рантайма. Первым делом покрыл бы чистые функции границы с API: unitsSearchSchema (мусорные URL), схемы units.types (грязные ответы) и uniqueById — самая высокая плотность логики на строчку кода.
  • React Compiler. Не включён из-за влияния на скорость dev-сборки; ручных мемоизаций в проекте две, обе — требование контракта react-table (стабильные ссылки columns/data), компилятор их не отменил бы.
  • Красивый URL для массивов. TanStack Router сериализует массивы как JSON (rooms=%5B1,2%5D). Кастомный кодек сделал бы ссылку читаемее, но добавил бы неоднозначный парсинг — не стоит того для двух параметров.
  • Ряд улучшений при большем времени: сохранение позиции скролла при возврате с карточки юнита, диапазон-слайдер цены вместо двух полей, колонка createdAt с сортировкой, e2e-смоук на Playwright.

API

Mock API задания: https://test-api.ltouch.ai (докуменация — /docs). Используются GET /api/units, GET /api/units/:id, GET /api/meta.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages