Skip to content

Commit 5c40afd

Browse files
authored
Merge pull request Junirezz#997 from ToryMic/fix/911-frontend-implement-loading-skeletons-and-empty-states-for-vault-pages
[911] Frontend: Implement loading skeletons and empty states for vault pages
2 parents 3a37b8e + daf92bc commit 5c40afd

14 files changed

Lines changed: 186 additions & 119 deletions

File tree

.github/workflows/docs.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ jobs:
1414
uses: actions/checkout@v4
1515

1616
- name: Setup Rust
17-
uses: dtolnay/rust-toolchain@stable
17+
uses: dtolnay/rust-toolchain@1.85.0
1818

1919
- name: Generate Rust docs
2020
run: |
2121
cargo doc -p share-price-math --no-deps
2222
cargo doc -p mock-strategy --no-deps
23+
continue-on-error: false

docs/VAULT_UX_PATTERN_LIBRARY.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,34 @@ Vault interactions are asynchronous and must distinguish between these states:
212212
- `stale`: the last known data is visible, but refresh freshness is degraded
213213
- `optimistic`: local cache reflects the expected result before server confirmation
214214

215+
### Loading skeletons (vault pages)
216+
217+
Use contextual skeletons from `frontend/src/components/Skeleton.tsx` instead of blank panels:
218+
219+
| Surface | Skeleton |
220+
| --- | --- |
221+
| Vault dashboard cards / stats | `DashboardCardSkeleton`, `VaultStatSkeleton`, `SharePriceSkeleton` |
222+
| Charts | `ChartSkeleton` |
223+
| Portfolio summary | `PortfolioCardSkeleton` |
224+
| Transaction rows / tables | `TransactionRowSkeleton`, `TableSkeleton` |
225+
| Analytics widgets | `AnalyticsWidgetSkeleton` |
226+
| Lazy route fallback | `RouteLoadingFallback` |
227+
228+
Rules:
229+
230+
- Mark loading regions with `aria-busy="true"` and keep `aria-hidden` on decorative skeleton chrome.
231+
- Prefer delayed loading (`useDelayedLoading`) for short fetches to avoid skeleton flash.
232+
- Do not show empty-state CTAs while `isLoading` is true.
233+
234+
### Empty states (vault pages)
235+
236+
Use `frontend/src/components/ui/EmptyState` with an explicit `kind`:
237+
238+
- `no-data` — wallet connected, no deposits/positions yet (offer deposit intent)
239+
- `no-results` / `search` — filters returned nothing (offer reset filters)
240+
- `permission` — wallet disconnected or access required
241+
- `error` — fetch failed after retries (offer retry)
242+
215243
Rules:
216244

217245
- Pending transactions must disable duplicate submission.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect } from "vitest";
2+
import { render } from "@testing-library/react";
3+
import Skeleton, {
4+
SkeletonBlock,
5+
SkeletonCircle,
6+
SkeletonText,
7+
TableSkeleton,
8+
DashboardCardSkeleton,
9+
ChartSkeleton,
10+
SharePriceSkeleton,
11+
VaultStatSkeleton,
12+
TransactionRowSkeleton,
13+
PortfolioCardSkeleton,
14+
AnalyticsWidgetSkeleton,
15+
} from "./Skeleton";
16+
17+
describe("Skeleton", () => {
18+
it("renders SkeletonBlock with aria-hidden", () => {
19+
const { container } = render(<SkeletonBlock width="100px" height="20px" />);
20+
const el = container.querySelector(".skeleton");
21+
expect(el).toBeTruthy();
22+
expect(el?.getAttribute("aria-hidden")).toBe("true");
23+
});
24+
25+
it("exports SkeletonBlock as default", () => {
26+
const { container } = render(<Skeleton width="50px" height="10px" />);
27+
expect(container.querySelector(".skeleton")).toBeTruthy();
28+
});
29+
30+
it("renders SkeletonCircle as a round block", () => {
31+
const { container } = render(<SkeletonCircle width={24} height={24} />);
32+
const el = container.querySelector(".skeleton") as HTMLElement;
33+
expect(el.style.borderRadius).toBe("50%");
34+
});
35+
36+
it("renders multi-line SkeletonText", () => {
37+
const { container } = render(<SkeletonText lines={3} />);
38+
expect(container.querySelectorAll(".skeleton")).toHaveLength(3);
39+
});
40+
41+
it("renders TableSkeleton with expected rows and columns", () => {
42+
const { container } = render(<TableSkeleton columns={3} rows={2} />);
43+
expect(container.querySelectorAll("tr.data-table-row")).toHaveLength(2);
44+
expect(container.querySelectorAll("td")).toHaveLength(6);
45+
});
46+
47+
it("renders vault page skeleton variants", () => {
48+
const variants = [
49+
<DashboardCardSkeleton key="dash" />,
50+
<ChartSkeleton key="chart" />,
51+
<SharePriceSkeleton key="share" />,
52+
<VaultStatSkeleton key="stat" />,
53+
<TransactionRowSkeleton key="tx" />,
54+
<PortfolioCardSkeleton key="port" />,
55+
<AnalyticsWidgetSkeleton key="analytics" />,
56+
];
57+
for (const variant of variants) {
58+
const { container, unmount } = render(variant);
59+
expect(container.querySelector(".skeleton")).toBeTruthy();
60+
unmount();
61+
}
62+
});
63+
});

frontend/src/context/ToastContext.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
7777
...options
7878
}: ToastOptions) => {
7979
const dedupeKey = generateDedupeKey({ ...options, variant });
80+
// Invoked from event handlers / async callbacks, not during render.
81+
// eslint-disable-next-line react-hooks/purity -- timestamp for toast dedupe window
8082
const now = Date.now();
8183

8284
// Check for duplicate within dedupe window

frontend/src/hooks/useAsyncActionButton.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function useAsyncActionButton({
3939
const [status, setStatus] = useState<AsyncActionStatus>("idle");
4040

4141
useEffect(() => {
42+
/* eslint-disable react-hooks/set-state-in-effect -- mirror external async flags into button status */
4243
if (isPending) {
4344
setStatus("pending");
4445
return;
@@ -56,6 +57,7 @@ export function useAsyncActionButton({
5657
if (!isPending && !isSuccess && !isError) {
5758
setStatus("idle");
5859
}
60+
/* eslint-enable react-hooks/set-state-in-effect */
5961
}, [isPending, isSuccess, isError, successResetMs, errorResetMs]);
6062

6163
const label = useMemo(() => {

frontend/src/hooks/useWalletHeartbeat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export function useWalletHeartbeat(
7676

7777
useEffect(() => {
7878
if (!walletAddress) {
79+
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset heartbeat when wallet disconnects
7980
setHeartbeat(INITIAL);
8081
return;
8182
}

frontend/src/lib/security.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ describe('escapeHtml', () => {
148148
expect(escapeHtml('<img src=x onerror=alert(1)>'))
149149
.toBe('&lt;img src=x onerror=alert(1)&gt;');
150150
expect(escapeHtml('Test & "quotes"'))
151-
.toBe('Test &amp; "quotes"');
151+
.toBe('Test &amp; &quot;quotes&quot;');
152152
});
153153

154154
test('preserves safe text', () => {

frontend/src/pages/Portfolio.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
22
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
33
import { describe, expect, it, vi, beforeEach } from "vitest";
44
import Portfolio from "./Portfolio";

frontend/src/pages/Portfolio.tsx

Lines changed: 75 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { useReferralStats, useReferralLink } from "../hooks/useReferral";
2929
import ShareModal from "../components/ShareModal";
3030
import EmptyState from "../components/ui/EmptyState";
3131
import FirstTimePortfolioPanel from "../components/FirstTimePortfolioPanel";
32+
import { PortfolioCardSkeleton } from "../components/Skeleton";
3233
import { useNavigate } from "react-router-dom";
3334
import { triggerDepositIntent } from "../lib/vaultIntentActions";
3435
import { formatCurrency, formatNumber, formatPercent } from "../lib/formatters";
@@ -413,67 +414,81 @@ const Portfolio: React.FC<PortfolioProps> = ({ walletAddress }) => {
413414
<div
414415
className="portfolio-summary-grid"
415416
style={{ marginBottom: "8px" }}
417+
aria-busy={isLoading || undefined}
416418
>
417-
<PortfolioSummaryCard
418-
label={t("portfolio.totalNetValue")}
419-
value={formatSensitiveCurrency(totalValue)}
420-
icon={<DollarSign size={20} color="var(--accent-cyan)" />}
421-
trend={totalNetValueTrend}
422-
trendPositive={totalGain >= 0}
423-
/>
424-
<PortfolioSummaryCard
425-
label={t("portfolio.cumulativeYield")}
426-
value={formatSensitiveCurrency(totalGain, true)}
427-
icon={<TrendingUp size={20} color="var(--accent-purple)" />}
428-
trend={cumulativeYieldTrend}
429-
trendPositive={totalGain >= 0}
430-
/>
431-
<PortfolioSummaryCard
432-
label={
433-
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
434-
Weighted Avg APY
435-
<HelpIcon
436-
variant="tooltip"
437-
content="The portfolio-value-weighted average of all active position APYs."
438-
/>
439-
</span>
440-
}
441-
value={formatPercent(weightedApy, {
442-
locale,
443-
minimumFractionDigits: 2,
444-
maximumFractionDigits: 2,
445-
})}
446-
icon={<Percent size={20} color="var(--accent-cyan)" />}
447-
trend={weightedApyTrend}
448-
trendPositive={true}
449-
/>
450-
<PortfolioSummaryCard
451-
label={t("portfolio.activePositions")}
452-
value={holdings.filter(h => h.status === 'active').length.toString()}
453-
icon={<Briefcase size={20} color="var(--text-secondary)" />}
454-
/>
455-
<PortfolioSummaryCard
456-
label={
457-
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
458-
Referral Earnings
459-
<HelpIcon
460-
variant="tooltip"
461-
content={t("portfolio.referralTooltip")}
462-
/>
463-
</span>
464-
}
465-
value={referralStats ? `$${referralStats.total_reward_earned}` : "$0.00"}
466-
icon={<TrendingUp size={20} color="var(--accent-green)" />}
467-
trend={referralStats ? `${referralStats.referral_count} referral${referralStats.referral_count !== 1 ? 's' : ''}` : "0 referrals"}
468-
trendPositive={true}
469-
/>
470-
<PortfolioSummaryCard
471-
label={t("portfolio.shareReferralLink")}
472-
value=""
473-
icon={<Share2 size={20} color="var(--accent-cyan)" />}
474-
onClick={() => setShowShareModal(true)}
475-
clickable={true}
476-
/>
419+
{isLoading ? (
420+
<>
421+
<PortfolioCardSkeleton />
422+
<PortfolioCardSkeleton />
423+
<PortfolioCardSkeleton />
424+
<PortfolioCardSkeleton />
425+
<PortfolioCardSkeleton />
426+
<PortfolioCardSkeleton />
427+
</>
428+
) : (
429+
<>
430+
<PortfolioSummaryCard
431+
label={t("portfolio.totalNetValue")}
432+
value={formatSensitiveCurrency(totalValue)}
433+
icon={<DollarSign size={20} color="var(--accent-cyan)" />}
434+
trend={totalNetValueTrend}
435+
trendPositive={totalGain >= 0}
436+
/>
437+
<PortfolioSummaryCard
438+
label={t("portfolio.cumulativeYield")}
439+
value={formatSensitiveCurrency(totalGain, true)}
440+
icon={<TrendingUp size={20} color="var(--accent-purple)" />}
441+
trend={cumulativeYieldTrend}
442+
trendPositive={totalGain >= 0}
443+
/>
444+
<PortfolioSummaryCard
445+
label={
446+
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
447+
Weighted Avg APY
448+
<HelpIcon
449+
variant="tooltip"
450+
content="The portfolio-value-weighted average of all active position APYs."
451+
/>
452+
</span>
453+
}
454+
value={formatPercent(weightedApy, {
455+
locale,
456+
minimumFractionDigits: 2,
457+
maximumFractionDigits: 2,
458+
})}
459+
icon={<Percent size={20} color="var(--accent-cyan)" />}
460+
trend={weightedApyTrend}
461+
trendPositive={true}
462+
/>
463+
<PortfolioSummaryCard
464+
label={t("portfolio.activePositions")}
465+
value={holdings.filter(h => h.status === 'active').length.toString()}
466+
icon={<Briefcase size={20} color="var(--text-secondary)" />}
467+
/>
468+
<PortfolioSummaryCard
469+
label={
470+
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
471+
Referral Earnings
472+
<HelpIcon
473+
variant="tooltip"
474+
content={t("portfolio.referralTooltip")}
475+
/>
476+
</span>
477+
}
478+
value={referralStats ? `$${referralStats.total_reward_earned}` : "$0.00"}
479+
icon={<TrendingUp size={20} color="var(--accent-green)" />}
480+
trend={referralStats ? `${referralStats.referral_count} referral${referralStats.referral_count !== 1 ? 's' : ''}` : "0 referrals"}
481+
trendPositive={true}
482+
/>
483+
<PortfolioSummaryCard
484+
label={t("portfolio.shareReferralLink")}
485+
value=""
486+
icon={<Share2 size={20} color="var(--accent-cyan)" />}
487+
onClick={() => setShowShareModal(true)}
488+
clickable={true}
489+
/>
490+
</>
491+
)}
477492
</div>
478493

479494
<YieldBreakdownChart totalGain={totalGain} />

frontend/src/pages/TransactionHistory.test.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
22
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3-
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
3+
import { MemoryRouter } from "react-router-dom";
44
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
55
import TransactionHistory from "./TransactionHistory";
66
import * as transactionApi from "../lib/transactionApi";
@@ -66,11 +66,6 @@ function makeManyTransactions(count: number): Transaction[] {
6666
);
6767
}
6868

69-
function UrlProbe() {
70-
const [params] = useSearchParams();
71-
return <div data-testid="url-probe">{params.toString()}</div>;
72-
}
73-
7469
function renderPage(walletAddress: string | null, initialEntries = ["/"]) {
7570
const queryClient = new QueryClient({
7671
defaultOptions: { queries: { retry: false } },

0 commit comments

Comments
 (0)