diff --git a/docs/UI-PATTERN.md b/docs/UI-PATTERN.md index de0eacc..2b5013c 100644 --- a/docs/UI-PATTERN.md +++ b/docs/UI-PATTERN.md @@ -4,6 +4,6 @@ Во время submit все действия формы блокируются. Ошибки Zod отображаются у поля; ошибки сети сохраняют введённые значения и показываются отдельным `Alert`. -Запросные экраны выбирают состояние в порядке loading, error, empty, loaded. Состояния имеют отдельные компоненты, если повторяются или содержат самостоятельную разметку. +Запросные экраны без успешных данных выбирают состояние в порядке loading, error, empty, loaded. Если query уже содержит данные, они остаются видимыми при фоновом refetch и его ошибке: рядом показываются неблокирующий progress и локальное предупреждение с retry. Такое состояние называется Client Cached Snapshot; оно не заменяет доменное server-side stale/fallback состояние. Для finance-экранов container вызывает query hooks, VM выбирает состояние и передает только presentation props в Loading/Error/Empty/Loaded view. Known API errors, retry и stale snapshot остаются local views, а непойманные route errors обрабатывает общий route-keyed ErrorBoundary. diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index 19158a4..bf7458d 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -34,4 +34,58 @@ describe("AuthProvider", () => { expect(authorization).toBe("Bearer restored-token"); }); + + it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => { + server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error())); + const queryClient = new QueryClient(); + queryClient.setQueryData(["cached-query"], { value: "stale" }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => expect(result.current.ready).toBe(true)); + + expect(result.current.user).toBeNull(); + expect(result.current.accessToken).toBeNull(); + expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); + }); + + it("keeps the original protected 401 when serialized refresh has a transport error", async () => { + let refreshCalls = 0; + server.use( + http.post("*/api/v1/auth/refresh", () => { + refreshCalls += 1; + return refreshCalls === 1 + ? HttpResponse.json({ + accessToken: "restored-token", + user: { id: "user-1", email: "user@example.com" }, + }) + : HttpResponse.error(); + }), + http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), + ), + ); + const queryClient = new QueryClient(); + queryClient.setQueryData(["cached-query"], { value: "stale" }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => + expect(result.current.accessToken).toBe("restored-token"), + ); + const response = await apiClient.GET("/api/v1/brokerage-accounts"); + + expect(response.response.status).toBe(401); + await waitFor(() => expect(result.current.user).toBeNull()); + expect(result.current.accessToken).toBeNull(); + expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); + }); }); diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx index 88c9829..1b6b17c 100644 --- a/src/features/auth/model/auth.tsx +++ b/src/features/auth/model/auth.tsx @@ -1,5 +1,12 @@ import { useQueryClient } from "@tanstack/react-query"; -import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; import { apiClient, configureAuthClient } from "@/shared/api"; import type { paths } from "@/shared/api/generated/schema"; @@ -41,40 +48,52 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [ready, setReady] = useState(false); const refreshPromise = useRef | null>(null); + const applyAuthentication = useCallback((authentication: Authentication) => { + setUser(authentication.user); + setAccessToken(authentication.accessToken); + accessTokenRef.current = authentication.accessToken; + }, []); + const clearSession = useCallback(() => { + setUser(null); + setAccessToken(null); + accessTokenRef.current = null; + queryClient.clear(); + }, [queryClient]); - async function refresh(): Promise { + const refresh = useCallback(async (): Promise => { if (refreshPromise.current) return refreshPromise.current; refreshPromise.current = (async () => { const run = async () => { - const { data } = await apiClient.POST("/api/v1/auth/refresh"); - if (!data) return false; - const authentication = data as Authentication; - setUser(authentication.user); - setAccessToken(authentication.accessToken); - accessTokenRef.current = authentication.accessToken; - return true; + try { + const { data } = await apiClient.POST("/api/v1/auth/refresh"); + if (!data) { + clearSession(); + return false; + } + applyAuthentication(data as Authentication); + return true; + } catch { + clearSession(); + return false; + } }; - return "locks" in navigator - ? navigator.locks.request("wealth-ledger-refresh", run) - : run(); + try { + return "locks" in navigator + ? navigator.locks.request("wealth-ledger-refresh", run) + : run(); + } catch { + clearSession(); + return false; + } })().finally(() => { refreshPromise.current = null; }); return refreshPromise.current; - } + }, [applyAuthentication, clearSession]); useEffect(() => { - void apiClient - .POST("/api/v1/auth/refresh") - .then(({ data }) => { - if (!data) return; - const authentication = data as Authentication; - setUser(authentication.user); - setAccessToken(authentication.accessToken); - accessTokenRef.current = authentication.accessToken; - }) - .finally(() => setReady(true)); - }, []); + void refresh().finally(() => setReady(true)); + }, [refresh]); async function authenticate( path: "/api/v1/auth/login" | "/api/v1/auth/register", @@ -82,19 +101,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { password: string, ) { const authentication = await request(path, email, password); - setUser(authentication.user); - setAccessToken(authentication.accessToken); - accessTokenRef.current = authentication.accessToken; + applyAuthentication(authentication); } async function logout() { try { await apiClient.POST("/api/v1/auth/logout"); } finally { - setUser(null); - setAccessToken(null); - accessTokenRef.current = null; - queryClient.clear(); + clearSession(); } } @@ -102,12 +116,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { configureAuthClient({ accessToken: () => accessTokenRef.current, refresh, - clear: () => { - setUser(null); - setAccessToken(null); - accessTokenRef.current = null; - queryClient.clear(); - }, + clear: clearSession, }); return () => configureAuthClient(null); }); diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index 8c445d4..751c125 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -2,17 +2,22 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import { MemoryRouter } from "react-router"; +import { MemoryRouter, Route, Routes } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Portfolio } from "@/entities/portfolio"; import { server } from "@/test/server"; import { + AccountPage, AccountPageEmpty, AccountPageError, AccountPageLoaded, AccountPageLoading, } from "./AccountPage"; +vi.mock("@/features/auth", () => ({ + useAuth: () => ({ accessToken: "test-token" }), +})); + function renderPage(ui: React.ReactNode) { return render( @@ -23,6 +28,20 @@ function renderPage(ui: React.ReactNode) { ); } +function renderAccountRoute(queryClient: QueryClient) { + return render( + + + + + } /> + + + + , + ); +} + const portfolio: Portfolio = { lastSuccessfulAt: "2026-08-13T10:00:00Z", isStale: true, @@ -169,4 +188,68 @@ describe("AccountPage states", () => { ); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); + + it("keeps the cached portfolio visible with distinct server and refresh warnings", async () => { + let requestCount = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { + requestCount += 1; + if (requestCount === 1) return HttpResponse.json(portfolio); + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + }), + ); + const queryClient = new QueryClient(); + renderAccountRoute(queryClient); + + await screen.findByText("Вторая"); + void queryClient.invalidateQueries({ + queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"], + }); + + expect( + await screen.findByText( + "Не удалось обновить портфель. Показан последний полученный снимок.", + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + "T-Bank временно недоступен. Показан последний успешный снимок.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Вторая")).toBeInTheDocument(); + screen.getByRole("button", { name: "Повторить" }).click(); + await waitFor(() => expect(requestCount).toBe(3)); + }); + + it("keeps the cached portfolio visible during a background refresh", async () => { + let requestCount = 0; + let resolveRefresh: ((response: Response) => void) | undefined; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { + requestCount += 1; + if (requestCount === 1) return HttpResponse.json(portfolio); + return new Promise((resolve) => { + resolveRefresh = resolve; + }); + }), + ); + const queryClient = new QueryClient(); + renderAccountRoute(queryClient); + + await screen.findByText("Вторая"); + void queryClient.invalidateQueries({ + queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"], + }); + + await waitFor(() => expect(requestCount).toBe(2)); + expect(screen.getByText("Вторая")).toBeInTheDocument(); + expect( + screen.getByRole("status", { name: "Обновляем портфель" }), + ).toBeInTheDocument(); + + resolveRefresh?.(HttpResponse.json(portfolio)); + }); }); diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx index 4f36ea6..cb2abbc 100644 --- a/src/pages/account/ui/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage.tsx @@ -16,6 +16,9 @@ export function AccountPage() { account={viewModel.account} accountId={viewModel.accountId} accessToken={viewModel.accessToken} + refreshing={viewModel.refreshing} + refreshError={viewModel.refreshError} + retry={viewModel.retry} /> ); return ( @@ -24,6 +27,9 @@ export function AccountPage() { accountId={viewModel.accountId} accessToken={viewModel.accessToken} portfolio={viewModel.portfolio} + refreshing={viewModel.refreshing} + refreshError={viewModel.refreshError} + retry={viewModel.retry} /> ); } diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx index 1fd0366..758280c 100644 --- a/src/pages/account/ui/AccountPage.view.tsx +++ b/src/pages/account/ui/AccountPage.view.tsx @@ -3,6 +3,7 @@ import { Button, Card, Group, + Loader, Paper, SimpleGrid, Stack, @@ -93,15 +94,26 @@ export function AccountPageEmpty({ account, accountId, accessToken = null, + refreshing = false, + refreshError = false, + retry, }: { account?: BrokerageAccount; accountId?: string; accessToken?: string | null; + refreshing?: boolean; + refreshError?: boolean; + retry?: () => void; }) { const { t } = useTranslation(); return ( + void; }) { const { t } = useTranslation(); const stocks = portfolio.positions.filter((p) => p.type === "STOCK"); @@ -140,6 +158,11 @@ export function AccountPageLoaded({ return ( + {portfolio.isStale ? ( {t("portfolio.staleWarning")} ) : null} @@ -197,6 +220,40 @@ export function AccountPageLoaded({ ); } +function PortfolioRefreshState({ + refreshing, + refreshError, + retry, +}: { + refreshing: boolean; + refreshError: boolean; + retry?: () => void; +}) { + const { t } = useTranslation(); + if (!refreshing && !refreshError) return null; + return ( + <> + {refreshing ? ( + + + + {t("portfolio.refreshing")} + + + ) : null} + {refreshError ? ( + + + {t("portfolio.refreshError")} + + + + ) : null} + + ); +} function RecentOperations({ accountId, accessToken, diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index 7930afe..88703fd 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -14,21 +14,34 @@ export function useAccountPageViewModel() { const account = useQueryClient() .getQueryData(brokerageAccountsQueryKey) ?.find((item) => item.id === accountId); - if (portfolio.isError) - return { - state: "error" as const, - code: portfolioErrorCode(portfolio.error), - retry: () => portfolio.refetch(), - }; - if (portfolio.isFetching || !portfolio.data) + if (!portfolio.data) { + if (portfolio.isError) + return { + state: "error" as const, + code: portfolioErrorCode(portfolio.error), + retry: () => portfolio.refetch(), + }; return { state: "loading" as const }; + } + const refresh = { + refreshing: portfolio.isFetching, + refreshError: portfolio.isError, + retry: () => portfolio.refetch(), + }; if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length) - return { state: "empty" as const, account, accountId, accessToken }; + return { + state: "empty" as const, + account, + accountId, + accessToken, + ...refresh, + }; return { state: "loaded" as const, account, accountId, accessToken, portfolio: portfolio.data, + ...refresh, }; } diff --git a/src/pages/accounts/ui/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage.test.tsx new file mode 100644 index 0000000..40644f3 --- /dev/null +++ b/src/pages/accounts/ui/AccountsPage.test.tsx @@ -0,0 +1,125 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { MemoryRouter } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { brokerageAccountsQueryKey } from "@/entities/brokerage-account"; +import { server } from "@/test/server"; +import { AccountsPage } from "./AccountsPage"; + +vi.mock("@/features/auth", () => ({ + useAuth: () => ({ accessToken: "test-token" }), +})); + +const account = { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2026-01-01", +} as const; + +function renderPage(queryClient: QueryClient) { + return render( + + + + + + + , + ); +} + +describe("AccountsPage", () => { + afterEach(cleanup); + + it("keeps the cached account list visible during a background refresh", async () => { + let requestCount = 0; + let resolveRefresh: ((response: Response) => void) | undefined; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requestCount += 1; + if (requestCount === 1) return HttpResponse.json([account]); + return new Promise((resolve) => { + resolveRefresh = resolve; + }); + }), + ); + const queryClient = new QueryClient(); + renderPage(queryClient); + + await screen.findByText(account.name); + void queryClient.invalidateQueries({ + queryKey: brokerageAccountsQueryKey, + }); + + await waitFor(() => expect(requestCount).toBe(2)); + expect(screen.getByText(account.name)).toBeInTheDocument(); + expect( + screen.getByRole("status", { name: "Обновляем данные" }), + ).toBeInTheDocument(); + + resolveRefresh?.(HttpResponse.json([account])); + }); + + it("keeps the cached account list and offers retry after a refresh error", async () => { + let requestCount = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requestCount += 1; + if (requestCount === 1) return HttpResponse.json([account]); + if (requestCount === 2) + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + return HttpResponse.json([account]); + }), + ); + const queryClient = new QueryClient(); + renderPage(queryClient); + + await screen.findByText(account.name); + void queryClient.invalidateQueries({ + queryKey: brokerageAccountsQueryKey, + }); + + const warning = await screen.findByText( + "Не удалось обновить данные. Показаны последние полученные.", + ); + expect(warning).toBeInTheDocument(); + expect(screen.getByText(account.name)).toBeInTheDocument(); + + screen.getByRole("button", { name: "Повторить" }).click(); + await waitFor(() => expect(requestCount).toBe(3)); + }); + + it("keeps a cached empty account list visible after a refresh error", async () => { + let requestCount = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requestCount += 1; + if (requestCount === 1) return HttpResponse.json([]); + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + }), + ); + const queryClient = new QueryClient(); + renderPage(queryClient); + + await screen.findByText("Нет инвестиционных счетов"); + void queryClient.invalidateQueries({ + queryKey: brokerageAccountsQueryKey, + }); + + expect( + await screen.findByText( + "Не удалось обновить данные. Показаны последние полученные.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/accounts/ui/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage.tsx index 2ca3de0..965a608 100644 --- a/src/pages/accounts/ui/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage.tsx @@ -10,8 +10,22 @@ export function AccountsPage() { const viewModel = useAccountsPageViewModel(); if (viewModel.state === "loading") return ; if (viewModel.state === "error") return ; - if (viewModel.state === "empty") return ; - return ; + if (viewModel.state === "empty") + return ( + + ); + return ( + + ); } export { diff --git a/src/pages/accounts/ui/AccountsPage.view.tsx b/src/pages/accounts/ui/AccountsPage.view.tsx index fdefe56..2927320 100644 --- a/src/pages/accounts/ui/AccountsPage.view.tsx +++ b/src/pages/accounts/ui/AccountsPage.view.tsx @@ -1,4 +1,12 @@ -import { Button, Stack, Title } from "@mantine/core"; +import { + Alert, + Button, + Group, + Loader, + Stack, + Text, + Title, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { BrokerageAccount } from "@/entities/brokerage-account"; @@ -71,26 +79,87 @@ export function AccountsPageError({ ); } -export function AccountsPageEmpty() { +export function AccountsPageEmpty({ + refreshing = false, + refreshError = false, + retry, +}: { + refreshing?: boolean; + refreshError?: boolean; + retry?: () => void; +}) { const { t } = useTranslation(); return ( - + + + + ); } export function AccountsPageLoaded({ accounts, + refreshing = false, + refreshError = false, + retry, }: { accounts: BrokerageAccount[]; + refreshing?: boolean; + refreshError?: boolean; + retry?: () => void; }) { const { t } = useTranslation(); return ( {t("accounts.title")} + ); } + +function AccountsRefreshState({ + refreshing, + refreshError, + retry, +}: { + refreshing: boolean; + refreshError: boolean; + retry?: () => void; +}) { + const { t } = useTranslation(); + if (!refreshing && !refreshError) return null; + return ( + <> + {refreshing ? ( + + + + {t("accounts.refreshing")} + + + ) : null} + {refreshError ? ( + + + {t("accounts.refreshError")} + + + + ) : null} + + ); +} diff --git a/src/pages/accounts/ui/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage.vm.ts index 86c5357..d1ac50c 100644 --- a/src/pages/accounts/ui/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage.vm.ts @@ -4,13 +4,27 @@ import { useAuth } from "@/features/auth"; export function useAccountsPageViewModel() { const { accessToken } = useAuth(); const accounts = useBrokerageAccounts(accessToken); - if (accounts.isFetching) return { state: "loading" as const }; - if (accounts.isError) + if (!accounts.data) { + if (accounts.isFetching) return { state: "loading" as const }; + if (accounts.isError) + return { + state: "error" as const, + code: errorCode(accounts.error), + retry: () => accounts.refetch(), + }; + } + if (!accounts.data?.length) return { - state: "error" as const, - code: errorCode(accounts.error), + state: "empty" as const, + refreshing: accounts.isFetching, + refreshError: accounts.isError, retry: () => accounts.refetch(), }; - if (!accounts.data?.length) return { state: "empty" as const }; - return { state: "loaded" as const, accounts: accounts.data }; + return { + state: "loaded" as const, + accounts: accounts.data, + refreshing: accounts.isFetching, + refreshError: accounts.isError, + retry: () => accounts.refetch(), + }; } diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 3a03113..358ffeb 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -41,6 +41,9 @@ export const ru = { unavailableTitle: "T-Bank временно недоступен", unavailableText: "Попробуйте загрузить счета еще раз или проверьте подключение.", + refreshing: "Обновляем данные", + refreshError: + "Не удалось обновить данные. Показаны последние полученные.", retry: "Повторить", types: { BROKERAGE: "Брокерский счет", @@ -61,6 +64,9 @@ export const ru = { back: "К счетам", staleWarning: "T-Bank временно недоступен. Показан последний успешный снимок.", + refreshing: "Обновляем портфель", + refreshError: + "Не удалось обновить портфель. Показан последний полученный снимок.", updatedAt: "Данные получены {{date}}", positions: "Позиции", stocks: "Акции",