feat: replace paired date inputs with range pickers

This commit is contained in:
2026-09-12 22:41:40 +03:00
parent aba4cf1ce8
commit 24d332e063
8 changed files with 137 additions and 175 deletions
@@ -55,7 +55,7 @@ describe("Upcoming event date filters", () => {
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[
`/accounts/${accountId}/events?from=2099-02-01&to=2099-02-28`,
@@ -72,31 +72,37 @@ describe("Upcoming event date filters", () => {
</MantineProvider>
</QueryClientProvider>,
);
const from = await screen.findByLabelText("С");
const to = screen.getByLabelText("По");
expect(from).toHaveValue("01.02.2099");
expect(to).toHaveValue("28.02.2099");
const from = await screen.findByLabelText("Период");
expect(from).toHaveTextContent("01.02.2099 – 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);
fireEvent.click(from);
fireEvent.click(
await screen.findByRole("button", { name: /10 February 2099/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /28 February 2099/i }),
);
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");
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"10.02.2099 – 28.02.2099",
);
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("С")).toHaveValue("01.02.2099"),
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.02.2099 – 28.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(await screen.findByLabelText("Период")).toHaveTextContent("");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(
screen.getByText("За выбранный период событий не найдено"),
@@ -7,22 +7,13 @@ import {
Text,
Title,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { DatePickerInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
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" }
@@ -46,25 +37,22 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
) : null}
<Text c="dimmed">{t("events.hint")}</Text>
<Group align="end">
<DateInput
label={t("ledger.from")}
value={props.draftFrom}
onChange={props.onDraftFromChange}
maxDate={props.draftTo ?? undefined}
<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"
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 } } }}
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
}}
/>
</Group>
<Group>
@@ -5,7 +5,7 @@
.fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
}
@@ -523,12 +523,10 @@ const LocationProbe: FC = () => {
);
};
function inputDate(label: string, value: string) {
const field = screen.getByLabelText(label);
fireEvent.focus(field);
fireEvent.input(field, { target: { value } });
fireEvent.blur(field);
return field;
function selectPeriod() {
fireEvent.click(screen.getByLabelText("Период"));
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i }));
}
function respondWithRequestedPeriod(requests: string[]) {
@@ -557,9 +555,10 @@ describe("income period and snapshot boundary", () => {
const requests: string[] = [];
respondWithRequestedPeriod(requests);
renderPage();
expect(await screen.findByLabelText("С")).toHaveValue("01.08.2026");
expect(screen.getByLabelText("По")).toHaveValue("27.08.2026");
inputDate("С", "02.08.2026");
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"01.08.2026 – 27.08.2026",
);
selectPeriod();
expect(requests).toHaveLength(1);
expect(
screen.queryByText("2026-08-01 — 2026-08-27"),
@@ -573,66 +572,37 @@ describe("income period and snapshot boundary", () => {
.closest("form") as HTMLFormElement,
);
await waitFor(() => expect(requests).toHaveLength(2));
expect(await screen.findByLabelText("С")).toHaveValue("02.08.2026");
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
expect(screen.getByTestId("location")).toHaveTextContent(
"from=2026-08-02&to=2026-08-27",
);
fireEvent.click(screen.getByRole("button", { name: "Back" }));
await waitFor(() =>
expect(screen.getByLabelText("С")).toHaveValue("01.08.2026"),
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.08.2026 – 27.08.2026",
),
);
});
it.each([
["С", "", "Введите существующую дату"],
["С", "31.02.2026", "Введите существующую дату"],
["По", "29.02.2026", "Введите существующую дату"],
["По", "2026-08-02", "Введите существующую дату"],
["С", "01.01.2099", "Дата не может быть позже"],
["По", "01.01.2099", "Дата не может быть позже"],
["По", "31.07.2026", "Укажите корректный период"],
])(
"rejects visible %s=%s without replacing data or URL",
async (label, value, error) => {
const requests: string[] = [];
respondWithRequestedPeriod(requests);
renderPage();
await screen.findByLabelText("С");
const field = inputDate(label, value);
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
expect(await screen.findByText(new RegExp(error))).toBeInTheDocument();
expect(field).toHaveValue(value);
expect(field).toHaveAttribute("aria-invalid", "true");
await waitFor(() => expect(field).toHaveFocus());
expect(requests).toHaveLength(1);
expect(screen.getByTestId("location")).toHaveTextContent(
"from=2026-08-01&to=2026-08-27",
);
expect(
screen.queryByText("2026-08-01 — 2026-08-27"),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
},
);
it("replaces invalid raw text with a calendar choice and closes the calendar with Escape", async () => {
respondWithRequestedPeriod([]);
it("rejects an incomplete range without replacing the URL and allows a single day", async () => {
const requests: string[] = [];
respondWithRequestedPeriod(requests);
renderPage();
const from = await screen.findByLabelText("С");
act(() => from.focus());
fireEvent.input(from, { target: { value: "invalid" } });
// The selected month stays available even when the visible draft is invalid.
const day = await screen.findByRole("button", {
name: /^2 августа 2026$/i,
});
fireEvent.click(day);
expect(from).toHaveValue("02.08.2026");
act(() => from.focus());
fireEvent.keyDown(from, { key: "Escape" });
const period = await screen.findByLabelText("Период");
fireEvent.click(period);
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
fireEvent.keyDown(period, { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
expect(await screen.findByText(/Введите существующую дату/)).toBeVisible();
expect(requests).toHaveLength(1);
if (period.getAttribute("aria-expanded") !== "true")
fireEvent.click(period);
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() =>
expect(
screen.queryByRole("button", { name: /^2 августа 2026$/i }),
).not.toBeInTheDocument(),
expect(requests.at(-1)).toBe("?from=2026-08-02&to=2026-08-02"),
);
});
@@ -642,19 +612,23 @@ describe("income period and snapshot boundary", () => {
const requests: string[] = [];
respondWithRequestedPeriod(requests);
renderPage("");
expect(await screen.findByLabelText("С")).toHaveValue("01.09.2026");
expect(screen.getByLabelText("По")).toHaveValue("01.09.2026");
inputDate("С", "wrong");
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"01.09.2026 – 01.09.2026",
);
fireEvent.click(screen.getByLabelText("Период"));
fireEvent.click(screen.getByRole("button", { name: /^1 сентября 2026$/i }));
fireEvent.keyDown(screen.getByLabelText("Период"), { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await screen.findByText(/Введите существующую дату/);
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() =>
expect(screen.getByLabelText("С")).toHaveValue("01.09.2026"),
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.09.2026 – 01.09.2026",
),
);
expect(
screen.queryByText(/Введите существующую дату/),
).not.toBeInTheDocument();
expect(screen.getByLabelText("По")).toHaveValue("01.09.2026");
expect(screen.getByTestId("location")).toHaveTextContent(
"from=2026-09-01&to=2026-09-01",
);
@@ -664,8 +638,8 @@ describe("income period and snapshot boundary", () => {
it("keeps valid data and draft during refetch, warns on failure and retries", async () => {
respondWithRequestedPeriod([]);
const { queryClient } = renderPage();
await screen.findByLabelText("С");
inputDate("С", "02.08.2026");
await screen.findByLabelText("Период");
selectPeriod();
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
@@ -686,7 +660,9 @@ describe("income period and snapshot boundary", () => {
await screen.findByRole("status", { name: "Обновляем пассивный доход" }),
).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
expect(screen.getByLabelText("С")).toHaveValue("02.08.2026");
expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
release();
expect(
await screen.findByText(/Показан последний загруженный результат/),
@@ -699,13 +675,15 @@ describe("income period and snapshot boundary", () => {
screen.queryByText(/Показан последний загруженный результат/),
).not.toBeInTheDocument(),
);
expect(screen.getByLabelText("С")).toHaveValue("02.08.2026");
expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
});
it("shows busy skeleton for a new uncached period without relabeling the previous result", async () => {
respondWithRequestedPeriod([]);
renderPage();
await screen.findByLabelText("С");
await screen.findByLabelText("Период");
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
@@ -722,7 +700,7 @@ describe("income period and snapshot boundary", () => {
},
),
);
inputDate("С", "02.08.2026");
selectPeriod();
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
expect(
await screen.findByRole("status", { name: "Загрузка" }),
@@ -734,7 +712,9 @@ describe("income period and snapshot boundary", () => {
screen.queryByText("2026-08-02 — 2026-08-27"),
).not.toBeInTheDocument();
release();
expect(await screen.findByLabelText("С")).toHaveValue("02.08.2026");
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
});
it("shows one flat payment card per entry in server order without currency or instrument groups", () => {
@@ -99,7 +99,7 @@ const useAccountIncomePageViewModel = (
const onReset = () => {
const next = defaultPeriod();
form.reset(draftPeriod(next));
// DateInput retains raw text while its calendar is open; reset that UI state too.
// Reset the open calendar along with the form.
setFormRevision((revision) => revision + 1);
setSearchParams(next, { replace: true });
};
@@ -1,7 +1,7 @@
import { Button, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import type { FC, FormEventHandler } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useController, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import classes from "../AccountIncomePage.module.css";
import {
@@ -22,6 +22,8 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
control,
formState: { isSubmitting },
} = useFormContext<IncomeForm>();
const fromField = useController({ control, name: "from" });
const toField = useController({ control, name: "to" });
const [from, to] = useWatch({ control, name: ["from", "to"] });
const today = moscowDate(new Date());
const start = parseDraftDate(from);
@@ -37,39 +39,34 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
<Stack gap="sm">
<Text fw={700}>{t("income.period")}</Text>
<div className={classes.fields}>
{(["from", "to"] as const).map((name) => (
<Controller
key={name}
name={name}
control={control}
render={({ field, fieldState }) => (
<DateInput
ref={field.ref}
label={t(`income.${name}`)}
placeholder={t("income.datePlaceholder")}
valueFormat="DD.MM.YYYY"
value={parseDraftDate(field.value)}
dateParser={parseDraftDate}
fixOnBlur={false}
popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
onInput={(event) => field.onChange(event.currentTarget.value)}
onChange={(value) =>
field.onChange(value ? formatDraftDate(value) : "")
}
onBlur={(event) => {
field.onChange(event.currentTarget.value);
field.onBlur();
}}
minDate={
name === "to" && start && start <= today ? start : undefined
}
maxDate={name === "from" && end && end <= today ? end : today}
error={fieldState.error?.message}
disabled={isSubmitting}
/>
)}
/>
))}
<DatePickerInput
type="range"
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
ref={fromField.field.ref}
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) : "");
}}
onBlur={() => {
fromField.field.onBlur();
toField.field.onBlur();
}}
maxDate={today}
error={
fromField.fieldState.error?.message ??
toField.fieldState.error?.message
}
disabled={isSubmitting}
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
}}
/>
</div>
<div className={classes.actions}>
<Button type="submit" loading={isSubmitting}>
@@ -7,7 +7,7 @@ import {
Stack,
Title,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -25,21 +25,6 @@ type AccountOperationsPageLoadedProps = Extract<
const operationCategorySet = new Set<string>(operationCategories);
function toDateValue(value: string | null): Date | null {
if (!value) return null;
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
function fromDateValue(value: Date | string | null): string | null {
if (!value) return null;
if (typeof value === "string") return value;
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function fromMultiSelectValue(value: string[]) {
return value.filter((item): item is OperationCategory =>
operationCategorySet.has(item),
@@ -87,19 +72,22 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
</Alert>
) : null}
<Group align="end">
<DateInput
label={t("ledger.from")}
value={toDateValue(draftFrom)}
onChange={(value) => onDraftFromChange(fromDateValue(value))}
maxDate={toDateValue(draftTo) ?? undefined}
valueFormat="DD.MM.YYYY"
/>
<DateInput
label={t("ledger.to")}
value={toDateValue(draftTo)}
onChange={(value) => onDraftToChange(fromDateValue(value))}
minDate={toDateValue(draftFrom) ?? undefined}
<DatePickerInput
type="range"
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
value={[draftFrom, draftTo]}
onChange={([from, to]) => {
onDraftFromChange(from);
onDraftToChange(to);
}}
valueFormat="DD.MM.YYYY"
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
}}
/>
</Group>
<MultiSelect
@@ -111,6 +99,7 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
onDraftCategoriesChange(fromMultiSelectValue(value))
}
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
/>
<Group>
<Button onClick={onApply} disabled={!canApply}>
+2
View File
@@ -529,6 +529,7 @@ const ru = {
share: "Доля: {{value}}",
},
ledger: {
clearPeriod: "Очистить период",
occurredAt: "{{date}} UTC",
quantity: "{{value}} шт.",
price: "Цена: {{value}}",
@@ -550,6 +551,7 @@ const ru = {
recentLoading: "Загрузка последних событий",
recentError: "Не удалось загрузить последние события.",
recentEmpty: "Последних событий пока нет.",
period: "Период",
from: "С",
to: "По",
categories: "Категории",