codex/pagination-loading-indicator #20

Merged
ksv741 merged 6 commits from codex/pagination-loading-indicator into main 2026-06-18 07:29:36 +03:00
6 changed files with 313 additions and 107 deletions

View File

@ -122,6 +122,7 @@ export function BrokerAccountDetailPage() {
<BrokerOperationsTable
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
pageNumber={operationCursorStack.length + 1}
canGoBack={operationCursorStack.length > 0}

View File

@ -95,6 +95,7 @@ const pagButtonDisabledStyle = {
export function BrokerOperationsTable({
isLoading,
isFetching,
page,
pageNumber,
canGoBack,
@ -103,6 +104,7 @@ export function BrokerOperationsTable({
onNext,
}: {
isLoading: boolean;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pageNumber: number;
canGoBack: boolean;
@ -128,10 +130,17 @@ export function BrokerOperationsTable({
<button
type="button"
onClick={onPrevious}
disabled={!canGoBack}
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? (
<span
className="loading-spinner"
style={{ width: 14, height: 14, display: 'block' }}
/>
) : (
'←'
)}
</button>
<span
style={{
@ -147,10 +156,17 @@ export function BrokerOperationsTable({
<button
type="button"
onClick={onNext}
disabled={!canGoForward}
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? (
<span
className="loading-spinner"
style={{ width: 14, height: 14, display: 'block' }}
/>
) : (
'→'
)}
</button>
</div>
</div>
@ -180,51 +196,60 @@ export function BrokerOperationsTable({
/>
</table>
</div>
) : operations.length === 0 ? (
) : operations.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Дата
</th>
<th align="left" style={thStyle}>
Тип
</th>
<th align="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => {
const impact = getBrokerOperationImpact(operation);
return (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}>
<span>{getBrokerOperationTypeLabel(operation)}</span>
</td>
<td style={tdStyle}>
<OperationInstrument operation={operation} />
</td>
<td
align="right"
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
>
{formatMoney(operation.payment)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Дата
</th>
<th align="left" style={thStyle}>
Тип
</th>
<th align="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => {
const impact = getBrokerOperationImpact(operation);
return (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}>
<span>{getBrokerOperationTypeLabel(operation)}</span>
</td>
<td style={tdStyle}>
<OperationInstrument operation={operation} />
</td>
<td
align="right"
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
>
{formatMoney(operation.payment)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{isFetching && (
<div className="table-loading-overlay">
<div className="loading-spinner" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber}
</span>
</div>
)}
</div>
)}
</section>

View File

@ -56,6 +56,7 @@ function mockUseBrokerPositions(...positions: BrokerPosition[]) {
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any;
});
@ -83,6 +84,7 @@ describe('Broker pages', () => {
},
],
isLoading: false,
isFetching: false,
error: null,
} as any);
@ -110,6 +112,7 @@ describe('Broker pages', () => {
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
@ -145,6 +148,7 @@ describe('Broker pages', () => {
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
mockUseBrokerPositions(
@ -188,6 +192,7 @@ describe('Broker pages', () => {
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
@ -199,6 +204,7 @@ describe('Broker pages', () => {
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
mockUseBrokerPositions(
@ -264,6 +270,7 @@ describe('Broker pages', () => {
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
@ -322,6 +329,7 @@ describe('Broker pages', () => {
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
mockUseBrokerPositions();
@ -361,6 +369,7 @@ describe('Broker pages', () => {
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
@ -431,6 +440,7 @@ describe('Broker pages', () => {
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
}) as any,
);

View File

@ -100,7 +100,7 @@ function PositionGroupTable({
const [cursor, setCursor] = useState<string | undefined>(undefined);
const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor };
const { data: page, isLoading } = useBrokerPositions(accountId, query);
const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query);
const rawPositions = page?.items ?? [];
const positions = group.type
@ -148,10 +148,17 @@ function PositionGroupTable({
<button
type="button"
onClick={handlePrevious}
disabled={!canGoBack}
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? (
<span
className="loading-spinner"
style={{ width: 14, height: 14, display: 'block' }}
/>
) : (
'←'
)}
</button>
<span
style={{
@ -167,10 +174,17 @@ function PositionGroupTable({
<button
type="button"
onClick={handleNext}
disabled={!canGoForward}
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? (
<span
className="loading-spinner"
style={{ width: 14, height: 14, display: 'block' }}
/>
) : (
'→'
)}
</button>
</div>
)}
@ -213,58 +227,68 @@ function PositionGroupTable({
)}
{!isLoading && positions.length > 0 && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Тикер
</th>
<th align="left" style={thStyle}>
Название
</th>
<th align="right" style={thStyle}>
Количество
</th>
<th align="right" style={thStyle}>
Цена
</th>
<th align="right" style={thStyle}>
Стоимость
</th>
</tr>
</thead>
<tbody>
{positions.map((position) => (
<tr
key={
position.positionUid ||
position.instrumentUid ||
position.ticker ||
position.figi
}
>
<td style={tdStyle}>
<PositionTicker position={position} />
</td>
<td style={tdStyle}>
<span style={{ color: 'var(--color-text-secondary)' }}>
{position.name || '-'}
</span>
</td>
<td align="right" style={tdStyle}>
{formatQuantity(position.quantity)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentPrice)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentValue)}
</td>
<div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Тикер
</th>
<th align="left" style={thStyle}>
Название
</th>
<th align="right" style={thStyle}>
Количество
</th>
<th align="right" style={thStyle}>
Цена
</th>
<th align="right" style={thStyle}>
Стоимость
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{positions.map((position) => (
<tr
key={
position.positionUid ||
position.instrumentUid ||
position.ticker ||
position.figi
}
>
<td style={tdStyle}>
<PositionTicker position={position} />
</td>
<td style={tdStyle}>
<span style={{ color: 'var(--color-text-secondary)' }}>
{position.name || '-'}
</span>
</td>
<td align="right" style={tdStyle}>
{formatQuantity(position.quantity)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentPrice)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentValue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{isFetching && (
<div className="table-loading-overlay">
<div className="loading-spinner" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber}
</span>
</div>
)}
</div>
)}
</section>

View File

@ -50,3 +50,33 @@ a {
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 4px;
}
@keyframes loading-spin {
to { transform: rotate(360deg); }
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-bg);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
.table-container {
position: relative;
}
.table-loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.65);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
transition: opacity 0.2s ease;
z-index: 1;
}

View File

@ -0,0 +1,116 @@
# Индикация загрузки при переключении страниц в таблицах брокера
Дата: 2026-06-18
Статус: черновик
## Контекст
Страница детального просмотра брокерского счёта (`BrokerAccountDetailPage.tsx`) содержит несколько таблиц с пагинацией:
- **PositionGroupTable** — Акции, Облигации, ETF, Фонды (4 независимые таблицы с курсорной пагинацией)
- **BrokerOperationsTable** — Операции (курсорная пагинация, управляемая из родительского компонента)
Текущее поведение при переключении страниц: `isLoading === true` → таблица скрывается, показывается `TableSkeleton` (shimmer-строки). Это создаёт визуальный flash: контент исчезает → скелетон → новые данные. При этом `placeholderData: keepPreviousData` уже настроен в хуках, но компоненты его не используют — они проверяют `isLoading`, а не `data`.
## Цель
Добавить плавную индикацию загрузки при переключении страниц, чтобы пользователь видел, что данные обновляются, но не терял визуальный контекст.
## Дизайн (выбран C3)
### Визуальное поведение
1. При нажатии «→» (вперед) или «←» (назад):
- Текущее содержимое таблицы **остаётся видимым** (предыдущая страница)
- Поверх таблицы появляется **полупрозрачный overlay** с центрированным спиннером
- Кнопка пагинации показывает спиннер и блокируется
2. Когда новые данные загружены:
- Overlay исчезает с fade-out
- Таблица обновляется новыми данными
3. При первой загрузке (initial load):
- Overlay не используется (нет старых данных для показа)
- Показывается `TableSkeleton` (как сейчас)
### Как это работает технически
TanStack Query v5 предоставляет два флага:
- `isLoading` — true, когда данных **нет** и идёт первый запрос (initial load)
- `isFetching` — true при любом запросе (включая фоновые refetch'и при смене cursor)
Логика рендеринга для таблиц:
```
if isLoading → TableSkeleton (первая загрузка, данных нет)
if isFetching && data → TableLoadingOverlay + старые данные (переключение страниц)
иначе → рендер таблицы с данными
```
### Компонент TableLoadingOverlay
```tsx
interface TableLoadingOverlayProps {
pageNumber?: number;
}
function TableLoadingOverlay({ pageNumber }: TableLoadingOverlayProps) {
return (
<div style={{
position: 'absolute', inset: 0,
background: 'rgba(255,255,255,0.65)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 12,
transition: 'opacity 0.2s ease',
}}>
<div className="loading-spinner" />
{pageNumber !== undefined && (
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber}…
</span>
)}
</div>
);
}
```
### Пагинация: спиннер в кнопке
При `isFetching` кнопка «→» или «←» показывает спиннер вместо стрелки и становится disabled.
```css
@keyframes loading-spin {
to { transform: rotate(360deg); }
}
.loading-spinner {
width: 20px; height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
```
## Где применяется
| Компонент | Что меняется |
|---|---|
| `BrokerPositionsSection.tsx` (PositionGroupTable) | Overlay вместо TableSkeleton при isFetching. Спиннер в кнопках пагинации |
| `BrokerOperationsTable.tsx` | Overlay вместо TableSkeleton при isFetching. Спиннер в кнопках пагинации |
## Файлы для изменения
| Файл | Изменение |
|---|---|
| `apps/frontend/src/styles.css` | Добавить `@keyframes loading-spin`, `.loading-spinner`, `.table-loading-overlay` |
| `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` | Overlay + спиннер в пагинации. Использовать `isFetching` из хука |
| `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` | Overlay + спиннер в пагинации. Использовать `isFetching` из хука |
| `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` | Прокинуть `isFetching` для операций (из useBrokerOperations) |
| `apps/frontend/src/pages/broker/BrokerPages.test.tsx` | Обновить тесты для overlay-логики |
## Тестирование
- `npm run test:frontend` — существующие тесты проходят с учётом изменений
- Ручная проверка: переключение страниц в Акциях, Облигациях, Операциях — overlay появляется/исчезает
- Ручная проверка: при первой загрузке — skeleton (не overlay)
- Ручная проверка: при быстром переключении (быстрее, чем загрузка) — overlay остаётся, данные не мигают