15 KiB
Pagination Loading Overlay — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add overlay + spinner to all broker paginated tables when switching pages
Architecture: Use TanStack Query's isFetching (any fetch) vs isLoading (initial fetch) to show overlay when data exists and a new page is loading. Keep keepPreviousData so old data stays visible under the overlay. Add CSS spinner animation.
Tech Stack: React, TanStack Query v5, CSS custom properties
Task 1: CSS — spinner animation and overlay styles
Files:
-
Modify:
apps/frontend/src/styles.css -
Step 1: Add spinner keyframes and loading-spinner class
Add to apps/frontend/src/styles.css at the end:
@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;
}
- Step 2: Commit
git add apps/frontend/src/styles.css
git commit -m "style: add loading-spinner and overlay CSS classes"
Task 2: PositionGroupTable — overlay on pagination + spinner in buttons
Files:
-
Modify:
apps/frontend/src/pages/broker/BrokerPositionsSection.tsx -
Step 1: Add
isFetchingto the query destructuring
Line 103 changes from:
const { data: page, isLoading } = useBrokerPositions(accountId, query);
to:
const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query);
- Step 2: Replace the loading rendering section
Current (lines 179-213):
{isLoading && (
<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="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<TableSkeleton rows={4} columns={[
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
]} />
</table>
</div>
)}
{!isLoading && positions.length > 0 && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
...
</table>
</div>
)}
Replace with new rendering logic:
{isLoading && (
<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="right" style={thStyle}>Количество</th>
<th align="right" style={thStyle}>Цена</th>
<th align="right" style={thStyle}>Стоимость</th>
</tr>
</thead>
<TableSkeleton rows={4} columns={[
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
]} />
</table>
</div>
)}
{!isLoading && positions.length > 0 && (
<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>
</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>
)}
- Step 3: Update pagination buttons to show spinner during fetch
Replace the button content in lines 167-174 (the "→" button):
<button
type="button"
onClick={handleNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
</button>
Also update the "←" button (lines 148-155):
<button
type="button"
onClick={handlePrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
</button>
- Step 4: Commit
git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
git commit -m "feat: add loading overlay and spinner to PositionGroupTable"
Task 3: BrokerOperationsTable — new isFetching prop + overlay
Files:
-
Modify:
apps/frontend/src/pages/broker/BrokerOperationsTable.tsx -
Step 1: Add
isFetchingto props interface
Change the component props destructuring (line 96-113):
export function BrokerOperationsTable({
isLoading,
isFetching,
page,
pageNumber,
canGoBack,
canGoForward,
onPrevious,
onNext,
}: {
isLoading: boolean;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
}) {
- Step 2: Replace the loading/empty/data rendering
Current (lines 158-229):
{isLoading ? (
<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>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
</table>
</div>
) : operations.length === 0 ? (
<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>
)}
Replace with:
{isLoading ? (
<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>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
</table>
</div>
) : operations.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : (
<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>
)}
Note: The empty state check changed from operations.length === 0 to operations.length === 0 && !isFetching — this ensures the overlay shows on top of old data, not the empty message.
- Step 3: Update pagination buttons
Replace line 132-135 (← button):
<button
type="button"
onClick={onPrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
</button>
Replace lines 147-154 (→ button):
<button
type="button"
onClick={onNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
</button>
- Step 4: Commit
git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
git commit -m "feat: add loading overlay and spinner to BrokerOperationsTable"
Task 4: BrokerAccountDetailPage — pass isFetching to operations table
Files:
-
Modify:
apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx -
Step 1: Add
isFetchingto the BrokerOperationsTable props
Change the <BrokerOperationsTable> call (line 123-131):
<BrokerOperationsTable
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
pageNumber={operationCursorStack.length + 1}
canGoBack={operationCursorStack.length > 0}
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
onPrevious={handlePreviousOperationsPage}
onNext={handleNextOperationsPage}
/>
- Step 2: Commit
git add apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
git commit -m "feat: pass isFetching to BrokerOperationsTable"
Task 5: Update tests
Files:
-
Modify:
apps/frontend/src/pages/broker/BrokerPages.test.tsx -
Step 1: Add
isFetching: falseto all existing position mocks
In mockUseBrokerPositions (line 47-61), add isFetching: false:
return {
data: {
accountId: 'acc-1',
items: filtered,
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any;
- Step 2: Add
isFetching: falseto all operations mocks
Add isFetching: false alongside each isLoading: false in the operations mocks (lines 58, 85, 112, 147, 190, 201, 266, 324, 363, 433).
For example, line 112 area becomes:
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-17T00:00:00.000Z' },
isLoading: false,
isFetching: false,
- Step 3: Verify tests pass
npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend
Expected: All tests PASS.
- Step 4: Commit
git add apps/frontend/src/pages/broker/BrokerPages.test.tsx
git commit -m "test: add isFetching to mock return values"
Task 6: Lint and final verification
- Step 1: Run lint
npm run lint
Expected: No errors (or only pre-existing ones).
- Step 2: Run full frontend test suite
npm run test:frontend
Expected: All tests pass.
- Step 3: Run typecheck
npx tsc -b apps/frontend
Expected: No type errors.