diff --git a/src/pages/account-events/ui/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage.filters.test.tsx index bd09a61..b402669 100644 --- a/src/pages/account-events/ui/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.filters.test.tsx @@ -55,7 +55,7 @@ describe("Upcoming event date filters", () => { ); render( - + { , ); - 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("За выбранный период событий не найдено"), diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx index ccc82e5..518706c 100644 --- a/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx @@ -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 = (props) => { ) : null} {t("events.hint")} - { + props.onDraftFromChange(from); + props.onDraftToChange(to); + }} valueFormat="DD.MM.YYYY" - dateParser={parseDate} - allowDeselect - popoverProps={{ middlewares: { shift: { crossAxis: true } } }} - /> - diff --git a/src/pages/account-income/ui/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage.module.css index d658516..cf9807e 100644 --- a/src/pages/account-income/ui/AccountIncomePage.module.css +++ b/src/pages/account-income/ui/AccountIncomePage.module.css @@ -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); } diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage.test.tsx index d93466f..3a587f3 100644 --- a/src/pages/account-income/ui/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage.test.tsx @@ -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((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((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", () => { diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts index ba5648c..07f4a66 100644 --- a/src/pages/account-income/ui/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -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 }); }; diff --git a/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx index ec9cce9..ae78c66 100644 --- a/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx +++ b/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx @@ -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 = (props) => { control, formState: { isSubmitting }, } = useFormContext(); + 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 = (props) => { {t("income.period")}
- {(["from", "to"] as const).map((name) => ( - ( - 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} - /> - )} - /> - ))} + { + 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 } }, + }} + />