AURORA-1843: свод 2 — догрузка строк страницами, поиск и фильтры через сервер (2/3, фронт) #243
@ -692,3 +692,8 @@ PR создаются по готовности, merge — по команде
|
|||||||
Цена ошибки: нет.
|
Цена ошибки: нет.
|
||||||
42. **2026-10-28 (контроллер, R-8) — M-4 (символ NUL в `search`/фильтре даёт ошибку типа `text` PostgreSQL, вероятно 5xx) не правится в этой задаче, `deferred`; с экрана такой символ не вводится.**
|
42. **2026-10-28 (контроллер, R-8) — M-4 (символ NUL в `search`/фильтре даёт ошибку типа `text` PostgreSQL, вероятно 5xx) не правится в этой задаче, `deferred`; с экрана такой символ не вводится.**
|
||||||
Цена ошибки: 5xx на искусственном запросе; правка — ранний возврат пустой страницы.
|
Цена ошибки: 5xx на искусственном запросе; правка — ранний возврат пустой страницы.
|
||||||
|
43. **2026-10-29 (контроллер, R-9) — высота строки окна определяется измерением первой отрисованной строки с данными (по умолчанию 37 px) и используется в `estimateSize`, `measureElement`, `rowHeight` хука и отступах. `ROW_VIRTUALIZER_OPTIONS` не правится.**
|
||||||
|
Обоснование: константа 37 px при фактической высоте ≈44 px (бейдж «Вид сметы», отступы ячеек, граница) наложила бы строки друг на друга; «до» мерил `offsetHeight`.
|
||||||
|
Цена ошибки: дрожание высоты при первом измерении; ручная проверка контроллером на стенде; после влития AURORA-1836 источник заменить на её константу.
|
||||||
|
44. **2026-10-29 (контроллер, R-10) — приняты как отступления (deferred): окно страниц запрашивается после ответа первой страницы (T12.1); при сужающем refine глубоко внизу уходит лишний запрос `offset ≥ total` (T12.2); после ошибки смена поиска запускает refine без ожидания «Повторить» (T12.4); на границе страниц после refine/«Обновить» на один ответ видны заглушки (T12.5).**
|
||||||
|
Цена ошибки: лишний запрос или кратковременные заглушки; проверяет главный контроллер вручную.
|
||||||
|
|||||||
@ -5,4 +5,10 @@ export const SummaryApi = {
|
|||||||
const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {};
|
const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {};
|
||||||
return api.get(`/svod/${summaryNumber}/${sheet}/${year}`, { params }).then((response) => response.data);
|
return api.get(`/svod/${summaryNumber}/${sheet}/${year}`, { params }).then((response) => response.data);
|
||||||
},
|
},
|
||||||
|
getSvod2Page(sheet, year, params, { signal } = {}) {
|
||||||
|
return api.get(`/svod/2/${sheet}/${year}`, { params, signal }).then((response) => response.data);
|
||||||
|
},
|
||||||
|
getSvod2Facets(sheet, year, params, { signal } = {}) {
|
||||||
|
return api.get(`/svod/2/${sheet}/${year}/facets`, { params, signal }).then((response) => response.data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
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, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@ -13,14 +13,14 @@ 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, 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';
|
||||||
|
|
||||||
const SHEETS = [
|
const SHEETS = [
|
||||||
{ value: 'GO', label: 'ССП' },
|
{ value: 'GO', label: 'ССП' },
|
||||||
{ value: 'RF', label: 'РФ' },
|
{ value: 'RF', label: 'РФ' },
|
||||||
];
|
];
|
||||||
const createInitialTableState = () =>
|
|
||||||
Object.fromEntries(SHEETS.map(({ value }) => [value, { rows: [], loading: false, error: '', loadedQueryKey: '' }]));
|
|
||||||
const QUARTERS = [1, 2, 3, 4];
|
const QUARTERS = [1, 2, 3, 4];
|
||||||
const tableSize = (rem) => rem * 16;
|
const tableSize = (rem) => rem * 16;
|
||||||
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
|
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
|
||||||
@ -282,32 +282,16 @@ const scaleNumericColumns = (columns, divisor) =>
|
|||||||
return { ...column, Cell: ({ cell }) => formatValue(cell.getValue(), divisor) };
|
return { ...column, Cell: ({ cell }) => formatValue(cell.getValue(), divisor) };
|
||||||
});
|
});
|
||||||
|
|
||||||
const matchesSearch = (row, query) => {
|
|
||||||
const searchableData = {
|
|
||||||
...row.data?.header,
|
|
||||||
...row.data?.contract_summary,
|
|
||||||
...row.data?.contract_detail,
|
|
||||||
};
|
|
||||||
return Object.values(searchableData).some((value) =>
|
|
||||||
String(value ?? '')
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(query),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Svod2Page() {
|
export default function Svod2Page() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
const { getSheetFilters, updateSheetFilters } = useSvod();
|
||||||
const [sheet, setSheet] = useState('GO');
|
const [sheet, setSheet] = useState('GO');
|
||||||
// <table> MRT: заголовок рисуем сами через портал (см. комментарий у enableTableHead).
|
// <table> MRT: заголовок рисуем сами через портал (см. комментарий у enableTableHead).
|
||||||
const [tableElement, setTableElement] = useState(null);
|
const [tableElement, setTableElement] = useState(null);
|
||||||
const denominationStorageKey = `svod2_${sheet}_denomination`;
|
const denominationStorageKey = `svod2_${sheet}_denomination`;
|
||||||
const { denomination, setDenomination: handleDenominationChange, divisor } = useDenomination(denominationStorageKey);
|
const { denomination, setDenomination: handleDenominationChange, divisor } = useDenomination(denominationStorageKey);
|
||||||
const [tableStateBySheet, setTableStateBySheet] = useState(createInitialTableState);
|
|
||||||
const [columnFiltersBySheet, setColumnFiltersBySheet] = useState(() => Object.fromEntries(SHEETS.map(({ value }) => [value, []])));
|
const [columnFiltersBySheet, setColumnFiltersBySheet] = useState(() => Object.fromEntries(SHEETS.map(({ value }) => [value, []])));
|
||||||
const latestSummaryRequestRef = useRef(Object.fromEntries(SHEETS.map(({ value }) => [value, 0])));
|
|
||||||
const { year, selectedOrganizations, search } = getSheetFilters(sheet);
|
const { year, selectedOrganizations, search } = getSheetFilters(sheet);
|
||||||
const { rows, loading, error, loadedQueryKey } = tableStateBySheet[sheet];
|
|
||||||
const columnFilters = columnFiltersBySheet[sheet];
|
const columnFilters = columnFiltersBySheet[sheet];
|
||||||
const selectedOrganizationIds = useMemo(
|
const selectedOrganizationIds = useMemo(
|
||||||
() => (selectedOrganizations.some(({ id }) => id === '__all__') ? [] : selectedOrganizations.map(({ id }) => id)),
|
() => (selectedOrganizations.some(({ id }) => id === '__all__') ? [] : selectedOrganizations.map(({ id }) => id)),
|
||||||
@ -317,7 +301,6 @@ export default function Svod2Page() {
|
|||||||
() => JSON.stringify([sheet, year, selectedOrganizations.map(({ id }) => String(id)).sort()]),
|
() => JSON.stringify([sheet, year, selectedOrganizations.map(({ id }) => String(id)).sort()]),
|
||||||
[sheet, year, selectedOrganizations],
|
[sheet, year, selectedOrganizations],
|
||||||
);
|
);
|
||||||
const hasCurrentData = loadedQueryKey === currentQueryKey;
|
|
||||||
const updateCurrentSheetFilters = useCallback(
|
const updateCurrentSheetFilters = useCallback(
|
||||||
(changes) => {
|
(changes) => {
|
||||||
updateSheetFilters(sheet, changes);
|
updateSheetFilters(sheet, changes);
|
||||||
@ -336,69 +319,37 @@ export default function Svod2Page() {
|
|||||||
[sheet],
|
[sheet],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
const tableRef = useRef(null);
|
||||||
const requestId = latestSummaryRequestRef.current[sheet] + 1;
|
const getScrollTop = useCallback(() => tableRef.current?.refs.tableContainerRef.current?.scrollTop ?? 0, []);
|
||||||
latestSummaryRequestRef.current[sheet] = requestId;
|
const [rowHeight, setRowHeight] = useState(ROW_HEIGHT);
|
||||||
setTableStateBySheet((current) => ({
|
const { state, windowRows, padding, facetsMap, load, onScroll } = useSvod2Pages({
|
||||||
...current,
|
sheet,
|
||||||
[sheet]: { ...current[sheet], loading: true, error: '' },
|
year,
|
||||||
}));
|
orgIds: selectedOrganizationIds,
|
||||||
try {
|
queryKey: currentQueryKey,
|
||||||
const response = await getSummary(sheet, year, selectedOrganizationIds);
|
search,
|
||||||
if (latestSummaryRequestRef.current[sheet] !== requestId) return;
|
columnFilters,
|
||||||
setTableStateBySheet((current) => ({
|
rowHeight,
|
||||||
...current,
|
getScrollTop,
|
||||||
[sheet]: { ...current[sheet], rows: response.result || [], loadedQueryKey: currentQueryKey },
|
});
|
||||||
}));
|
const { loading, error } = state;
|
||||||
} catch (loadError) {
|
const hasCurrentData = state.loadedQueryKey === currentQueryKey;
|
||||||
if (latestSummaryRequestRef.current[sheet] !== requestId) return;
|
const facetsMapRef = useRef(facetsMap);
|
||||||
setTableStateBySheet((current) => ({
|
facetsMapRef.current = facetsMap;
|
||||||
...current,
|
const refreshSummary = useCallback(() => load('refresh'), [load]);
|
||||||
[sheet]: {
|
|
||||||
...current[sheet],
|
|
||||||
rows: [],
|
|
||||||
error: loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
if (latestSummaryRequestRef.current[sheet] === requestId) {
|
|
||||||
setTableStateBySheet((current) => ({
|
|
||||||
...current,
|
|
||||||
[sheet]: { ...current[sheet], loading: false },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentQueryKey, getSummary, selectedOrganizationIds, sheet, year]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
SHEETS.forEach(({ value }) => {
|
|
||||||
latestSummaryRequestRef.current[value] += 1;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshSummary = useCallback(() => {
|
|
||||||
invalidateSummary(sheet, year, selectedOrganizationIds);
|
|
||||||
loadSummary();
|
|
||||||
}, [invalidateSummary, loadSummary, selectedOrganizationIds, sheet, year]);
|
|
||||||
const openFilteredTasks = useCallback(() => {
|
const openFilteredTasks = useCallback(() => {
|
||||||
const params = new URLSearchParams({ year: String(year), form_type: 'FORM_2' });
|
const params = new URLSearchParams({ year: String(year), form_type: 'FORM_2' });
|
||||||
if (selectedOrganizationIds.length) params.set('org_units', selectedOrganizationIds.join(','));
|
if (selectedOrganizationIds.length) params.set('org_units', selectedOrganizationIds.join(','));
|
||||||
navigate(`/tasks?${params.toString()}`);
|
navigate(`/tasks?${params.toString()}`);
|
||||||
}, [navigate, selectedOrganizationIds, year]);
|
}, [navigate, selectedOrganizationIds, year]);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
|
||||||
if (!hasCurrentData) return [];
|
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
|
||||||
return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows;
|
|
||||||
}, [hasCurrentData, rows, search]);
|
|
||||||
const { columns: rawColumns, columnClassNames } = useMemo(() => getColumnConfig(sheet), [sheet]);
|
const { columns: rawColumns, columnClassNames } = useMemo(() => getColumnConfig(sheet), [sheet]);
|
||||||
const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]);
|
const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]);
|
||||||
const table = useMaterialReactTable({
|
const table = useMaterialReactTable({
|
||||||
columns,
|
columns,
|
||||||
data: filteredRows,
|
data: hasCurrentData ? windowRows : [],
|
||||||
getRowId: (row) => `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`,
|
getRowId: getSvod2RowId,
|
||||||
|
manualFiltering: true,
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
enableColumnFilters: true,
|
enableColumnFilters: true,
|
||||||
enableFacetedValues: true,
|
enableFacetedValues: true,
|
||||||
@ -419,7 +370,8 @@ export default function Svod2Page() {
|
|||||||
enableTopToolbar: false,
|
enableTopToolbar: false,
|
||||||
enableBottomToolbar: false,
|
enableBottomToolbar: false,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
|
getFacetedUniqueValues: (_table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_FACET_MAP,
|
||||||
|
rowVirtualizerOptions: { ...ROW_VIRTUALIZER_OPTIONS, estimateSize: () => rowHeight, measureElement: () => rowHeight, ...padding },
|
||||||
state: { isLoading: loading, columnFilters },
|
state: { isLoading: loading, columnFilters },
|
||||||
initialState: {
|
initialState: {
|
||||||
columnPinning: { left: ['smeta_type', 'smeta_direction', 'org_name', 'name'] },
|
columnPinning: { left: ['smeta_type', 'smeta_direction', 'org_name', 'name'] },
|
||||||
@ -438,7 +390,9 @@ export default function Svod2Page() {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX },
|
muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll },
|
||||||
|
muiTableBodyRowProps: ({ row }) =>
|
||||||
|
isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '', style: { height: rowHeight } } : {},
|
||||||
muiTableHeadProps: {
|
muiTableHeadProps: {
|
||||||
sx: {
|
sx: {
|
||||||
'& tr:not(:last-of-type) > th > .MuiCollapse-root': {
|
'& tr:not(:last-of-type) > th > .MuiCollapse-root': {
|
||||||
@ -448,12 +402,34 @@ 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: 'Нет данных для выбранных параметров',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
tableRef.current = table;
|
||||||
|
// Единая высота строки для окна страниц: реальная высота первой строки с данными (по умолчанию ROW_HEIGHT).
|
||||||
|
// Не измеряем во время загрузки (MRT рисует скелетон другой высоты); берём максимум по строкам с данными.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
const elements = tableRef.current?.refs.tableContainerRef.current?.querySelectorAll(
|
||||||
|
'tbody tr[data-index]:not([data-svod2-placeholder])',
|
||||||
|
);
|
||||||
|
let measured = 0;
|
||||||
|
for (const element of elements ?? []) measured = Math.max(measured, Math.round(element.offsetHeight));
|
||||||
|
if (measured > 0 && measured !== rowHeight) setRowHeight(measured);
|
||||||
|
});
|
||||||
let content = (
|
let content = (
|
||||||
<>
|
<>
|
||||||
<MaterialReactTable table={table} />
|
<MaterialReactTable table={table} />
|
||||||
@ -519,9 +495,9 @@ export default function Svod2Page() {
|
|||||||
emptyOrganizationsPlaceholder='Выберите ССП/РФ'
|
emptyOrganizationsPlaceholder='Выберите ССП/РФ'
|
||||||
allowSelectAll
|
allowSelectAll
|
||||||
additionalFilters={<DenominationSelect value={denomination} onChange={handleDenominationChange} minWidth={135} />}>
|
additionalFilters={<DenominationSelect value={denomination} onChange={handleDenominationChange} minWidth={135} />}>
|
||||||
{hasCurrentData && !loading && filteredRows.length !== rows.length && (
|
{shouldShowFound({ hasCurrentData, loading, facets: state.facets, error }) && (
|
||||||
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
|
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||||
{`Найдено ${filteredRows.length.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
|
{`Найдено ${state.facets.search_total.toLocaleString('ru-RU')} из ${state.facets.total.toLocaleString('ru-RU')} строк`}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<PrimaryButton variant='contained' onClick={openFilteredTasks} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
<PrimaryButton variant='contained' onClick={openFilteredTasks} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
||||||
|
|||||||
193
web/src/pages/Svod2Page/svod2Paging.js
Normal file
193
web/src/pages/Svod2Page/svod2Paging.js
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
// Чистая логика серверной постраничной загрузки свода 2: окно страниц, срез с заглушками,
|
||||||
|
// LRU-кэш страниц, параметры запроса, варианты фильтров. Без React, JSX и axios.
|
||||||
|
|
||||||
|
export const PAGE_SIZE = 300;
|
||||||
|
export const WINDOW_RADIUS = 1;
|
||||||
|
export const PAGE_CACHE_LIMIT = 5;
|
||||||
|
export const FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name'];
|
||||||
|
export const EMPTY_FACET_MAP = Object.freeze(new Map());
|
||||||
|
|
||||||
|
export function getPageCount(total) {
|
||||||
|
return total > 0 ? Math.ceil(total / PAGE_SIZE) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVisiblePage({ scrollTop, rowHeight, total }) {
|
||||||
|
const lastPage = Math.max(getPageCount(total) - 1, 0);
|
||||||
|
const firstRow = Math.floor(scrollTop / rowHeight);
|
||||||
|
const page = Math.floor(firstRow / PAGE_SIZE);
|
||||||
|
if (!Number.isFinite(page)) return 0;
|
||||||
|
return Math.min(Math.max(page, 0), lastPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindowPages(visiblePage, total) {
|
||||||
|
const count = getPageCount(total);
|
||||||
|
const pages = [];
|
||||||
|
for (let p = visiblePage - WINDOW_RADIUS; p <= visiblePage + WINDOW_RADIUS; p++) {
|
||||||
|
if (p >= 0 && p < count) pages.push(p);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindowRange(pages, total) {
|
||||||
|
if (pages.length === 0) return { windowStart: 0, windowEnd: 0 };
|
||||||
|
const first = pages[0];
|
||||||
|
const last = pages.at(-1);
|
||||||
|
return { windowStart: first * PAGE_SIZE, windowEnd: Math.min(total, (last + 1) * PAGE_SIZE) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWindowRows(pages, pageCache, total) {
|
||||||
|
const result = [];
|
||||||
|
for (const p of pages) {
|
||||||
|
const start = p * PAGE_SIZE;
|
||||||
|
const expected = Math.min(total, (p + 1) * PAGE_SIZE) - start;
|
||||||
|
const cached = pageCache.get(p) ?? [];
|
||||||
|
for (let i = 0; i < expected; i++) {
|
||||||
|
result.push(i < cached.length ? cached[i] : { __placeholder: true, index: start + i });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_PAGES = Object.freeze(new Map());
|
||||||
|
|
||||||
|
// Страницы, которые можно показывать: только поколения, чьи данные уже пришли. После смены условий (поиск, фильтры,
|
||||||
|
// «Обновить») поколение вырастает сразу, а первый ответ нового приходит позже: до него все строки окна — заглушки.
|
||||||
|
export function getCurrentPages({ pages, dataGeneration, generation }) {
|
||||||
|
return dataGeneration === generation ? pages : NO_PAGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVirtualizerPadding({ windowStart, windowEnd, total, rowHeight }) {
|
||||||
|
return {
|
||||||
|
paddingStart: windowStart * rowHeight,
|
||||||
|
paddingEnd: Math.max(total - windowEnd, 0) * rowHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchPage(cache, pageIndex, rows, keepPages = []) {
|
||||||
|
const next = new Map(cache);
|
||||||
|
next.delete(pageIndex);
|
||||||
|
next.set(pageIndex, rows);
|
||||||
|
const keep = new Set(keepPages);
|
||||||
|
keep.add(pageIndex);
|
||||||
|
for (const key of [...next.keys()]) {
|
||||||
|
if (next.size <= PAGE_CACHE_LIMIT) break;
|
||||||
|
if (!keep.has(key)) next.delete(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMissingPages(pages, cache, inflight) {
|
||||||
|
return pages.filter((p) => !cache.has(p) && !inflight.has(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSvod2QueryParams({ orgIds = [], search = '', columnFilters = [] } = {}) {
|
||||||
|
const params = {};
|
||||||
|
if (orgIds.length > 0) params.org_ids = orgIds.join(',');
|
||||||
|
if (search !== '') params.search = search;
|
||||||
|
for (const { id, value } of columnFilters) {
|
||||||
|
if (FILTER_COLUMNS.includes(id) && value !== '' && value !== null && value !== undefined) {
|
||||||
|
params[id] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSvod2PageParams({ orgIds, search, columnFilters, offset, limit = PAGE_SIZE }) {
|
||||||
|
return { view: 'compact', offset, limit, ...buildSvod2QueryParams({ orgIds, search, columnFilters }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function facetsToMaps(result) {
|
||||||
|
const maps = new Map();
|
||||||
|
for (const column of FILTER_COLUMNS) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const { value, count } of result?.[column] ?? []) map.set(value, count);
|
||||||
|
maps.set(column, map);
|
||||||
|
}
|
||||||
|
return maps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowFound({ hasCurrentData, loading, facets, error = '' }) {
|
||||||
|
return Boolean(hasCurrentData) && !loading && !error && !!facets && facets.search_total !== facets.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нужно ли уточнение (refine) при смене поиска/колоночных фильтров: на загруженных данных
|
||||||
|
// либо пока для текущего queryKey ещё летит первая загрузка (иначе ответы станут «текущими» по старым условиям).
|
||||||
|
// dataRefineKey — условия, на которых построены загруженные (или летящие) данные вкладки; нет их — нет и refine.
|
||||||
|
export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKey, dataRefineKey, refineKey }) {
|
||||||
|
if (dataRefineKey === undefined || dataRefineKey === null || dataRefineKey === refineKey) return false;
|
||||||
|
if (hasCurrentData) return true;
|
||||||
|
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) {
|
||||||
|
return Boolean(row?.__placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSvod2RowId(row) {
|
||||||
|
if (isPlaceholderRow(row)) return `ph-${row.index}`;
|
||||||
|
return `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSheetPagingState() {
|
||||||
|
return {
|
||||||
|
generation: 0,
|
||||||
|
dataGeneration: 0,
|
||||||
|
pendingQueryKey: '',
|
||||||
|
loadedQueryKey: '',
|
||||||
|
total: 0,
|
||||||
|
facets: null,
|
||||||
|
pages: new Map(),
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инвариант для хука: settled надо слать для КАЖДОГО поколения; settled старого поколения игнорируется,
|
||||||
|
// поэтому loading, выставленный begin(load), снимает только settled текущего поколения.
|
||||||
|
export function svod2PagingReducer(state, action) {
|
||||||
|
if (action.generation !== state.generation && action.type !== 'begin') return state;
|
||||||
|
switch (action.type) {
|
||||||
|
case 'begin':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
generation: action.generation,
|
||||||
|
pendingQueryKey: action.queryKey,
|
||||||
|
error: '',
|
||||||
|
loading: action.mode === 'load' ? true : state.loading,
|
||||||
|
};
|
||||||
|
case 'page': {
|
||||||
|
const first = state.dataGeneration !== action.generation;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pages: first ? new Map([[action.pageIndex, action.rows]]) : touchPage(state.pages, action.pageIndex, action.rows, action.keepPages),
|
||||||
|
dataGeneration: action.generation,
|
||||||
|
loadedQueryKey: first ? state.pendingQueryKey : state.loadedQueryKey,
|
||||||
|
total: action.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'facets':
|
||||||
|
return { ...state, facets: action.facets };
|
||||||
|
case 'fail':
|
||||||
|
if (action.mode === 'scroll') return state;
|
||||||
|
return { ...state, error: action.message, pages: new Map(), total: 0 };
|
||||||
|
case 'settled':
|
||||||
|
return { ...state, loading: false };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
346
web/src/pages/Svod2Page/svod2Paging.test.js
Normal file
346
web/src/pages/Svod2Page/svod2Paging.test.js
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
EMPTY_FACET_MAP,
|
||||||
|
PAGE_CACHE_LIMIT,
|
||||||
|
buildSvod2PageParams,
|
||||||
|
buildSvod2QueryParams,
|
||||||
|
buildWindowRows,
|
||||||
|
createSheetPagingState,
|
||||||
|
facetsToMaps,
|
||||||
|
getCurrentPages,
|
||||||
|
getMissingPages,
|
||||||
|
getPageCount,
|
||||||
|
getSkeletonWidth,
|
||||||
|
getSvod2RowId,
|
||||||
|
getVirtualizerPadding,
|
||||||
|
getVisiblePage,
|
||||||
|
getWindowPages,
|
||||||
|
getWindowRange,
|
||||||
|
isPlaceholderRow,
|
||||||
|
shouldFetchFirstPageFallback,
|
||||||
|
shouldRefine,
|
||||||
|
shouldShowFound,
|
||||||
|
svod2PagingReducer,
|
||||||
|
touchPage,
|
||||||
|
} from './svod2Paging.js';
|
||||||
|
|
||||||
|
test('окно в начале — страницы 0 и 1 (решение #15)', () => assert.deepEqual(getWindowPages(0, 5563), [0, 1]));
|
||||||
|
test('окно в середине и в конце', () => {
|
||||||
|
assert.deepEqual(getWindowPages(5, 5563), [4, 5, 6]);
|
||||||
|
assert.deepEqual(getWindowPages(18, 5563), [17, 18]);
|
||||||
|
});
|
||||||
|
test('total = 0 — пустое окно и пустой срез', () => {
|
||||||
|
assert.deepEqual(getWindowPages(0, 0), []);
|
||||||
|
assert.deepEqual(buildWindowRows([], new Map(), 0), []);
|
||||||
|
assert.equal(getPageCount(0), 0);
|
||||||
|
assert.deepEqual(getWindowRange([], 0), { windowStart: 0, windowEnd: 0 });
|
||||||
|
});
|
||||||
|
test('видимая страница прижимается к последней при уменьшении total', () =>
|
||||||
|
assert.equal(getVisiblePage({ scrollTop: 200000 * 37, rowHeight: 37, total: 700 }), 2));
|
||||||
|
test('видимая страница: обычный скролл и total = 0', () => {
|
||||||
|
assert.equal(getVisiblePage({ scrollTop: 0, rowHeight: 37, total: 5563 }), 0);
|
||||||
|
assert.equal(getVisiblePage({ scrollTop: 300 * 37, rowHeight: 37, total: 5563 }), 1);
|
||||||
|
assert.equal(getVisiblePage({ scrollTop: 300 * 37 - 1, rowHeight: 37, total: 5563 }), 0);
|
||||||
|
assert.equal(getVisiblePage({ scrollTop: 5000, rowHeight: 37, total: 0 }), 0);
|
||||||
|
});
|
||||||
|
test('срез: неполная последняя страница и заглушки', () => {
|
||||||
|
const rows = buildWindowRows([17, 18], new Map([[18, Array.from({ length: 163 }, (_, i) => ({ id: i }))]]), 5563);
|
||||||
|
assert.equal(rows.length, 300 + 163);
|
||||||
|
assert.deepEqual(rows[0], { __placeholder: true, index: 5100 });
|
||||||
|
assert.equal(rows[300].id, 0);
|
||||||
|
});
|
||||||
|
test('страница короче ожидаемой дополняется заглушками, длиннее — обрезается', () => {
|
||||||
|
// total изменился между запросами: страница 0 пришла с 250 строками, страница 1 — с 400
|
||||||
|
const short = Array.from({ length: 250 }, (_, i) => ({ id: i }));
|
||||||
|
const long = Array.from({ length: 400 }, (_, i) => ({ id: 1000 + i }));
|
||||||
|
const rows = buildWindowRows(
|
||||||
|
[0, 1],
|
||||||
|
new Map([
|
||||||
|
[0, short],
|
||||||
|
[1, long],
|
||||||
|
]),
|
||||||
|
600,
|
||||||
|
);
|
||||||
|
assert.equal(rows.length, 600);
|
||||||
|
assert.equal(rows[249].id, 249);
|
||||||
|
assert.deepEqual(rows[250], { __placeholder: true, index: 250 });
|
||||||
|
assert.deepEqual(rows[299], { __placeholder: true, index: 299 });
|
||||||
|
assert.equal(rows[300].id, 1000);
|
||||||
|
assert.equal(rows[599].id, 1299);
|
||||||
|
});
|
||||||
|
test('сумма отступов и строк окна = total * rowH', () => {
|
||||||
|
const { windowStart, windowEnd } = getWindowRange([4, 5, 6], 5563);
|
||||||
|
assert.equal(windowStart, 1200);
|
||||||
|
assert.equal(windowEnd, 2100);
|
||||||
|
const { paddingStart, paddingEnd } = getVirtualizerPadding({ windowStart, windowEnd, total: 5563, rowHeight: 37 });
|
||||||
|
assert.equal(paddingStart + (windowEnd - windowStart) * 37 + paddingEnd, 5563 * 37);
|
||||||
|
});
|
||||||
|
test('окно с последней неполной страницей: windowEnd = total, нижний отступ 0', () => {
|
||||||
|
const { windowStart, windowEnd } = getWindowRange([17, 18], 5563);
|
||||||
|
assert.equal(windowEnd, 5563);
|
||||||
|
assert.deepEqual(getVirtualizerPadding({ windowStart, windowEnd, total: 5563, rowHeight: 37 }).paddingEnd, 0);
|
||||||
|
});
|
||||||
|
test('LRU: не больше 5 страниц, страницы окна не вытесняются', () => {
|
||||||
|
let cache = new Map();
|
||||||
|
for (let p = 0; p < 7; p++) cache = touchPage(cache, p, [{ id: p }], [0, 1]);
|
||||||
|
assert.equal(PAGE_CACHE_LIMIT, 5);
|
||||||
|
assert.equal(cache.size, 5);
|
||||||
|
assert.ok(cache.has(0));
|
||||||
|
assert.ok(cache.has(1));
|
||||||
|
assert.deepEqual(
|
||||||
|
[...cache.keys()].sort((a, b) => a - b),
|
||||||
|
[0, 1, 4, 5, 6],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('LRU: повторное обращение делает страницу самой свежей, исходный Map не мутируется', () => {
|
||||||
|
const base = new Map([
|
||||||
|
[1, []],
|
||||||
|
[2, []],
|
||||||
|
]);
|
||||||
|
const next = touchPage(base, 1, [{ id: 'x' }]);
|
||||||
|
assert.notEqual(next, base);
|
||||||
|
assert.deepEqual([...base.keys()], [1, 2]);
|
||||||
|
assert.deepEqual([...next.keys()], [2, 1]);
|
||||||
|
assert.deepEqual(next.get(1), [{ id: 'x' }]);
|
||||||
|
});
|
||||||
|
test('getMissingPages исключает загруженные и летящие', () =>
|
||||||
|
assert.deepEqual(getMissingPages([4, 5, 6], new Map([[5, []]]), new Set([6])), [4]));
|
||||||
|
test('параметры страницы: compact, пустые не передаются, search как введён', () => {
|
||||||
|
assert.deepEqual(buildSvod2PageParams({ orgIds: [], search: '', columnFilters: [], offset: 300 }), {
|
||||||
|
view: 'compact',
|
||||||
|
offset: 300,
|
||||||
|
limit: 300,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
buildSvod2QueryParams({
|
||||||
|
orgIds: [3, 5],
|
||||||
|
search: ' аренда ',
|
||||||
|
columnFilters: [
|
||||||
|
{ id: 'org_name', value: 'Филиал Ёлки' },
|
||||||
|
{ id: 'name', value: 'x' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ org_ids: '3,5', search: ' аренда ', org_name: 'Филиал Ёлки' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildSvod2QueryParams({
|
||||||
|
columnFilters: [
|
||||||
|
{ id: 'smeta_type', value: '' },
|
||||||
|
{ id: 'smeta_direction', value: null },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
assert.equal(buildSvod2PageParams({ offset: 0, limit: 50, search: 'a' }).limit, 50);
|
||||||
|
});
|
||||||
|
test('facetsToMaps', () => {
|
||||||
|
const result = { smeta_type: [{ value: 'КВ', count: 3 }], smeta_direction: [], org_name: [] };
|
||||||
|
const maps = facetsToMaps(result);
|
||||||
|
assert.equal(maps.get('smeta_type').get('КВ'), 3);
|
||||||
|
assert.equal(maps.get('org_name').size, 0);
|
||||||
|
assert.notEqual(facetsToMaps(result), maps);
|
||||||
|
assert.notEqual(facetsToMaps(result).get('smeta_type'), maps.get('smeta_type'));
|
||||||
|
assert.equal(facetsToMaps(null).get('org_name').size, 0);
|
||||||
|
assert.ok(Object.isFrozen(EMPTY_FACET_MAP));
|
||||||
|
});
|
||||||
|
test('«Найдено» — только поиск', () => {
|
||||||
|
assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 300 } }), true);
|
||||||
|
assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 5563 } }), false);
|
||||||
|
assert.equal(shouldShowFound({ hasCurrentData: true, loading: true, facets: { total: 5563, search_total: 300 } }), false);
|
||||||
|
assert.equal(shouldShowFound({ hasCurrentData: false, loading: false, facets: { total: 5563, search_total: 300 } }), false);
|
||||||
|
assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: null }), false);
|
||||||
|
});
|
||||||
|
test('getSvod2RowId', () => {
|
||||||
|
assert.equal(getSvod2RowId({ __placeholder: true, index: 7 }), 'ph-7');
|
||||||
|
assert.equal(getSvod2RowId({ data: { header: { source_form: 'f1', source_id: 2 }, line_id: 3 } }), 'f1-2-3');
|
||||||
|
assert.equal(isPlaceholderRow({ __placeholder: true, index: 1 }), true);
|
||||||
|
assert.equal(isPlaceholderRow({ data: {} }), false);
|
||||||
|
assert.equal(isPlaceholderRow(undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const begin = (s, generation, mode, queryKey = 'k') => svod2PagingReducer(s, { type: 'begin', generation, mode, queryKey });
|
||||||
|
const page = (s, generation, pageIndex, rows, total) =>
|
||||||
|
svod2PagingReducer(s, { type: 'page', generation, pageIndex, rows, total, keepPages: [pageIndex] });
|
||||||
|
|
||||||
|
test('начальное состояние вкладки', () => {
|
||||||
|
const s = createSheetPagingState();
|
||||||
|
assert.equal(s.generation, 0);
|
||||||
|
assert.equal(s.dataGeneration, 0);
|
||||||
|
assert.equal(s.total, 0);
|
||||||
|
assert.equal(s.facets, null);
|
||||||
|
assert.equal(s.loading, false);
|
||||||
|
assert.equal(s.error, '');
|
||||||
|
assert.equal(s.pages.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('устаревшие page/facets/fail игнорируются', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load');
|
||||||
|
s = begin(s, 2, 'refine');
|
||||||
|
for (const a of [
|
||||||
|
{ type: 'page', generation: 1, pageIndex: 0, rows: [], total: 9 },
|
||||||
|
{ type: 'facets', generation: 1, facets: {} },
|
||||||
|
{ type: 'fail', generation: 1, mode: 'load', message: 'x' },
|
||||||
|
{ type: 'settled', generation: 1 },
|
||||||
|
])
|
||||||
|
assert.equal(svod2PagingReducer(s, a), s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('load ставит loading и pendingQueryKey, сбрасывает error', () => {
|
||||||
|
const s = begin(createSheetPagingState(), 1, 'load', 'q1');
|
||||||
|
assert.equal(s.loading, true);
|
||||||
|
assert.equal(s.generation, 1);
|
||||||
|
assert.equal(s.pendingQueryKey, 'q1');
|
||||||
|
assert.equal(s.error, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refine оставляет прежние строки до первого ответа нового поколения', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load');
|
||||||
|
s = page(s, 1, 0, ['a', 'b'], 2);
|
||||||
|
s = svod2PagingReducer(s, { type: 'facets', generation: 1, facets: { total: 2 } });
|
||||||
|
s = svod2PagingReducer(s, { type: 'settled', generation: 1 });
|
||||||
|
s = begin(s, 2, 'refine', 'k2');
|
||||||
|
assert.deepEqual(s.pages.get(0), ['a', 'b']);
|
||||||
|
assert.equal(s.total, 2);
|
||||||
|
assert.deepEqual(s.facets, { total: 2 });
|
||||||
|
assert.equal(s.loading, false);
|
||||||
|
assert.equal(s.pendingQueryKey, 'k2');
|
||||||
|
s = page(s, 2, 0, ['c'], 1);
|
||||||
|
assert.deepEqual([...s.pages.keys()], [0]);
|
||||||
|
assert.deepEqual(s.pages.get(0), ['c']);
|
||||||
|
assert.equal(s.dataGeneration, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('первый ответ поколения ставит loadedQueryKey', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load', 'k');
|
||||||
|
assert.equal(s.loadedQueryKey, '');
|
||||||
|
s = page(s, 1, 0, ['a'], 1);
|
||||||
|
assert.equal(s.loadedQueryKey, 'k');
|
||||||
|
assert.equal(s.dataGeneration, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ошибка догрузки (scroll) не меняет состояние, страница снова среди недостающих', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load');
|
||||||
|
s = page(s, 1, 0, ['a'], 600);
|
||||||
|
assert.equal(svod2PagingReducer(s, { type: 'fail', generation: s.generation, mode: 'scroll', message: 'x' }), s);
|
||||||
|
assert.deepEqual(getMissingPages([0, 1], s.pages, new Set()), [1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ошибка load очищает строки и ставит error', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load');
|
||||||
|
s = page(s, 1, 0, ['a'], 5);
|
||||||
|
s = svod2PagingReducer(s, { type: 'fail', generation: 1, mode: 'load', message: 'x' });
|
||||||
|
assert.equal(s.error, 'x');
|
||||||
|
assert.equal(s.pages.size, 0);
|
||||||
|
assert.equal(s.total, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ошибка refine очищает строки и ставит error', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load');
|
||||||
|
s = page(s, 1, 0, ['a'], 5);
|
||||||
|
s = begin(s, 2, 'refine');
|
||||||
|
s = svod2PagingReducer(s, { type: 'fail', generation: 2, mode: 'refine', message: 'y' });
|
||||||
|
assert.equal(s.error, 'y');
|
||||||
|
assert.equal(s.pages.size, 0);
|
||||||
|
assert.equal(s.total, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('последний total поколения побеждает', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load');
|
||||||
|
s = page(s, 1, 0, ['a'], 5563);
|
||||||
|
s = page(s, 1, 1, ['b'], 5560);
|
||||||
|
assert.equal(s.total, 5560);
|
||||||
|
assert.equal(s.pages.size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('facets и settled текущего поколения', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load');
|
||||||
|
s = svod2PagingReducer(s, { type: 'facets', generation: 1, facets: { total: 3 } });
|
||||||
|
assert.deepEqual(s.facets, { total: 3 });
|
||||||
|
s = svod2PagingReducer(s, { type: 'settled', generation: 1 });
|
||||||
|
assert.equal(s.loading, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('begin(load) оставляет прежние pages/total/facets до первого ответа нового поколения', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load');
|
||||||
|
s = page(s, 1, 0, ['a'], 5);
|
||||||
|
s = svod2PagingReducer(s, { type: 'facets', generation: 1, facets: { total: 5 } });
|
||||||
|
s = begin(s, 2, 'load', 'k2');
|
||||||
|
assert.equal(s.loading, true);
|
||||||
|
assert.deepEqual(s.pages.get(0), ['a']);
|
||||||
|
assert.equal(s.total, 5);
|
||||||
|
assert.deepEqual(s.facets, { total: 5 });
|
||||||
|
s = page(s, 2, 0, ['z'], 1);
|
||||||
|
assert.deepEqual([...s.pages.keys()], [0]);
|
||||||
|
assert.deepEqual(s.pages.get(0), ['z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refine поверх load: loading снимает settled нового поколения, старого - игнорируется', () => {
|
||||||
|
let s = begin(createSheetPagingState(), 1, 'load');
|
||||||
|
s = begin(s, 2, 'refine');
|
||||||
|
assert.equal(s.loading, true);
|
||||||
|
assert.equal(svod2PagingReducer(s, { type: 'settled', generation: 1 }), s);
|
||||||
|
s = svod2PagingReducer(s, { type: 'settled', generation: 2 });
|
||||||
|
assert.equal(s.loading, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('«Найдено» не показывается рядом с ошибкой', () => {
|
||||||
|
assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, error: 'x', facets: { total: 5, search_total: 1 } }), false);
|
||||||
|
});
|
||||||
|
test('shouldRefine: на загруженных данных при смене условий', () => {
|
||||||
|
const base = { hasCurrentData: true, loading: false, pendingQueryKey: 'q', queryKey: 'q', dataRefineKey: 'a', refineKey: 'b' };
|
||||||
|
assert.equal(shouldRefine(base), true);
|
||||||
|
assert.equal(shouldRefine({ ...base, refineKey: 'a' }), false);
|
||||||
|
});
|
||||||
|
test('shouldRefine: поиск изменён во время первой загрузки (T12.7)', () => {
|
||||||
|
const base = { hasCurrentData: false, loading: true, pendingQueryKey: 'q', queryKey: 'q', dataRefineKey: 'a', refineKey: 'b' };
|
||||||
|
assert.equal(shouldRefine(base), true);
|
||||||
|
assert.equal(shouldRefine({ ...base, pendingQueryKey: 'old' }), false);
|
||||||
|
assert.equal(shouldRefine({ ...base, loading: false }), false);
|
||||||
|
});
|
||||||
|
test('shouldRefine: ключ A -> B -> A при изменённых фильтрах', () => {
|
||||||
|
// данные ключа A построены на фильтре 'a'; на B фильтры сброшены (''), возврат на A — данные устарели
|
||||||
|
const onA = { hasCurrentData: true, loading: false, pendingQueryKey: 'A', queryKey: 'A', dataRefineKey: 'a', refineKey: '' };
|
||||||
|
assert.equal(shouldRefine(onA), true);
|
||||||
|
const onB = { ...onA, hasCurrentData: false, queryKey: 'B' };
|
||||||
|
assert.equal(shouldRefine(onB), false);
|
||||||
|
});
|
||||||
|
test('shouldRefine: первое монтаж и смена вкладки без расхождения не запускают refine', () => {
|
||||||
|
const base = { hasCurrentData: false, loading: false, pendingQueryKey: '', queryKey: 'q', refineKey: 'a' };
|
||||||
|
assert.equal(shouldRefine({ ...base, dataRefineKey: undefined }), 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('смена поколения условий: пока нет ответа нового поколения, старые страницы не показываются (все строки окна — заглушки)', () => {
|
||||||
|
const pages = new Map([[0, [{ data: 1 }]]]);
|
||||||
|
assert.equal(getCurrentPages({ pages, dataGeneration: 3, generation: 3 }), pages);
|
||||||
|
const stale = getCurrentPages({ pages, dataGeneration: 3, generation: 4 });
|
||||||
|
assert.equal(stale.size, 0);
|
||||||
|
const rows = buildWindowRows([0], stale, 3);
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((r) => isPlaceholderRow(r)),
|
||||||
|
[true, true, true],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('до первой загрузки (поколение 0) страниц нет и показывать нечего', () => {
|
||||||
|
assert.equal(getCurrentPages({ pages: new Map(), dataGeneration: 0, generation: 0 }).size, 0);
|
||||||
|
});
|
||||||
@ -105,6 +105,13 @@ export const SVOD2_TABLE_CONTAINER_SX = {
|
|||||||
'& .svod2-badge-oper': { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' },
|
'& .svod2-badge-oper': { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' },
|
||||||
'& .svod2-badge-kv': { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' },
|
'& .svod2-badge-kv': { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' },
|
||||||
'& .svod2-badge-default': { backgroundColor: '#f3f4f6', borderColor: '#d1d5db', color: '#4b5563' },
|
'& .svod2-badge-default': { backgroundColor: '#f3f4f6', borderColor: '#d1d5db', color: '#4b5563' },
|
||||||
|
// Строка-заглушка ещё не загруженной страницы: пустой вид без мигающих бейджей.
|
||||||
|
'& tbody tr.svod2-placeholder td': { color: 'transparent' },
|
||||||
|
'& tbody tr.svod2-placeholder .svod2-badge, & tbody tr.svod2-placeholder .svod2-org-badge': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderColor: 'transparent',
|
||||||
|
color: 'transparent',
|
||||||
|
},
|
||||||
'& .svod2-org-badge': {
|
'& .svod2-org-badge': {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
|||||||
@ -67,6 +67,12 @@ test('container styles define cell, band, group-end and pinned rules', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('заглушка свода 2 — прозрачный текст в строке-заглушке', () => {
|
||||||
|
const rule = SVOD2_TABLE_CONTAINER_SX['& tbody tr.svod2-placeholder td'];
|
||||||
|
assert.ok(rule, 'нет правила заглушки');
|
||||||
|
assert.equal(rule.color, 'transparent');
|
||||||
|
});
|
||||||
|
|
||||||
test('column virtualization layout uses grid-no-grow and column pinning', () => {
|
test('column virtualization layout uses grid-no-grow and column pinning', () => {
|
||||||
assert.equal(SVOD2_LAYOUT_OPTIONS.layoutMode, 'grid-no-grow');
|
assert.equal(SVOD2_LAYOUT_OPTIONS.layoutMode, 'grid-no-grow');
|
||||||
assert.equal(SVOD2_LAYOUT_OPTIONS.enableColumnPinning, true);
|
assert.equal(SVOD2_LAYOUT_OPTIONS.enableColumnPinning, true);
|
||||||
|
|||||||
24
web/src/pages/Svod2Page/svod2Virtualizer.test.js
Normal file
24
web/src/pages/Svod2Page/svod2Virtualizer.test.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { Virtualizer } from '@tanstack/virtual-core';
|
||||||
|
|
||||||
|
test('paddingStart/paddingEnd ставят глобальную строку i на i*rowH, общий размер = total*rowH', () => {
|
||||||
|
const rowH = 37;
|
||||||
|
const total = 5563;
|
||||||
|
const windowStart = 300;
|
||||||
|
const windowEnd = 1200;
|
||||||
|
const v = new Virtualizer({
|
||||||
|
count: windowEnd - windowStart,
|
||||||
|
getScrollElement: () => null,
|
||||||
|
estimateSize: () => rowH,
|
||||||
|
paddingStart: windowStart * rowH,
|
||||||
|
paddingEnd: (total - windowEnd) * rowH,
|
||||||
|
scrollToFn: () => undefined,
|
||||||
|
observeElementRect: () => undefined,
|
||||||
|
observeElementOffset: () => undefined,
|
||||||
|
});
|
||||||
|
const m = v.getMeasurements();
|
||||||
|
assert.equal(m[0].start, windowStart * rowH);
|
||||||
|
assert.equal(m.at(-1).end, windowEnd * rowH);
|
||||||
|
assert.equal(v.getTotalSize(), total * rowH);
|
||||||
|
});
|
||||||
320
web/src/pages/Svod2Page/useSvod2Pages.js
Normal file
320
web/src/pages/Svod2Page/useSvod2Pages.js
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
// Хук серверной постраничной загрузки свода 2: поколения запросов, окно страниц, догрузка при прокрутке.
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
import { SummaryApi } from '../../api/summary';
|
||||||
|
import {
|
||||||
|
PAGE_SIZE,
|
||||||
|
buildSvod2PageParams,
|
||||||
|
buildSvod2QueryParams,
|
||||||
|
buildWindowRows,
|
||||||
|
createSheetPagingState,
|
||||||
|
facetsToMaps,
|
||||||
|
getCurrentPages,
|
||||||
|
getMissingPages,
|
||||||
|
getPageCount,
|
||||||
|
getVirtualizerPadding,
|
||||||
|
getVisiblePage,
|
||||||
|
getWindowPages,
|
||||||
|
getWindowRange,
|
||||||
|
shouldFetchFirstPageFallback,
|
||||||
|
shouldRefine,
|
||||||
|
svod2PagingReducer,
|
||||||
|
} from './svod2Paging';
|
||||||
|
|
||||||
|
const SHEET_KEYS = ['GO', 'RF'];
|
||||||
|
const LOAD_ERROR_FALLBACK = 'Не удалось загрузить свод. Попробуйте ещё раз.';
|
||||||
|
const SCROLL_ERROR_MESSAGE = 'Не удалось загрузить строки свода. Прокрутите ещё раз, чтобы повторить.';
|
||||||
|
const NO_PADDING = { paddingStart: 0, paddingEnd: 0 };
|
||||||
|
|
||||||
|
const createInitialState = () => Object.fromEntries(SHEET_KEYS.map((key) => [key, createSheetPagingState()]));
|
||||||
|
const rootReducer = (state, action) => ({ ...state, [action.sheet]: svod2PagingReducer(state[action.sheet], action) });
|
||||||
|
|
||||||
|
const getRawPage = (scrollTop, rowHeight) => {
|
||||||
|
const page = Math.floor(scrollTop / rowHeight / PAGE_SIZE);
|
||||||
|
return Number.isFinite(page) && page > 0 ? page : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRefineKey = (search, columnFilters) => JSON.stringify([search, buildSvod2QueryParams({ columnFilters })]);
|
||||||
|
|
||||||
|
const isCanceled = (error, ctx) => axios.isCancel(error) || error?.code === 'ERR_CANCELED' || ctx.controller.signal.aborted;
|
||||||
|
|
||||||
|
export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }) {
|
||||||
|
// Состояние хранится в ref синхронно (редьюсер вызывается в dispatch), чтобы логика догрузки видела актуальные страницы.
|
||||||
|
const stateRef = useRef(null);
|
||||||
|
if (stateRef.current === null) stateRef.current = createInitialState();
|
||||||
|
const [state, setState] = useState(stateRef.current);
|
||||||
|
const [scrollPage, setScrollPage] = useState(0);
|
||||||
|
|
||||||
|
const generationRef = useRef(Object.fromEntries(SHEET_KEYS.map((key) => [key, 0])));
|
||||||
|
const ctxRef = useRef({});
|
||||||
|
const rafRef = useRef(0);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const propsRef = useRef(null);
|
||||||
|
propsRef.current = { sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop };
|
||||||
|
|
||||||
|
const dispatch = useCallback((action) => {
|
||||||
|
const next = rootReducer(stateRef.current, action);
|
||||||
|
if (next === stateRef.current) return;
|
||||||
|
stateRef.current = next;
|
||||||
|
if (mountedRef.current) setState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPage = useCallback(async (ctx, pageIndex, signal = ctx.controller.signal) => {
|
||||||
|
ctx.inflight.add(pageIndex);
|
||||||
|
try {
|
||||||
|
const response = await SummaryApi.getSvod2Page(
|
||||||
|
ctx.sheet,
|
||||||
|
ctx.year,
|
||||||
|
buildSvod2PageParams({
|
||||||
|
orgIds: ctx.orgIds,
|
||||||
|
search: ctx.search,
|
||||||
|
columnFilters: ctx.columnFilters,
|
||||||
|
offset: pageIndex * PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const rows = response.result || [];
|
||||||
|
return { rows, total: response.total ?? rows.length };
|
||||||
|
} finally {
|
||||||
|
ctx.inflight.delete(pageIndex);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getWindowFor = useCallback((total) => {
|
||||||
|
const { rowHeight: height, getScrollTop: readScrollTop } = propsRef.current;
|
||||||
|
const visible = getVisiblePage({ scrollTop: readScrollTop(), rowHeight: height, total });
|
||||||
|
return getWindowPages(visible, total);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const start = useCallback(
|
||||||
|
(mode) => {
|
||||||
|
const current = propsRef.current;
|
||||||
|
const sheetKey = current.sheet;
|
||||||
|
ctxRef.current[sheetKey]?.controller.abort();
|
||||||
|
const generation = generationRef.current[sheetKey] + 1;
|
||||||
|
generationRef.current[sheetKey] = generation;
|
||||||
|
const ctx = {
|
||||||
|
sheet: sheetKey,
|
||||||
|
year: current.year,
|
||||||
|
orgIds: current.orgIds,
|
||||||
|
search: current.search,
|
||||||
|
columnFilters: current.columnFilters,
|
||||||
|
generation,
|
||||||
|
controller: new AbortController(),
|
||||||
|
inflight: new Set(),
|
||||||
|
ready: false,
|
||||||
|
refineKey: makeRefineKey(current.search, current.columnFilters),
|
||||||
|
};
|
||||||
|
ctxRef.current[sheetKey] = ctx;
|
||||||
|
const candidate = getRawPage(current.getScrollTop(), current.rowHeight);
|
||||||
|
setScrollPage(candidate);
|
||||||
|
dispatch({ type: 'begin', sheet: sheetKey, generation, queryKey: current.queryKey, mode: mode === 'refine' ? 'refine' : 'load' });
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
const fail = (error) => {
|
||||||
|
if (isCanceled(error, ctx) || failed) return;
|
||||||
|
failed = true;
|
||||||
|
dispatch({
|
||||||
|
type: 'fail',
|
||||||
|
sheet: sheetKey,
|
||||||
|
generation,
|
||||||
|
mode: mode === 'refine' ? 'refine' : 'load',
|
||||||
|
message: error?.response?.data?.detail || LOAD_ERROR_FALLBACK,
|
||||||
|
});
|
||||||
|
ctx.controller.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFacets = async () => {
|
||||||
|
try {
|
||||||
|
const response = await SummaryApi.getSvod2Facets(ctx.sheet, ctx.year, buildSvod2QueryParams(ctx), {
|
||||||
|
signal: ctx.controller.signal,
|
||||||
|
});
|
||||||
|
if (ctx.controller.signal.aborted) return;
|
||||||
|
dispatch({ type: 'facets', sheet: sheetKey, generation, facets: response.result });
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPages = async () => {
|
||||||
|
try {
|
||||||
|
// Страница по прокрутке и (на глубине) запасная offset=0 летят параллельно: если страница по прокрутке
|
||||||
|
// за концом нового списка, сервер отвечает на неё долго и без строк, а страница 0 уже даёт total.
|
||||||
|
const candidateController = new AbortController();
|
||||||
|
const zeroController = new AbortController();
|
||||||
|
const abortRequests = () => {
|
||||||
|
candidateController.abort();
|
||||||
|
zeroController.abort();
|
||||||
|
};
|
||||||
|
ctx.controller.signal.addEventListener('abort', abortRequests);
|
||||||
|
const settle = (request, index) =>
|
||||||
|
request.then(
|
||||||
|
(result) => ({ result, index }),
|
||||||
|
(error) => ({ error, index }),
|
||||||
|
);
|
||||||
|
const candidateRequest = settle(fetchPage(ctx, candidate, candidateController.signal), candidate);
|
||||||
|
const zeroRequest = candidate > 0 ? settle(fetchPage(ctx, 0, zeroController.signal), 0) : null;
|
||||||
|
let answer = zeroRequest ? await Promise.race([candidateRequest, zeroRequest]) : await candidateRequest;
|
||||||
|
if (
|
||||||
|
answer.index === 0 &&
|
||||||
|
candidate > 0 &&
|
||||||
|
!answer.error &&
|
||||||
|
shouldFetchFirstPageFallback({ candidate, total: answer.result.total })
|
||||||
|
) {
|
||||||
|
candidateController.abort();
|
||||||
|
} else {
|
||||||
|
zeroController.abort();
|
||||||
|
answer = await candidateRequest;
|
||||||
|
if (answer.error) throw answer.error;
|
||||||
|
}
|
||||||
|
if (ctx.controller.signal.aborted) return;
|
||||||
|
const first = answer.result;
|
||||||
|
const { total } = first;
|
||||||
|
const pages = getWindowFor(total);
|
||||||
|
const pageCount = getPageCount(total);
|
||||||
|
let cache = new Map();
|
||||||
|
if (total === 0 || answer.index < pageCount) {
|
||||||
|
const pageIndex = total === 0 ? 0 : answer.index;
|
||||||
|
dispatch({ type: 'page', sheet: sheetKey, generation, pageIndex, rows: first.rows, total, keepPages: pages });
|
||||||
|
cache = stateRef.current[sheetKey].pages;
|
||||||
|
}
|
||||||
|
ctx.ready = true;
|
||||||
|
const missing = getMissingPages(pages, cache, ctx.inflight);
|
||||||
|
await Promise.all(
|
||||||
|
missing.map(async (pageIndex) => {
|
||||||
|
try {
|
||||||
|
const result = await fetchPage(ctx, pageIndex);
|
||||||
|
if (ctx.controller.signal.aborted) return;
|
||||||
|
dispatch({
|
||||||
|
type: 'page',
|
||||||
|
sheet: sheetKey,
|
||||||
|
generation,
|
||||||
|
pageIndex,
|
||||||
|
rows: result.rows,
|
||||||
|
total: result.total,
|
||||||
|
keepPages: getWindowFor(result.total),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([loadFacets(), loadPages()]);
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: 'settled', sheet: sheetKey, generation });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[dispatch, fetchPage, getWindowFor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(() => start('load'), [start]);
|
||||||
|
|
||||||
|
// Догрузка недостающих страниц окна текущей вкладки в режиме scroll.
|
||||||
|
const syncWindow = useCallback(() => {
|
||||||
|
const current = propsRef.current;
|
||||||
|
setScrollPage(getRawPage(current.getScrollTop(), current.rowHeight));
|
||||||
|
const ctx = ctxRef.current[current.sheet];
|
||||||
|
const sheetState = stateRef.current[current.sheet];
|
||||||
|
if (!ctx || !ctx.ready || ctx.controller.signal.aborted) return;
|
||||||
|
if (sheetState.generation !== ctx.generation || sheetState.loadedQueryKey !== current.queryKey) return;
|
||||||
|
const pages = getWindowFor(sheetState.total);
|
||||||
|
for (const pageIndex of getMissingPages(
|
||||||
|
pages,
|
||||||
|
sheetState.dataGeneration === ctx.generation ? sheetState.pages : new Map(),
|
||||||
|
ctx.inflight,
|
||||||
|
)) {
|
||||||
|
fetchPage(ctx, pageIndex)
|
||||||
|
.then((result) => {
|
||||||
|
if (ctx.controller.signal.aborted) return;
|
||||||
|
dispatch({
|
||||||
|
type: 'page',
|
||||||
|
sheet: ctx.sheet,
|
||||||
|
generation: ctx.generation,
|
||||||
|
pageIndex,
|
||||||
|
rows: result.rows,
|
||||||
|
total: result.total,
|
||||||
|
keepPages: getWindowFor(result.total),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (isCanceled(error, ctx)) return;
|
||||||
|
toast.error(SCROLL_ERROR_MESSAGE, { toastId: 'svod2-scroll-error' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [dispatch, fetchPage, getWindowFor]);
|
||||||
|
|
||||||
|
const onScroll = useCallback(() => {
|
||||||
|
if (rafRef.current) return;
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
rafRef.current = 0;
|
||||||
|
if (mountedRef.current) syncWindow();
|
||||||
|
});
|
||||||
|
}, [syncWindow]);
|
||||||
|
|
||||||
|
// Смена поиска или колоночных фильтров на загруженных данных — уточнение (refine) теми же запросами.
|
||||||
|
const sheetState = state[sheet];
|
||||||
|
const hasCurrentData = sheetState.loadedQueryKey === queryKey;
|
||||||
|
const refineKey = makeRefineKey(search, columnFilters);
|
||||||
|
// Условия, на которых построены данные вкладки (или летящего поколения), хранятся в ctx: сравнение с ними, а не с прошлым рендером.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: start и поля sheetState читаются из актуального рендера
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
shouldRefine({
|
||||||
|
hasCurrentData,
|
||||||
|
loading: sheetState.loading,
|
||||||
|
pendingQueryKey: sheetState.pendingQueryKey,
|
||||||
|
queryKey,
|
||||||
|
dataRefineKey: ctxRef.current[sheet]?.refineKey,
|
||||||
|
refineKey,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
start('refine');
|
||||||
|
}, [sheet, refineKey, hasCurrentData, queryKey, sheetState.loading, sheetState.pendingQueryKey]);
|
||||||
|
|
||||||
|
// Смена вкладки или высоты строки — пересчёт окна страниц.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: syncWindow читает актуальные props из ref
|
||||||
|
useEffect(() => {
|
||||||
|
syncWindow();
|
||||||
|
}, [sheet, rowHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
const contexts = ctxRef.current;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = 0;
|
||||||
|
for (const ctx of Object.values(contexts)) ctx.controller.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visiblePage = getVisiblePage({ scrollTop: scrollPage * PAGE_SIZE * rowHeight, rowHeight, total: sheetState.total });
|
||||||
|
const windowPages = useMemo(() => getWindowPages(visiblePage, sheetState.total), [visiblePage, sheetState.total]);
|
||||||
|
const windowRows = useMemo(
|
||||||
|
() =>
|
||||||
|
buildWindowRows(
|
||||||
|
windowPages,
|
||||||
|
getCurrentPages({ pages: sheetState.pages, dataGeneration: sheetState.dataGeneration, generation: sheetState.generation }),
|
||||||
|
sheetState.total,
|
||||||
|
),
|
||||||
|
[windowPages, sheetState.pages, sheetState.dataGeneration, sheetState.generation, sheetState.total],
|
||||||
|
);
|
||||||
|
const padding = useMemo(() => {
|
||||||
|
if (!hasCurrentData) return NO_PADDING;
|
||||||
|
const { windowStart, windowEnd } = getWindowRange(windowPages, sheetState.total);
|
||||||
|
return getVirtualizerPadding({ windowStart, windowEnd, total: sheetState.total, rowHeight });
|
||||||
|
}, [hasCurrentData, windowPages, sheetState.total, rowHeight]);
|
||||||
|
const facetsMap = useMemo(() => facetsToMaps(sheetState.facets), [sheetState.facets]);
|
||||||
|
|
||||||
|
return { state: sheetState, windowRows, padding, facetsMap, load, onScroll };
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user