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:
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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")} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+9
-8
@@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+76
-65
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-3
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user