diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx index ffd3e4f..288d9f9 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx @@ -10,6 +10,7 @@ import { formatMoney } from "@/shared/lib"; import type { BankSummary } from "../../api/types"; import { bankOperationAppearance } from "../../model/operation-appearance"; +import { BankFeedback } from "../BankFeedback"; import classes from "./BankSummary.module.css"; const CATEGORY_COLUMNS = 2; @@ -17,9 +18,10 @@ const CATEGORY_COLUMNS = 2; type BankSummaryViewProps = { all?: boolean; data?: BankSummary; - error: boolean; + error: unknown; fetching: boolean; loading: boolean; + retry?: () => void; }; const BankSummaryViewCategories: FC = (props) => { @@ -122,7 +124,14 @@ const BankSummaryView: FC = (props) => { - {props.error && !props.data && {t("bank.error")}} + {Boolean(props.error) && ( + + )} ); }; diff --git a/src/features/auth/ui/AuthForm/AuthForm.test.tsx b/src/features/auth/ui/AuthForm/AuthForm.test.tsx index 2e0cd61..71daaf3 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.test.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.test.tsx @@ -30,4 +30,44 @@ describe("AuthForm", () => { await waitFor(() => expect(onSubmit).not.toHaveBeenCalled()); expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument(); }); + + it.each([ + ["register", "Регистрация", "Уже есть аккаунт? Войти"], + ["login", "Вход", "Нет аккаунта? Зарегистрироваться"], + ])("blocks the switch-auth link while %s is submitting", async (_mode, submitLabel, linkLabel) => { + const { promise, resolve } = Promise.withResolvers(); + const onSubmit = vi.fn(() => promise); + render( + + + + + , + ); + + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: "user@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Пароль"), { + target: { value: "a secure password" }, + }); + + if (_mode === "register") { + fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { + target: { value: "a secure password" }, + }); + } + + fireEvent.click(screen.getByRole("button", { name: submitLabel })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(screen.queryByRole("link", { name: linkLabel })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: linkLabel })).toBeDisabled(); + + resolve(); + await screen.findByRole("link", { name: linkLabel }); + }); }); diff --git a/src/features/auth/ui/AuthForm/AuthForm.tsx b/src/features/auth/ui/AuthForm/AuthForm.tsx index 3149dab..b273e72 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import type { FC, ReactNode } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core"; @@ -15,7 +15,7 @@ import { type AuthFormProps = { onSubmit: (email: string, password: string) => Promise; - register?: true; + register?: boolean; }; const AuthForm: FC = (props) => { @@ -45,6 +45,24 @@ const AuthForm: FC = (props) => { }); const to = register ? "/login" : "/register"; + const switchLabel = t(register ? "auth.haveAccount" : "auth.noAccount"); + + let switchLink: ReactNode; + + if (isSubmitting) { + switchLink = ( + + ); + } + else { + switchLink = ( + + ); + } return ( @@ -77,9 +95,7 @@ const AuthForm: FC = (props) => { {t(register ? "auth.register" : "auth.login")} - + {switchLink} diff --git a/src/pages/card/ui/CardPage/CardHistory.view.tsx b/src/pages/card/ui/CardPage/CardHistory.view.tsx index 5111a95..43870d9 100644 --- a/src/pages/card/ui/CardPage/CardHistory.view.tsx +++ b/src/pages/card/ui/CardPage/CardHistory.view.tsx @@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { + BankFeedback, bankOperationAppearance, } from "@/entities/bank-card"; @@ -108,6 +109,17 @@ const CardHistory: FC = (props) => { return {t("bank.noOperations")}; } + if (vm.history.error) { + return ( + + ); + } + if (vm.history.loading) { return ( diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx index 467d35a..17df568 100644 --- a/src/pages/card/ui/CardPage/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage/CardPage.test.tsx @@ -316,12 +316,47 @@ describe("CardPage", () => { setup({ summaryError: true }); await screen.findByRole("heading", { name: "Повседневная" }); expect( - screen.queryByRole("alert"), - ).not.toBeInTheDocument(); + await screen.findByText("Перевод на «Запасная»"), + ).toBeInTheDocument(); + expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent( + "Не удалось загрузить данные карт.", + ); + expect(screen.queryByText("0 ₽")).not.toBeInTheDocument(); + }); + + it("recovers summary with retry while keeping card and journal", async () => { + const { summaryRequests } = setup({ summaryError: true }); + await screen.findByRole("heading", { name: "Повседневная" }); expect( await screen.findByText("Перевод на «Запасная»"), ).toBeInTheDocument(); - expect(screen.queryByText("0 ₽")).not.toBeInTheDocument(); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: 5000 }, + ); + + const requestsBefore = summaryRequests.length; + server.use( + http.get("*/api/v1/bank-cards/summary", ({ request }) => { + summaryRequests.push(new URL(request.url).search); + + return HttpResponse.json({ + cardId: "a", + categories: [], + expenses: money("0"), + net: money("-50"), + refunds: money("50"), + }); + }), + ); + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + expect(summaryRequests).toHaveLength(requestsBefore + 1); }); it("keeps cached card and history after refetch failure", async () => { const { client } = setup(); @@ -331,9 +366,8 @@ describe("CardPage", () => { http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()), ); await client.invalidateQueries(); - await waitFor(() => { - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); + const alerts = await screen.findAllByRole("alert", {}, { timeout: 5000 }); + expect(alerts.length).toBeGreaterThanOrEqual(1); expect( screen.getByRole("heading", { name: "Повседневная" }), ).toBeInTheDocument(); @@ -401,11 +435,11 @@ describe("CardPage", () => { ]); }); it("recovers the prerequisite card list while preserving the visible balance and journal", async () => { - setup({ cardsError: true }); + const { client } = setup({ cardsError: true }); await screen.findByText("Перевод на «Запасная»"); expect( - screen.queryByRole("alert"), - ).not.toBeInTheDocument(); + await screen.findByRole("alert", {}, { timeout: 5000 }), + ).toHaveTextContent("Не удалось загрузить данные карт."); expect( screen.getByRole("button", { name: "Добавить операцию" }), ).toBeDisabled(); @@ -414,6 +448,7 @@ describe("CardPage", () => { HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]), ), ); + await client.invalidateQueries(); await waitFor(() => expect( screen.getByRole("button", { name: "Добавить операцию" }), diff --git a/src/pages/card/ui/CardPage/CardPage.view.tsx b/src/pages/card/ui/CardPage/CardPage.view.tsx index 4a499d9..df534f1 100644 --- a/src/pages/card/ui/CardPage/CardPage.view.tsx +++ b/src/pages/card/ui/CardPage/CardPage.view.tsx @@ -75,6 +75,11 @@ const CardPageView: FC = (props) => { {t("bank.back")} + {!vm.notFound && ( + + )} )} error @@ -128,18 +133,27 @@ const CardPageView: FC = (props) => { )} {card.archivedAt && {t("bank.archiveHint")}} + + {vm.cardError && vm.card && ( + + )} {vm.cardsFeedback && ( )} - + @@ -183,8 +197,6 @@ const CardPageView: FC = (props) => { - {vm.history.error && !vm.rows && {t("bank.error")}} - {vm.history.page !== undefined && vm.pageSize !== "all" && ( card.refetch(), cards: cards.data, cardsFeedback: cards.isError ? { cached: Boolean(cards.data), error: cards.error } : undefined, + cardsRetry: () => cards.refetch(), closeDeletion: () => setDeleting(null), closeEditing: () => setEditingCard(false), closeOperation: () => setOperation(null), @@ -112,9 +115,10 @@ function useCardPageVm() { editingCard, history: { changingPage: history.isPlaceholderData, - error: history.isError, + error: history.error, loading: history.isLoading, page: history.data?.page, + refetch: () => history.refetch(), }, notFound: isApiErrorCode( apiErrorCode(card.error), @@ -139,9 +143,10 @@ function useCardPageVm() { }, summary: { data: summary.data, - error: summary.isError, + error: summary.error, fetching: summary.isFetching, loading: summary.isLoading, + refetch: () => summary.refetch(), }, toggleArchive: () => { if (!card.data) return; diff --git a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx index 8856550..12af7b8 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx @@ -47,8 +47,9 @@ function renderCards(isFail = false, hasCategoryFailure = false) { ), ); - return render( - + const client = createTestQueryClient(); + render( + @@ -56,6 +57,8 @@ function renderCards(isFail = false, hasCategoryFailure = false) { , ); + + return client; } describe("CardsPage", () => { @@ -232,4 +235,76 @@ describe("CardsPage", () => { }); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); + + it("shows a cold cards error with retry", async () => { + renderCards(true); + + expect( + await screen.findByRole("alert", {}, { timeout: 5000 }), + ).toHaveTextContent("Не удалось загрузить данные карт."); + expect( + screen.getByRole("button", { name: "Повторить" }), + ).toBeInTheDocument(); + + server.use( + http.get("*/api/v1/bank-cards", () => + HttpResponse.json([ + { + archivedAt: null, + balance: money("10000"), + bankName: null, + id: "a", + lastFour: null, + name: "Повседневная", + openingBalance: money("10000"), + openingDate: "2026-01-01", + version: 1, + }, + ]), + ), + ); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + expect( + screen.getByRole("link", { name: /Повседневная/ }), + ).toBeInTheDocument(); + }); + + it("shows a cached cards error while keeping the grid", async () => { + const view = renderCards(); + + await screen.findByRole("heading", { name: "Добавьте первую карту" }); + server.use( + http.get("*/api/v1/bank-cards", () => + HttpResponse.json([ + { + archivedAt: null, + balance: money("10000"), + bankName: null, + id: "a", + lastFour: null, + name: "Повседневная", + openingBalance: money("10000"), + openingDate: "2026-01-01", + version: 1, + }, + ]), + ), + ); + await view.invalidateQueries(); + await screen.findByRole("link", { name: /Повседневная/ }); + + server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.error())); + await view.invalidateQueries(); + + expect( + await screen.findByRole("alert", {}, { timeout: 5000 }), + ).toHaveTextContent("Не удалось обновить данные. Показаны последние полученные."); + expect( + screen.getByRole("link", { name: /Повседневная/ }), + ).toBeInTheDocument(); + }); }); diff --git a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx index 1ad94dd..ecc07bc 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx @@ -18,6 +18,7 @@ import { Link } from "react-router"; import { BankCardFace, + BankFeedback, BankPeriodPicker, BankSummaryView, } from "@/entities/bank-card"; @@ -51,15 +52,17 @@ const CardsPageViewCards: FC = (props) => { ); } - const text = t(vm.archive ? "bank.emptyHint" : "bank.noActive"); + if (!vm.cards.error) { + const text = t(vm.archive ? "bank.emptyHint" : "bank.noActive"); - return ( - {t("bank.addCard")}} - text={text} - title={t("bank.empty")} - /> - ); + return ( + {t("bank.addCard")}} + text={text} + title={t("bank.empty")} + /> + ); + } } if (vm.cards.isLoading) { @@ -103,6 +106,15 @@ const CardsPageView: FC = (props) => { onChange={vm.onArchiveChange} /> + {vm.cards.error && ( + + )} + @@ -123,9 +135,10 @@ const CardsPageView: FC = (props) => { ); diff --git a/src/pages/cards/ui/CardsPage/CardsPage.vm.ts b/src/pages/cards/ui/CardsPage/CardsPage.vm.ts index 20b00aa..297faf0 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.vm.ts +++ b/src/pages/cards/ui/CardsPage/CardsPage.vm.ts @@ -39,6 +39,8 @@ function useCardsPageVm() { setArchive(event.currentTarget.checked), onCategories: () => setManagingCategories(true), period, + retryCards: () => cards.refetch(), + retrySummary: () => summary.refetch(), setPeriod: (value: [null | string, null | string]) => { if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] }); },