chore: add typed ESLint checks #18
Refs WealthLedger/main#18. Keep Biome; add no Prettier dependencies.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter } from "react-router";
|
||||
@@ -41,22 +41,18 @@ describe("AppRoutes", () => {
|
||||
])("renders %s with its page title", async (path, title) => {
|
||||
renderRoutes(path);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: title }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 1, name: title }),
|
||||
).toBeInTheDocument();
|
||||
expect(document.title).toBe(`${title} — WealthLedger`);
|
||||
});
|
||||
|
||||
it("redirects anonymous users from accounts", async () => {
|
||||
renderRoutes("/accounts");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Вход" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 1, name: "Вход" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("logs in through the public form and reaches the protected landing", async () => {
|
||||
@@ -142,14 +138,12 @@ describe("AppRoutes", () => {
|
||||
);
|
||||
renderRoutes("/accounts");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Нет инвестиционных счетов",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
level: 2,
|
||||
name: "Нет инвестиционных счетов",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "WealthLedger" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { type FC, lazy, Suspense } from "react";
|
||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -135,8 +135,6 @@ describe("RouteErrorBoundary", () => {
|
||||
authenticated = true;
|
||||
renderBoundary("/accounts");
|
||||
screen.getByRole("button", { name: "go healthy" }).click();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("healthy route")).toBeInTheDocument(),
|
||||
);
|
||||
expect(await screen.findByText("healthy route")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
|
||||
if (!value || value === accountId) return;
|
||||
const filters = new URLSearchParams(search);
|
||||
filters.delete("cursor");
|
||||
navigate({
|
||||
void navigate({
|
||||
pathname: `/accounts/${encodeURIComponent(value)}${section ? `/${section}` : ""}`,
|
||||
search: filters.toString(),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ async function mutationData<T>(
|
||||
request: Promise<{ data?: T; error?: unknown }>,
|
||||
) {
|
||||
const { data, error } = await request;
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ function useBankOperations(
|
||||
"/api/v1/bank-cards/{id}/operations",
|
||||
{ params },
|
||||
);
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
|
||||
return useAuthenticatedQuery({
|
||||
queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey,
|
||||
placeholderData: (
|
||||
@@ -108,6 +109,7 @@ function useBankOperations(
|
||||
signal,
|
||||
},
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error("Missing bank operations response");
|
||||
if (!all) return data;
|
||||
|
||||
@@ -101,28 +101,30 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(async (values) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
if (editing)
|
||||
await mutation.updateCategory.mutateAsync({
|
||||
id: editing.id,
|
||||
body: { name: values.name, version: editing.version },
|
||||
});
|
||||
else {
|
||||
const result = await mutation.createCategory.mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
if (result) props.onCreated?.(result.id);
|
||||
onSubmit={(event) => {
|
||||
void methods.handleSubmit(async (values) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
if (editing)
|
||||
await mutation.updateCategory.mutateAsync({
|
||||
id: editing.id,
|
||||
body: { name: values.name, version: editing.version },
|
||||
});
|
||||
else {
|
||||
const result = await mutation.createCategory.mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
if (result) props.onCreated?.(result.id);
|
||||
}
|
||||
editSession.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
} catch (failure) {
|
||||
setError(failure);
|
||||
}
|
||||
editSession.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
} catch (failure) {
|
||||
setError(failure);
|
||||
}
|
||||
})}
|
||||
})(event);
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={pending} className={classes.fieldset}>
|
||||
<Stack>
|
||||
|
||||
@@ -141,6 +141,7 @@ describe("persistent resource query lifecycle", () => {
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{ params: { path: { accountId: "a" } } },
|
||||
);
|
||||
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
|
||||
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
|
||||
params: { path: { accountId: "a" }, query: { sync: "observe" } },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { AuthProvider, useAuth } from "./model/auth";
|
||||
export { AuthProvider } from "./model/auth";
|
||||
export { useAuth } from "./model/auth-context";
|
||||
export { AuthPageLayout } from "./ui/AuthPageLayout/AuthPageLayout";
|
||||
export { LoginForm } from "./ui/LoginForm";
|
||||
export { RegisterForm } from "./ui/RegisterForm";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { paths } from "@/shared/api";
|
||||
|
||||
type User =
|
||||
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
||||
type AuthContextValue = {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
ready: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<boolean>;
|
||||
};
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error("AuthProvider is required");
|
||||
return context;
|
||||
}
|
||||
|
||||
export { AuthContext, type User, useAuth };
|
||||
@@ -7,7 +7,8 @@ import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AuthProvider, useAuth } from "./auth";
|
||||
import { AuthProvider } from "./auth";
|
||||
import { useAuth } from "./auth-context";
|
||||
|
||||
const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
|
||||
params: { query: { includeArchived: "false" } },
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
||||
import { AuthContext, type User } from "./auth-context";
|
||||
|
||||
type User =
|
||||
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
||||
type Authentication =
|
||||
paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
||||
type Credentials =
|
||||
paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"];
|
||||
type AuthContextValue = {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
ready: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<boolean>;
|
||||
};
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
async function request(
|
||||
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
||||
email: string,
|
||||
@@ -35,8 +16,9 @@ async function request(
|
||||
const { data, error } = await apiClient.POST(path, {
|
||||
body: { email, password },
|
||||
} satisfies { body: Credentials });
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
||||
if (error) throw error;
|
||||
return data as Authentication;
|
||||
return data;
|
||||
}
|
||||
|
||||
const AuthProvider: FC<PropsWithChildren> = (props) => {
|
||||
@@ -165,10 +147,4 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error("AuthProvider is required");
|
||||
return context;
|
||||
}
|
||||
|
||||
export { AuthProvider, useAuth };
|
||||
export { AuthProvider };
|
||||
|
||||
@@ -27,8 +27,6 @@ describe("AuthForm", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Регистрация" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Пароли не совпадают")).toBeInTheDocument(),
|
||||
);
|
||||
expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { useAuth } from "../model/auth";
|
||||
import { useAuth } from "../model/auth-context";
|
||||
import { AuthForm } from "./AuthForm/AuthForm";
|
||||
|
||||
const LoginForm: FC = () => {
|
||||
@@ -14,7 +14,7 @@ const LoginForm: FC = () => {
|
||||
<AuthForm
|
||||
onSubmit={async (email, password) => {
|
||||
await login(email, password);
|
||||
navigate(returnTo, { replace: true });
|
||||
await navigate(returnTo, { replace: true });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { useAuth } from "../model/auth";
|
||||
import { useAuth } from "../model/auth-context";
|
||||
import { AuthForm } from "./AuthForm/AuthForm";
|
||||
|
||||
const RegisterForm: FC = () => {
|
||||
@@ -15,7 +15,7 @@ const RegisterForm: FC = () => {
|
||||
register
|
||||
onSubmit={async (email, password) => {
|
||||
await register(email, password);
|
||||
navigate(returnTo, { replace: true });
|
||||
await navigate(returnTo, { replace: true });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,57 @@ function auth() {
|
||||
});
|
||||
}
|
||||
describe("page-owned screen synchronization", () => {
|
||||
it("ignores a late sync acceptance after the account scope changes", async () => {
|
||||
auth();
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const posted: string[] = [];
|
||||
const polled: string[] = [];
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
const scope = (await request.json()) as {
|
||||
screen: "account";
|
||||
accountId: string;
|
||||
};
|
||||
posted.push(scope.accountId);
|
||||
await blocked;
|
||||
return HttpResponse.json(
|
||||
{ id: scope.accountId, scope, now: "2026-09-25T00:00:00Z" },
|
||||
{ status: 202 },
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/tbank-sync/:id", ({ params }) => {
|
||||
polled.push(String(params.id));
|
||||
return HttpResponse.json({
|
||||
id: params.id,
|
||||
state: "succeeded",
|
||||
resources: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
const { result, rerender } = renderHook(
|
||||
({ accountId }) => useScreenSync({ screen: "account", accountId }, []),
|
||||
{
|
||||
initialProps: { accountId: "old" },
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
},
|
||||
);
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.refresh();
|
||||
});
|
||||
await waitFor(() => expect(posted).toEqual(["old"]));
|
||||
rerender({ accountId: "new" });
|
||||
await act(async () => {
|
||||
release();
|
||||
await pending;
|
||||
});
|
||||
await act(() => result.current.refresh());
|
||||
await waitFor(() => expect(polled).toEqual(["new"]));
|
||||
expect(posted).toEqual(["old", "new"]);
|
||||
});
|
||||
it.each([false, true])(
|
||||
"retries a failed GET without forcing the source (cached=%s)",
|
||||
async (cached) => {
|
||||
@@ -155,7 +206,7 @@ describe("page-owned screen synchronization", () => {
|
||||
);
|
||||
expect(published).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({ stateVersion: "old" }),
|
||||
meta: expect.objectContaining({ stateVersion: "old" }) as unknown,
|
||||
}),
|
||||
);
|
||||
unsubscribe();
|
||||
@@ -354,7 +405,7 @@ describe("page-owned screen synchronization", () => {
|
||||
});
|
||||
expect(modes).toEqual([null, "observe"]);
|
||||
if (boundary === "leave") unmount();
|
||||
else await act(() => configureAuthClient(null));
|
||||
else act(() => configureAuthClient(null));
|
||||
const stopped = polls;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
apiErrorCode,
|
||||
apiQueryClient,
|
||||
@@ -33,7 +33,9 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
} | null>(null);
|
||||
const requestInFlight = useRef<string | null>(null);
|
||||
const currentScope = useRef(scopeKey);
|
||||
currentScope.current = scopeKey;
|
||||
useLayoutEffect(() => {
|
||||
currentScope.current = scopeKey;
|
||||
}, [scopeKey]);
|
||||
const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
|
||||
const observed = useRef(new Map<string, string>());
|
||||
const ids = [
|
||||
|
||||
@@ -56,7 +56,10 @@ describe("sync connection and session boundaries", () => {
|
||||
const { wrapper, queryClient } = createQueryWrapper();
|
||||
const published: string[] = [];
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.query.queryKey[1] === "/api/v1/tbank-sync/{id}") {
|
||||
if (
|
||||
(event.query.queryKey as readonly unknown[])[1] ===
|
||||
"/api/v1/tbank-sync/{id}"
|
||||
) {
|
||||
const data = event.query.state.data as { now?: string } | undefined;
|
||||
if (data?.now) published.push(data.now);
|
||||
}
|
||||
@@ -73,6 +76,7 @@ describe("sync connection and session boundaries", () => {
|
||||
act(() => {
|
||||
mutation = result.current.mutation.mutateAsync({ body: screen });
|
||||
});
|
||||
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||
const discarded = expect(mutation).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
|
||||
+6
-10
@@ -163,11 +163,9 @@ describe("TbankConnectionSection", () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Заменить token" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Заменить token" }),
|
||||
).toBeInTheDocument();
|
||||
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
|
||||
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
|
||||
{ id: "bank-card-1" },
|
||||
@@ -224,11 +222,9 @@ describe("TbankConnectionSection", () => {
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Подключить" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Подключить" }),
|
||||
).toBeInTheDocument();
|
||||
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
|
||||
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
|
||||
{ id: "bank-card-1" },
|
||||
|
||||
@@ -48,10 +48,15 @@ function useAccountEventsPageViewModel(
|
||||
normalized.delete("to");
|
||||
setSearchParams(normalized, { replace: true });
|
||||
}, [validUrlPeriod, searchParams, setSearchParams]);
|
||||
useEffect(() => {
|
||||
const [previousPeriod, setPreviousPeriod] = useState([
|
||||
appliedFrom,
|
||||
appliedTo,
|
||||
]);
|
||||
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
|
||||
setPreviousPeriod([appliedFrom, appliedTo]);
|
||||
setDraftFrom(appliedFrom);
|
||||
setDraftTo(appliedTo);
|
||||
}, [appliedFrom, appliedTo]);
|
||||
}
|
||||
const events = useBrokerageEvents(accountId, {
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
|
||||
@@ -462,11 +462,9 @@ describe("AccountIncomePage", () => {
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Пассивный доход" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Пассивный доход" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Налоговые операции не включены в этот результат."),
|
||||
).toBeInTheDocument();
|
||||
@@ -542,11 +540,9 @@ describe("AccountIncomePage", () => {
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Повторить" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Повторить" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,18 +43,22 @@ type IncomeDisplayModel = {
|
||||
|
||||
type AccountIncomePageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; retry: () => void; period: IncomePeriod }
|
||||
| {
|
||||
state: "error";
|
||||
retry: () => undefined | Promise<unknown>;
|
||||
period: IncomePeriod;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
analytics: IncomeAnalytics;
|
||||
display: IncomeDisplayModel;
|
||||
formRevision: number;
|
||||
formRevision: string;
|
||||
sourceError?: boolean;
|
||||
lastSuccessfulAt?: string | null;
|
||||
partial?: boolean;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
retry: () => undefined | Promise<unknown>;
|
||||
form: ReturnType<typeof useForm<IncomeForm>>;
|
||||
onReset: () => void;
|
||||
onSubmit: (values: IncomeForm) => void;
|
||||
@@ -102,7 +106,7 @@ const useAccountIncomePageViewModel = (
|
||||
defaultValues: draftPeriod(period),
|
||||
resolver: zodResolver(incomeFormSchema),
|
||||
});
|
||||
const [formRevision, setFormRevision] = useState(0);
|
||||
const [resetRevision, setResetRevision] = useState(0);
|
||||
const analytics = useIncomeAnalytics(accountId, period);
|
||||
const sync = useScreenSync(
|
||||
{ screen: "income", accountId, from: period.from, to: period.to },
|
||||
@@ -114,7 +118,6 @@ const useAccountIncomePageViewModel = (
|
||||
}, [period, requested, setSearchParams]);
|
||||
useEffect(() => {
|
||||
form.reset(draftPeriod(period));
|
||||
setFormRevision((revision) => revision + 1);
|
||||
}, [form, period]);
|
||||
|
||||
const onSubmit = (values: IncomeForm) => {
|
||||
@@ -126,7 +129,7 @@ const useAccountIncomePageViewModel = (
|
||||
const next = defaultPeriod();
|
||||
form.reset(draftPeriod(next));
|
||||
// Reset the open calendar along with the form.
|
||||
setFormRevision((revision) => revision + 1);
|
||||
setResetRevision((revision) => revision + 1);
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
if (analytics.isLoading || !analytics.data) {
|
||||
@@ -138,16 +141,14 @@ const useAccountIncomePageViewModel = (
|
||||
if (!display)
|
||||
return {
|
||||
state: "error",
|
||||
retry: () => {
|
||||
void analytics.observe();
|
||||
},
|
||||
retry: () => analytics.observe(),
|
||||
period,
|
||||
};
|
||||
return {
|
||||
state: "loaded",
|
||||
analytics: analytics.data,
|
||||
display,
|
||||
formRevision,
|
||||
formRevision: `${period.from}:${period.to}:${resetRevision}`,
|
||||
sourceError: sync.sourceError,
|
||||
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
||||
partial: analytics.meta?.availability !== "complete",
|
||||
|
||||
+14
-11
@@ -22,8 +22,14 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useFormContext<IncomeForm>();
|
||||
const fromField = useController({ control, name: "from" });
|
||||
const toField = useController({ control, name: "to" });
|
||||
const {
|
||||
field: { ref: fromRef, onChange: onFromChange, onBlur: onFromBlur },
|
||||
fieldState: { error: fromError },
|
||||
} = useController({ control, name: "from" });
|
||||
const {
|
||||
field: { onChange: onToChange, onBlur: onToBlur },
|
||||
fieldState: { error: toError },
|
||||
} = useController({ control, name: "to" });
|
||||
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
||||
const today = moscowDate(new Date());
|
||||
const start = parseDraftDate(from);
|
||||
@@ -44,23 +50,20 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
allowSingleDateInRange
|
||||
clearable
|
||||
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||
ref={fromField.field.ref}
|
||||
ref={fromRef}
|
||||
label={t("income.period")}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
value={[start, end]}
|
||||
onChange={([from, to]) => {
|
||||
fromField.field.onChange(from ? formatDraftDate(from) : "");
|
||||
toField.field.onChange(to ? formatDraftDate(to) : "");
|
||||
onFromChange(from ? formatDraftDate(from) : "");
|
||||
onToChange(to ? formatDraftDate(to) : "");
|
||||
}}
|
||||
onBlur={() => {
|
||||
fromField.field.onBlur();
|
||||
toField.field.onBlur();
|
||||
onFromBlur();
|
||||
onToBlur();
|
||||
}}
|
||||
maxDate={today}
|
||||
error={
|
||||
fromField.fieldState.error?.message ??
|
||||
toField.fieldState.error?.message
|
||||
}
|
||||
error={fromError?.message ?? toError?.message}
|
||||
disabled={isSubmitting}
|
||||
popoverProps={{
|
||||
returnFocus: true,
|
||||
|
||||
@@ -72,11 +72,9 @@ const useAccountOperationsPageVm = (
|
||||
}>({ scopeKey, cursors: [] });
|
||||
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
||||
const cursor = cursors.at(-1);
|
||||
useEffect(() => {
|
||||
setPagination((current) =>
|
||||
current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] },
|
||||
);
|
||||
}, [scopeKey]);
|
||||
if (pagination.scopeKey !== scopeKey) {
|
||||
setPagination({ scopeKey, cursors: [] });
|
||||
}
|
||||
useEffect(() => {
|
||||
if (validUrlPeriod) return;
|
||||
const normalized = new URLSearchParams(searchParams);
|
||||
@@ -84,7 +82,9 @@ const useAccountOperationsPageVm = (
|
||||
normalized.delete("to");
|
||||
setSearchParams(normalized, { replace: true });
|
||||
}, [validUrlPeriod, searchParams, setSearchParams]);
|
||||
useEffect(() => {
|
||||
const [previousSearch, setPreviousSearch] = useState(searchParams);
|
||||
if (previousSearch !== searchParams) {
|
||||
setPreviousSearch(searchParams);
|
||||
setDraftFrom(appliedFrom);
|
||||
setDraftTo(appliedTo);
|
||||
setDraftCategories(
|
||||
@@ -92,7 +92,7 @@ const useAccountOperationsPageVm = (
|
||||
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||
),
|
||||
);
|
||||
}, [appliedFrom, appliedTo, searchParams]);
|
||||
}
|
||||
const firstPageQuery = {
|
||||
from: dateStart(appliedFrom),
|
||||
to: dateEnd(appliedTo),
|
||||
|
||||
@@ -58,7 +58,7 @@ type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||
|
||||
type AccountPageIncomeSummary =
|
||||
| { state: "loading" | "partial" | "empty"; to: string }
|
||||
| { state: "error"; to: string; retry: () => void }
|
||||
| { state: "error"; to: string; retry: () => undefined | Promise<unknown> }
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
@@ -75,7 +75,7 @@ type AccountPageContent = {
|
||||
accountId?: string;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
retry: () => undefined | Promise<unknown>;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
incomeSummary: AccountPageIncomeSummary;
|
||||
@@ -93,7 +93,7 @@ type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
|
||||
type AccountPageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; code?: string; retry: () => void }
|
||||
| { state: "error"; code?: string; retry: () => undefined | Promise<unknown> }
|
||||
| AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -157,12 +157,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
description: props.operation?.description ?? "",
|
||||
},
|
||||
});
|
||||
const kind = methods.watch("kind");
|
||||
const kind = useWatch({ control: methods.control, name: "kind" });
|
||||
const expenses = useBankExpenses(
|
||||
kind === "refund" ? props.card.id : undefined,
|
||||
{ page: sourcePage },
|
||||
);
|
||||
const source = methods.watch("cardId");
|
||||
const source = useWatch({ control: methods.control, name: "cardId" });
|
||||
const pending = !opened || methods.formState.isSubmitting;
|
||||
const options = props.cards
|
||||
.filter((c) => !c.archivedAt)
|
||||
|
||||
@@ -17,7 +17,7 @@ const SettingsPage: FC = () => {
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
await navigate("/login", { replace: true });
|
||||
}}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("apiClient authentication", () => {
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||
const response = expect(
|
||||
apiClient.GET("/api/v1/brokerage-accounts"),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
|
||||
@@ -2,10 +2,15 @@ import createClient from "openapi-fetch";
|
||||
import createQueryClient from "openapi-react-query";
|
||||
|
||||
import type { paths } from "./generated/schema";
|
||||
import { apiErrorCode } from "./problem-details";
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
|
||||
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
|
||||
const apiBaseUrl =
|
||||
typeof configuredBaseUrl === "string"
|
||||
? configuredBaseUrl
|
||||
: "http://localhost:3000";
|
||||
|
||||
type AuthHandlers = {
|
||||
generation?: () => number;
|
||||
@@ -105,12 +110,12 @@ async function fetchCurrent(
|
||||
response.status !== HTTP_UNAUTHORIZED ||
|
||||
isAuthRequest ||
|
||||
!auth ||
|
||||
(
|
||||
apiErrorCode(
|
||||
await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null)
|
||||
)?.code !== "UNAUTHORIZED"
|
||||
.catch(() => null),
|
||||
) !== "UNAUTHORIZED"
|
||||
)
|
||||
return response;
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
|
||||
@@ -38,7 +38,7 @@ export const Primary: Story = {
|
||||
name: "Основное действие",
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const button = within(canvasElement).getByRole("button", {
|
||||
name: String(args.children),
|
||||
name: typeof args.children === "string" ? args.children : undefined,
|
||||
});
|
||||
button.focus();
|
||||
await expect(button).toHaveFocus();
|
||||
|
||||
Reference in New Issue
Block a user