feat(svod2): полосы загрузки в строках-заглушках (Skeleton, как у MRT при «Обновить»)

Ячейки строк с data-svod2-placeholder рисуют MUI Skeleton wave высотой 20 с детерминированной шириной.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-29 13:18:13 +04:00
parent e3a2683b1d
commit eef358c56e
3 changed files with 49 additions and 3 deletions

View File

@ -1,5 +1,5 @@
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material'; import { Alert, Box, Button, Paper, Skeleton, Tab, Tabs, Typography } from '@mui/material';
import { MRT_TableHead, MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { MRT_TableHead, MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { MRT_Localization_RU } from 'material-react-table/locales/ru'; import { MRT_Localization_RU } from 'material-react-table/locales/ru';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
@ -13,7 +13,7 @@ import DenominationSelect from '../../components/common/DenominationSelect';
import useDenomination from '../../components/common/useDenomination'; import useDenomination from '../../components/common/useDenomination';
import { useSvod } from '../SvodPage/SvodContext'; import { useSvod } from '../SvodPage/SvodContext';
import { SvodFilters } from '../SvodPage/SvodFilters'; import { SvodFilters } from '../SvodPage/SvodFilters';
import { EMPTY_FACET_MAP, getSvod2RowId, isPlaceholderRow, shouldShowFound } from './svod2Paging'; import { EMPTY_FACET_MAP, getSkeletonWidth, getSvod2RowId, isPlaceholderRow, shouldShowFound } from './svod2Paging';
import { SVOD2_LAYOUT_OPTIONS, SVOD2_TABLE_CONTAINER_SX, createColumnClassMap, getEstimateTypeClassName } from './svod2Styles'; import { SVOD2_LAYOUT_OPTIONS, SVOD2_TABLE_CONTAINER_SX, createColumnClassMap, getEstimateTypeClassName } from './svod2Styles';
import { useSvod2Pages } from './useSvod2Pages'; import { useSvod2Pages } from './useSvod2Pages';
@ -402,7 +402,17 @@ export default function Svod2Page() {
}, },
muiTableHeadCellProps: ({ column }) => ({ className: columnClassNames.get(column.id) }), muiTableHeadCellProps: ({ column }) => ({ className: columnClassNames.get(column.id) }),
muiTableHeadRowProps: { sx: { boxShadow: 'none' } }, muiTableHeadRowProps: { sx: { boxShadow: 'none' } },
muiTableBodyCellProps: ({ column }) => ({ className: columnClassNames.get(column.id) }), muiTableBodyCellProps: ({ column, row }) => ({
className: columnClassNames.get(column.id),
// Ячейки строк-заглушек: полосы загрузки того же вида, что MRT рисует при «Обновить» (Skeleton wave, высота 20).
...(isPlaceholderRow(row.original)
? {
children: (
<Skeleton animation='wave' height={20} width={getSkeletonWidth(column.getSize(), row.original.index, column.getIndex())} />
),
}
: {}),
}),
localization: { localization: {
...MRT_Localization_RU, ...MRT_Localization_RU,
noRecordsToDisplay: 'Нет данных для выбранных параметров', noRecordsToDisplay: 'Нет данных для выбранных параметров',

View File

@ -111,6 +111,21 @@ export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKe
return Boolean(loading) && pendingQueryKey === queryKey; return Boolean(loading) && pendingQueryKey === queryKey;
} }
// Ширина полосы загрузки в ячейке строки-заглушки: детерминированная (без Math.random, чтобы полосы не «прыгали»
// при перерисовке окна), от трети до полной ширины колонки, как у штатных полос MRT.
export function getSkeletonWidth(columnSize, rowIndex, columnIndex) {
const hash = ((rowIndex + 1) * 2654435761 + (columnIndex + 1) * 40503) >>> 0;
const min = Math.round(columnSize / 3);
return (min + ((hash % 1000) / 1000) * (columnSize - min)) | 0;
}
// При смене поиска/фильтров на глубине первая запрошенная страница (по прокрутке) может оказаться за концом нового,
// короткого списка: сервер ответит только total. Тогда страницу 0 и общее число берём из запасного запроса offset=0,
// не дожидаясь (и не ожидая) ответа на страницу за пределами списка.
export function shouldFetchFirstPageFallback({ candidate, total }) {
return candidate > 0 && candidate >= getPageCount(total);
}
export function isPlaceholderRow(row) { export function isPlaceholderRow(row) {
return Boolean(row?.__placeholder); return Boolean(row?.__placeholder);
} }

View File

@ -10,12 +10,14 @@ import {
facetsToMaps, facetsToMaps,
getMissingPages, getMissingPages,
getPageCount, getPageCount,
getSkeletonWidth,
getSvod2RowId, getSvod2RowId,
getVirtualizerPadding, getVirtualizerPadding,
getVisiblePage, getVisiblePage,
getWindowPages, getWindowPages,
getWindowRange, getWindowRange,
isPlaceholderRow, isPlaceholderRow,
shouldFetchFirstPageFallback,
shouldRefine, shouldRefine,
shouldShowFound, shouldShowFound,
svod2PagingReducer, svod2PagingReducer,
@ -307,3 +309,22 @@ test('shouldRefine: первое монтаж и смена вкладки бе
assert.equal(shouldRefine({ ...base, dataRefineKey: undefined }), false); assert.equal(shouldRefine({ ...base, dataRefineKey: undefined }), false);
assert.equal(shouldRefine({ ...base, hasCurrentData: true, dataRefineKey: 'a' }), false); assert.equal(shouldRefine({ ...base, hasCurrentData: true, dataRefineKey: 'a' }), false);
}); });
test('полосы загрузки: ширина детерминирована и в пределах колонки', () => {
for (const size of [60, 150, 400]) {
for (let i = 0; i < 20; i++) {
const w = getSkeletonWidth(size, i, 3);
assert.equal(w, getSkeletonWidth(size, i, 3));
assert.ok(w >= Math.round(size / 3) && w <= size, `${size}:${i}:${w}`);
}
}
assert.notEqual(getSkeletonWidth(400, 1, 0), getSkeletonWidth(400, 2, 0));
});
test('первая страница при смене условий: запасной offset=0, если запрошенная страница за концом нового списка', () => {
assert.equal(shouldFetchFirstPageFallback({ candidate: 0, total: 43 }), false);
assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 43 }), true);
assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 0 }), true);
assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 22419 }), false);
assert.equal(shouldFetchFirstPageFallback({ candidate: 75, total: 22419 }), true);
});