chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
@@ -1,3 +1,5 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
@@ -8,8 +10,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import { http, HttpResponse } from "msw";
import {
MemoryRouter,
Route,
@@ -18,10 +19,12 @@ import {
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper, createTestQueryClient } from "@/test/query";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
@@ -31,10 +34,10 @@ const Navigation: FC = () => {
return (
<>
<output aria-label="URL">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
<button onClick={() => navigate(-1)} type="button">
History back
</button>
<button type="button" onClick={() => navigate(1)}>
<button onClick={() => navigate(1)} type="button">
History forward
</button>
</>
@@ -45,8 +48,8 @@ describe("Upcoming event date filters", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const requests: string[] = [];
server.use(
@@ -55,17 +58,17 @@ describe("Upcoming event date filters", () => {
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
</QueryClientProvider>
),
});
await act(async () => {});
await new Promise(resolve => setTimeout(resolve, 0));
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
@@ -80,8 +83,8 @@ describe("Upcoming event date filters", () => {
async (search) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const requested: string[] = [];
const syncRequests: unknown[] = [];
@@ -89,13 +92,15 @@ describe("Upcoming event date filters", () => {
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed",
},
}
/* eslint-enable unicorn/no-null */)),
);
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
@@ -107,16 +112,16 @@ describe("Upcoming event date filters", () => {
}),
);
const period = Object.fromEntries(new URLSearchParams(search));
const { wrapper: QueryWrapper } = createQueryWrapper();
const queryClient = createTestQueryClient();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
{
wrapper: ({ children }) => (
<QueryWrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`?${search}`]}>
{children}
</MemoryRouter>
</QueryWrapper>
</QueryClientProvider>
),
},
);
@@ -128,7 +133,7 @@ describe("Upcoming event date filters", () => {
});
await waitFor(() =>
expect(syncRequests).toEqual([
{ screen: "events", accountId: "all", ...period },
{ accountId: "all", screen: "events", ...period },
]),
);
},
@@ -139,8 +144,8 @@ describe("Upcoming event date filters", () => {
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const searches: string[] = [];
server.use(
@@ -156,20 +161,20 @@ describe("Upcoming event date filters", () => {
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2099-02-28",
"?from=2099-02-30",
"?from=2099-03-01&to=2099-02-28",
"?from=2099-02-01",
"?to=2099-02-28",
].map((search) => `/accounts/${accountId}/events${search}`)}
].map(search => `/accounts/${accountId}/events${search}`)}
initialIndex={0}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
@@ -182,6 +187,7 @@ describe("Upcoming event date filters", () => {
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- URL history requires exact equality, including empty search.
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
@@ -202,8 +208,8 @@ describe("Upcoming event date filters", () => {
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
server.use(
http.get(
@@ -223,8 +229,8 @@ describe("Upcoming event date filters", () => {
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
@@ -234,7 +240,7 @@ describe("Upcoming event date filters", () => {
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
},
);
@@ -243,8 +249,8 @@ describe("Upcoming event date filters", () => {
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const searches: string[] = [];
server.use(
@@ -274,8 +280,8 @@ describe("Upcoming event date filters", () => {
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
@@ -324,41 +330,41 @@ describe("Upcoming event date filters", () => {
it("retains the previous complete forecast and timestamp when its source refresh fails", async () => {
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const events = [
{
date: "2026-10-01",
displayName: "Synthetic coupon",
type: "coupon",
projectedAmount: { currency: "RUB", value: "123.45" },
type: "coupon",
},
];
const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { id: "failed", state: "failed", errorCode: "TBANK_UNAVAILABLE" } },
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } },
);
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(snapshot),
),
);
const { wrapper: QueryWrapper } = createQueryWrapper();
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
wrapper: ({ children }) => (
<QueryWrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter>
</QueryWrapper>
</QueryClientProvider>
),
});
await waitFor(() =>
expect(result.current).toMatchObject({
state: "loaded",
events,
sourceError: true,
refreshError: true,
lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
refreshError: true,
sourceError: true,
state: "loaded",
}),
);
});
@@ -1,21 +1,23 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPage } from "./AccountEventsPage";
const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
vi.mock("@/features/tbank-connection", () => ({
useScreenSync: () => ({
refresh,
retry: refresh,
refreshing: false,
retry: refresh,
sourceError: false,
transportError: false,
}),
}));
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
vi.mock("@/entities/brokerage-event", async importOriginal => ({
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
useBrokerageEvents: vi.fn(),
}));
@@ -35,20 +37,22 @@ function renderPage() {
describe("AccountEventsPage", () => {
beforeEach(() => {
refresh.mockClear();
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
},
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
},
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
});
it("passes the selected route account to the query", () => {
@@ -57,8 +61,8 @@ describe("AccountEventsPage", () => {
<MemoryRouter initialEntries={["/accounts/selected-account/events"]}>
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
@@ -68,16 +72,18 @@ describe("AccountEventsPage", () => {
});
it("keeps the loading fallback when no result or account is available", () => {
query.mockReturnValue({
data: undefined,
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
data: undefined,
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
renderPage();
expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
@@ -87,46 +93,48 @@ describe("AccountEventsPage", () => {
});
it("renders events and marks an offer without an amount", () => {
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
projectedAmount: { currency: "RUB", value: "1250" },
ticker: "OFZ",
type: "coupon",
logoName: "ofz.png",
isin: "RU000A0JX0J2",
},
{
date: "2026-08-25",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
projectedAmount: null,
ticker: "OFZ",
type: "offer",
logoName: null,
isin: null,
},
],
},
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
const { container } = renderPage();
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: "ofz.png",
projectedAmount: { currency: "RUB", value: "1250" },
ticker: "OFZ",
type: "coupon",
},
{
date: "2026-08-25",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: null,
logoName: null,
projectedAmount: null,
ticker: "OFZ",
type: "offer",
},
],
},
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
renderPage();
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible();
@@ -136,7 +144,7 @@ describe("AccountEventsPage", () => {
expect(screen.getByText("25.08.2026")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
const logo = container.querySelector("img");
const logo = screen.queryByAltText("");
expect(logo).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/ofzx160.png",
@@ -144,7 +152,7 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText("O")).toHaveLength(1);
if (!logo) throw new Error("Expected the event logo");
fireEvent.error(logo);
expect(container.querySelector("img")).toBeNull();
expect(screen.queryByAltText("")).not.toBeInTheDocument();
expect(screen.getAllByText("O")).toHaveLength(2);
});
@@ -167,26 +175,28 @@ describe("AccountEventsPage", () => {
</MemoryRouter>
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(refresh).toHaveBeenCalledOnce();
});
it("shows a structural busy state before events are available", () => {
query.mockReturnValue({
data: undefined,
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: true,
refetch: vi.fn(),
} as never);
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
data: undefined,
isError: false,
isLoading: true,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
renderPage();
const status = screen.getByRole("status", { name: "Загрузка" });
expect(status).toHaveAttribute("aria-busy", "true");
expect(status.querySelector("button")).toBeNull();
expect(within(status).queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
@@ -9,9 +10,7 @@ type AccountEventsPageProps = { accountId?: string };
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />;
return viewModel.state === "error" ? <AccountEventsPageError {...viewModel} /> : <AccountEventsPageLoaded {...viewModel} />;
};
export { AccountEventsPage };
@@ -1,53 +1,54 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
events: BrokerageEvent[];
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
draftFrom: string | null;
draftTo: string | null;
canApply: boolean;
onDraftFromChange: (value: string | null) => void;
onDraftToChange: (value: string | null) => void;
onApply: () => void;
onReset: () => void;
};
type AccountEventsPageViewModel
= | {
canApply: boolean;
draftFrom: null | string;
draftTo: null | string;
events: BrokerageEvent[];
lastSuccessfulAt?: null | string;
onApply: () => void;
onDraftFromChange: (value: null | string) => void;
onDraftToChange: (value: null | string) => void;
onReset: () => void;
partial?: boolean;
refreshError: boolean;
refreshing: boolean;
retry: () => void;
sourceError?: boolean;
state: "loaded";
}
| { onReset: () => void; retry: () => void; state: "error" }
| { state: "loading" };
function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
const [searchParameters, setSearchParameters] = useSearchParams();
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
if (isValidUrlPeriod) return;
const normalized = new URLSearchParams(searchParameters);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const [previousPeriod, setPreviousPeriod] = useState([
appliedFrom,
appliedTo,
@@ -58,69 +59,69 @@ function useAccountEventsPageViewModel(
setDraftTo(appliedTo);
}
const events = useBrokerageEvents(accountId, {
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
...(appliedFrom && { from: appliedFrom }),
...(appliedTo && { to: appliedTo }),
});
const sync = useScreenSync(
{
screen: "events",
accountId,
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
screen: "events",
...(appliedFrom && { from: appliedFrom }),
...(appliedTo && { to: appliedTo }),
},
[events],
);
if (events.isLoading) return { state: "loading" };
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
setSearchParameters({});
};
if (events.isLoading) return { state: "loading" };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
state: "error",
onReset,
retry: () => {
void sync.retry();
},
state: "error",
};
if (!events.data) return { state: "loading" };
return {
state: "loaded",
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo),
draftFrom,
draftTo,
events: events.data.events,
sourceError: sync.sourceError,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({
...(draftFrom && { from: draftFrom }),
...(draftTo && { to: draftTo }),
});
},
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onReset,
partial: events.meta?.availability !== "complete",
refreshing: events.isFetching || sync.refreshing,
refreshError: events.isError || sync.sourceError || sync.transportError,
refreshing: events.isFetching || sync.refreshing,
retry: () => {
void sync.retry();
},
draftFrom,
draftTo,
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
});
},
onReset,
sourceError: sync.sourceError,
state: "loaded",
};
}
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
const calendarDate = z.iso.date();
function isValidPeriod(from: null | string, to: null | string) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
date => date === null || calendarDate.safeParse(date).success,
)
&& (!from || !to || from <= to)
);
}
@@ -1,11 +1,12 @@
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
<StateView text={t("events.emptyText")} title={t("events.emptyTitle")} />
);
};
@@ -1,25 +1,26 @@
import type { FC } from "react";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void };
type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={
action={(
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
}
)}
error
text={t("events.errorText")}
title={t("events.errorTitle")}
/>
);
};
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Alert,
Button,
@@ -8,11 +10,13 @@ import {
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
type AccountEventsPageLoadedProps = Extract<
@@ -22,68 +26,11 @@ type AccountEventsPageLoadedProps = Extract<
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack gap="lg">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
<Title order={1}>{t("events.title")}</Title>
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
{t("investment.refresh")}
</Button>
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : null}
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow" role="status" aria-live="polite">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Text c="dimmed">{t("events.hint")}</Text>
<Group align="end">
<DatePickerInput
type="range"
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
value={[props.draftFrom, props.draftTo]}
onChange={([from, to]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
}}
valueFormat="DD.MM.YYYY"
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
}}
/>
</Group>
<Group>
<Button onClick={props.onApply} disabled={!props.canApply}>
{t("ledger.apply")}
</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
{props.events.length ? (
function renderEvents() {
if (props.events.length > 0) {
return (
<Stack gap="sm">
{props.events.map((event) => (
{props.events.map(event => (
<BrokerageEventCard
event={event}
key={JSON.stringify([
@@ -98,9 +45,73 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
/>
))}
</Stack>
) : !props.partial ? (
<AccountEventsPageEmpty />
) : null}
);
}
if (props.partial) {
return;
}
return <AccountEventsPageEmpty />;
}
return (
<Stack gap="lg">
{!!(props.lastSuccessfulAt) && ((
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
<Title order={1}>{t("events.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
{!!(props.partial) && ((
<Alert role="status">{t("investment.partial")}</Alert>
))}
{!!(props.refreshing) && ((
<Progress animated aria-label={t("accounts.refreshing")} value={100} />
))}
{!!(props.refreshError) && ((
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
))}
<Text c="dimmed">{t("events.hint")}</Text>
<Group align="end">
<DatePickerInput
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
onChange={([from, to]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
}}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
}}
type="range"
value={[props.draftFrom, props.draftTo]}
valueFormat="DD.MM.YYYY"
/>
</Group>
<Group>
<Button disabled={!props.canApply} onClick={props.onApply}>
{t("ledger.apply")}
</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
{renderEvents()}
</Stack>
);
};
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { Group, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
const skeletonRowIds = ["first", "second", "third", "fourth"];
@@ -19,8 +20,8 @@ const AccountEventsPageLoading: FC = () => {
<Skeleton height={36} width={116} />
<Skeleton height={36} width={108} />
</Group>
{skeletonRowIds.map((id) => (
<Skeleton key={id} height={82} radius="md" />
{skeletonRowIds.map(id => (
<Skeleton height={82} key={id} radius="md" />
))}
</LoadingSkeleton>
);