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
@@ -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}>