feat(events): add date filters to upcoming events
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
MemoryRouter,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountEventsPage } from "./AccountEventsPage";
|
||||
|
||||
const Navigation: FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<output aria-label="URL">{location.search}</output>
|
||||
<button type="button" onClick={() => navigate(-1)}>
|
||||
History back
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
describe("Upcoming event date filters", () => {
|
||||
it.each(["account-1", "all"])(
|
||||
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
|
||||
async (accountId) => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const searches: string[] = [];
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/events",
|
||||
({ request }) => {
|
||||
searches.push(new URL(request.url).search);
|
||||
return HttpResponse.json({
|
||||
accountId,
|
||||
calculatedAt: "2099-01-01T00:00:00Z",
|
||||
events: [],
|
||||
incomeTotalsByCurrency: [],
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
`/accounts/${accountId}/events?from=2099-02-01&to=2099-02-28`,
|
||||
]}
|
||||
>
|
||||
<Navigation />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/events"
|
||||
element={<AccountEventsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const from = await screen.findByLabelText("С");
|
||||
const to = screen.getByLabelText("По");
|
||||
expect(from).toHaveValue("01.02.2099");
|
||||
expect(to).toHaveValue("28.02.2099");
|
||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||
expect(searches).toEqual(["?from=2099-02-01&to=2099-02-28"]);
|
||||
fireEvent.change(from, { target: { value: "10.02.2099" } });
|
||||
fireEvent.blur(from);
|
||||
expect(searches).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
await waitFor(() =>
|
||||
expect(searches.at(-1)).toBe("?from=2099-02-10&to=2099-02-28"),
|
||||
);
|
||||
expect(await screen.findByLabelText("С")).toHaveValue("10.02.2099");
|
||||
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("С")).toHaveValue("01.02.2099"),
|
||||
);
|
||||
expect(screen.getByLabelText("URL")).toHaveTextContent(
|
||||
"?from=2099-02-01&to=2099-02-28",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||
await waitFor(() => expect(searches.at(-1)).toBe(""));
|
||||
expect(await screen.findByLabelText("С")).toHaveValue("");
|
||||
expect(screen.getByLabelText("По")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("За выбранный период событий не найдено"),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -49,7 +49,7 @@ describe("AccountEventsPage", () => {
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(query).toHaveBeenCalledWith("selected-account");
|
||||
expect(query).toHaveBeenCalledWith("selected-account", {});
|
||||
});
|
||||
|
||||
it("keeps the loading fallback when no result or account is available", () => {
|
||||
@@ -60,7 +60,7 @@ describe("AccountEventsPage", () => {
|
||||
refetch: vi.fn(),
|
||||
} as never);
|
||||
renderPage();
|
||||
expect(query).toHaveBeenCalledWith(undefined);
|
||||
expect(query).toHaveBeenCalledWith(undefined, {});
|
||||
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
|
||||
"aria-busy",
|
||||
"true",
|
||||
@@ -129,7 +129,7 @@ describe("AccountEventsPage", () => {
|
||||
const refetch = vi.fn();
|
||||
const { rerender } = renderPage();
|
||||
expect(
|
||||
screen.getByText("На ближайшие 30 дней событий не найдено"),
|
||||
screen.getByText("За выбранный период событий не найдено"),
|
||||
).toBeInTheDocument();
|
||||
query.mockReturnValue({
|
||||
data: undefined,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
||||
import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
|
||||
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
||||
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
||||
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
||||
@@ -12,8 +11,6 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
|
||||
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
||||
if (viewModel.state === "error")
|
||||
return <AccountEventsPageError retry={viewModel.retry} />;
|
||||
if (viewModel.state === "empty")
|
||||
return <AccountEventsPageEmpty {...viewModel} />;
|
||||
return <AccountEventsPageLoaded {...viewModel} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,40 +1,81 @@
|
||||
import { useParams } from "react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
|
||||
type EventsRefreshState = {
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
};
|
||||
|
||||
type AccountEventsPageViewModel =
|
||||
| { state: "loading" }
|
||||
| ({ state: "empty" } & EventsRefreshState)
|
||||
| { state: "error"; retry: () => void }
|
||||
| ({ state: "loaded"; events: BrokerageEvent[] } & EventsRefreshState);
|
||||
| {
|
||||
state: "loaded";
|
||||
events: BrokerageEvent[];
|
||||
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;
|
||||
};
|
||||
|
||||
function useAccountEventsPageViewModel(
|
||||
scopeId?: string,
|
||||
): AccountEventsPageViewModel {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
const events = useBrokerageEvents(accountId);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const appliedFrom = searchParams.get("from");
|
||||
const appliedTo = searchParams.get("to");
|
||||
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
|
||||
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
|
||||
useEffect(() => {
|
||||
setDraftFrom(appliedFrom);
|
||||
setDraftTo(appliedTo);
|
||||
}, [appliedFrom, appliedTo]);
|
||||
const events = useBrokerageEvents(accountId, {
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
});
|
||||
if (events.isLoading) return { state: "loading" };
|
||||
if (events.isError && !events.data)
|
||||
return { state: "error", retry: () => events.refetch() };
|
||||
return {
|
||||
state: "error",
|
||||
retry: () => {
|
||||
void events.refetch();
|
||||
},
|
||||
};
|
||||
if (!events.data) return { state: "loading" };
|
||||
const refresh = {
|
||||
return {
|
||||
state: "loaded",
|
||||
events: events.data.events,
|
||||
refreshing: events.isFetching,
|
||||
refreshError: events.isError,
|
||||
retry: () => {
|
||||
void events.refetch();
|
||||
},
|
||||
draftFrom,
|
||||
draftTo,
|
||||
canApply:
|
||||
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
|
||||
(!draftFrom || !draftTo || draftFrom <= draftTo),
|
||||
onDraftFromChange: setDraftFrom,
|
||||
onDraftToChange: setDraftTo,
|
||||
onApply: () =>
|
||||
setSearchParams({
|
||||
...(draftFrom ? { from: draftFrom } : {}),
|
||||
...(draftTo ? { to: draftTo } : {}),
|
||||
}),
|
||||
onReset: () => {
|
||||
setDraftFrom(null);
|
||||
setDraftTo(null);
|
||||
setSearchParams({});
|
||||
},
|
||||
};
|
||||
if (!events.data.events.length) return { state: "empty", ...refresh };
|
||||
return { state: "loaded", events: events.data.events, ...refresh };
|
||||
}
|
||||
|
||||
export { type EventsRefreshState, useAccountEventsPageViewModel };
|
||||
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import type { EventsRefreshState } from "../AccountEventsPage.vm";
|
||||
|
||||
const AccountEventsPageEmpty: FC<EventsRefreshState> = (props) => {
|
||||
const AccountEventsPageEmpty: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
{props.refreshing ? (
|
||||
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||
) : null}
|
||||
{props.refreshError ? (
|
||||
<Alert color="yellow">
|
||||
{t("accounts.refreshError")}
|
||||
<Button variant="subtle" onClick={props.retry}>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
|
||||
</Stack>
|
||||
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
BrokerageEventCard,
|
||||
} from "@/entities/brokerage-event";
|
||||
import type { EventsRefreshState } from "../AccountEventsPage.vm";
|
||||
import { BrokerageEventCard } from "@/entities/brokerage-event";
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
|
||||
|
||||
type AccountEventsPageLoadedProps = EventsRefreshState & {
|
||||
events: BrokerageEvent[];
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
const parseDate = (value: string) => {
|
||||
const date = dayjs(value, "DD.MM.YYYY", true);
|
||||
return date.isValid() ? date.format("YYYY-MM-DD") : null;
|
||||
};
|
||||
|
||||
type AccountEventsPageLoadedProps = Extract<
|
||||
AccountEventsPageViewModel,
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
|
||||
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -28,22 +45,56 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
</Alert>
|
||||
) : null}
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
<Stack gap="sm">
|
||||
{props.events.map((event) => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={JSON.stringify([
|
||||
event.account?.id,
|
||||
event.type,
|
||||
event.date,
|
||||
event.instrumentUid,
|
||||
event.figi,
|
||||
event.ticker,
|
||||
event.logoName,
|
||||
])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Group align="end">
|
||||
<DateInput
|
||||
label={t("ledger.from")}
|
||||
value={props.draftFrom}
|
||||
onChange={props.onDraftFromChange}
|
||||
maxDate={props.draftTo ?? undefined}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
dateParser={parseDate}
|
||||
allowDeselect
|
||||
popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
|
||||
/>
|
||||
<DateInput
|
||||
label={t("ledger.to")}
|
||||
value={props.draftTo}
|
||||
onChange={props.onDraftToChange}
|
||||
minDate={props.draftFrom ?? undefined}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
dateParser={parseDate}
|
||||
allowDeselect
|
||||
popoverProps={{ 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 ? (
|
||||
<Stack gap="sm">
|
||||
{props.events.map((event) => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={JSON.stringify([
|
||||
event.account?.id,
|
||||
event.type,
|
||||
event.date,
|
||||
event.instrumentUid,
|
||||
event.figi,
|
||||
event.ticker,
|
||||
event.logoName,
|
||||
])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<AccountEventsPageEmpty />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import { Group, Skeleton } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
@@ -9,6 +9,14 @@ const AccountEventsPageLoading: FC = () => {
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="40%" />
|
||||
<Skeleton height={18} width="70%" />
|
||||
<Group>
|
||||
<Skeleton height={60} width={220} />
|
||||
<Skeleton height={60} width={220} />
|
||||
</Group>
|
||||
<Group>
|
||||
<Skeleton height={36} width={116} />
|
||||
<Skeleton height={36} width={108} />
|
||||
</Group>
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<Skeleton key={index} height={82} radius="md" />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user