diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index 8afef1e..b6cfef1 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -6,10 +6,6 @@ import { type StockCandles = NonNullable; -type StockPosition = NonNullable; - -const MIN_STOCK_SEARCH_LENGTH = 2; - type StockDividends = NonNullable; type StockPeriod = NonNullable< @@ -18,6 +14,8 @@ type StockPeriod = NonNullable< >["period"] >; +type StockPosition = NonNullable; + const STOCK_STALE_TIME_MS = 30_000; function useStock(identifier: string) { @@ -96,18 +94,7 @@ function useStockQuote(uid: string) { }); } -function useStockSearch(query: string) { - return useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { - params: { query: { query } }, - }), - enabled: query.length >= MIN_STOCK_SEARCH_LENGTH, - staleTime: STOCK_STALE_TIME_MS, - }); -} - export { - MIN_STOCK_SEARCH_LENGTH, type StockCandles, type StockDividends, type StockPosition, @@ -117,5 +104,4 @@ export { useStockDividends, useStockPosition, useStockQuote, - useStockSearch, }; diff --git a/src/entities/stock/index.ts b/src/entities/stock/index.ts index b349d42..9277fad 100644 --- a/src/entities/stock/index.ts +++ b/src/entities/stock/index.ts @@ -1,5 +1,4 @@ export { - MIN_STOCK_SEARCH_LENGTH, type StockCandles, type StockDividends, type StockPosition, @@ -9,5 +8,4 @@ export { useStockDividends, useStockPosition, useStockQuote, - useStockSearch, } from "./api/queries"; diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx index 1b6ae25..038529a 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx @@ -1,13 +1,12 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; -import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { StocksPage } from "./StocksPage"; @@ -18,27 +17,28 @@ describe("stock search", () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => { const query = vi.fn(); server.use( - http.get("*/api/v1/stocks", ({ request }) => { - query(new URL(request.url).searchParams.get("query")); + http.post("*/api/v1/stocks/screener/search", async ({ request }) => { + query(await request.json()); - return HttpResponse.json( - resource([ - { - apiTradeAvailable: true, - classCode: "TQBR", - instrumentUid: "unique-stock", - isin: "RU0009029540", - logoName: "sber.png", - name: "Сбербанк", - price: { currency: "RUB", value: "306.12" }, - priceAt: "2026-09-10T10:00:00Z", - realExchange: "REAL_EXCHANGE_MOEX", - shareType: "SHARE_TYPE_COMMON", - slug: "SBER", - ticker: "SBER", - }, - ]), - ); + return HttpResponse.json({ + asOf: "2026-10-10T10:00:00Z", + hasNext: false, + items: [{ + countryOfRisk: "RU", + currency: "RUB", + instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13", + isin: "RU0009029540", + logoName: "sber.png", + lot: 10, + name: "Сбербанк", + realExchange: "REAL_EXCHANGE_MOEX", + sector: "financial", + shareType: "SHARE_TYPE_COMMON", + ticker: "SBER", + }], + page: 1, + searchId: "search-id", + }); }), ); configureAuthClient({ @@ -46,7 +46,6 @@ describe("stock search", () => { clear: vi.fn(), refresh: () => Promise.resolve(false), }); - vi.useFakeTimers(); render( @@ -56,23 +55,148 @@ describe("stock search", () => { , ); - const input = screen.getByLabelText("Поиск акций"); - fireEvent.change(input, { target: { value: "S" } }); - await act(() => vi.advanceTimersByTimeAsync(300)); - expect(query).not.toHaveBeenCalled(); - fireEvent.change(input, { target: { value: "SB" } }); - await act(() => vi.advanceTimersByTimeAsync(299)); - expect(query).not.toHaveBeenCalled(); - // The final input uses real timers for network and query notifications. - vi.useRealTimers(); + const input = screen.getByLabelText("Название или тикер"); fireEvent.change(input, { target: { value: "SBER" } }); + expect(query).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Найти" })); expect( await screen.findByRole("link", { name: /Сбербанк/ }), - ).toHaveAttribute("href", "/stocks/SBER"); - expect(screen.getByText("306,12 ₽")).toBeVisible(); - expect(screen.getByText("Режим: TQBR")).toBeVisible(); + ).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13"); + expect(screen.getByText("Список получен 10.10.2026, 10:00")).toBeVisible(); + expect(screen.getByText("Лот: 10 шт.")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); - expect(screen.getByText("Мосбиржа")).toBeVisible(); - await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER")); + expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible(); + expect(screen.getByAltText("")).toHaveAttribute( + "src", + "https://invest-brands.cdn-tinkoff.ru/sberx160.png", + ); + await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" })); + }); + + it("searches by the selected sector without a text query", async () => { + const query = vi.fn(); + server.use( + http.post("*/api/v1/stocks/screener/search", async ({ request }) => { + query(await request.json()); + + return HttpResponse.json({ + asOf: "2026-10-10T10:00:00Z", + hasNext: false, + items: [], + page: 1, + searchId: "sector-search", + }); + }), + ); + configureAuthClient({ + accessToken: () => "synthetic", + clear: vi.fn(), + refresh: () => Promise.resolve(false), + }); + render( + + + + + + + , + ); + fireEvent.click(screen.getByRole("combobox", { name: "Сектор" })); + fireEvent.click(await screen.findByRole("option", { name: "Энергетика" })); + fireEvent.click(screen.getByRole("button", { name: "Найти" })); + + await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ + sector: "energy", + })); + }); + + it("shows known result pages and reads the selected page", async () => { + const requestedPages = vi.fn(); + server.use( + http.post("*/api/v1/stocks/screener/search", () => HttpResponse.json({ + asOf: "2026-10-10T10:00:00Z", + hasNext: true, + items: [], + page: 1, + searchId: "paged-search", + })), + http.get("*/api/v1/stocks/screener/search/paged-search", ({ request }) => { + const page = Number(new URL(request.url).searchParams.get("page")); + requestedPages(page); + + return HttpResponse.json({ + asOf: "2026-10-10T10:00:00Z", + hasNext: false, + items: [], + page, + searchId: "paged-search", + }); + }), + ); + configureAuthClient({ + accessToken: () => "synthetic", + clear: vi.fn(), + refresh: () => Promise.resolve(false), + }); + render( + + + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Найти" })); + fireEvent.click(await screen.findByRole( + "button", + { name: "Страница 2" }, + )); + + await waitFor(() => + expect(requestedPages).toHaveBeenCalledExactlyOnceWith(2), + ); + }); + + it("disables filters and shows loading while searching", async () => { + const { promise, resolve } = Promise.withResolvers(); + server.use( + http.post("*/api/v1/stocks/screener/search", async () => { + await promise; + + return HttpResponse.json({ + asOf: "2026-10-10T10:00:00Z", + hasNext: false, + items: [], + page: 1, + searchId: "pending-search", + }); + }), + ); + configureAuthClient({ + accessToken: () => "synthetic", + clear: vi.fn(), + refresh: () => Promise.resolve(false), + }); + render( + + + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Найти" })); + + await waitFor(() => expect( + screen.getByRole("combobox", { name: "Сектор" }), + ).toBeDisabled()); + expect(screen.getByRole("button", { name: "Найти" })).toHaveAttribute( + "data-loading", + "true", + ); + resolve(); }); }); diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx index 6056e71..d380ff9 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx @@ -1,89 +1,274 @@ -import type { FC } from "react"; - -import { Badge, Group, Text } from "@mantine/core"; -import { useTranslation } from "react-i18next"; +import type { ChangeEvent, FC } from "react"; import { - InstrumentSearch, - InstrumentSearchCard, -} from "@/features/instrument-search"; -import { formatMoney, formatSnapshotTime } from "@/shared/lib"; + Alert, + Anchor, + Avatar, + Badge, + Button, + Card, + Fieldset, + Group, + NumberInput, + Pagination, + Select, + SimpleGrid, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; -import type { StocksPageVm } from "./StocksPage.vm"; +import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib"; + +import type { Criteria, StocksPageVm } from "./StocksPage.vm"; + +import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm"; + +type CriteriaField = { + label: keyof Criteria; + options?: readonly string[]; + type?: "number"; +}; + +type StockCardProps = { item: StocksPageVm["items"][number] }; + +type StockCriteriaFieldProps = { + criteria: Criteria; + field: CriteriaField; + onChange: (name: keyof Criteria, value: string) => void; +}; type StocksPageViewProps = { vm: StocksPageVm }; +const criteriaFields: CriteriaField[] = [ + { label: "query" }, + { + label: "sector", + options: [ + "consumer", + "energy", + "financial", + "health_care", + "industrials", + "it", + "materials", + "real_estate", + "telecom", + "utilities", + ], + }, + { + label: "shareType", + options: [ + "SHARE_TYPE_ADR", + "SHARE_TYPE_COMMON", + "SHARE_TYPE_GDR", + "SHARE_TYPE_PREFERRED", + ], + }, + { label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] }, + { + label: "realExchange", + options: [ + "REAL_EXCHANGE_MOEX", + "REAL_EXCHANGE_RTS", + "REAL_EXCHANGE_OTC", + "REAL_EXCHANGE_DEALER", + ], + }, + { label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] }, + { label: "maxLot", type: "number" }, +]; + +function numberChange( + field: keyof Criteria, + onChange: StockCriteriaFieldProps["onChange"], +) { + return (value: number | string) => onChange(field, String(value)); +} + +function selectChange( + field: keyof Criteria, + onChange: StockCriteriaFieldProps["onChange"], +) { + return (value: null | string) => onChange(field, value ?? ""); +} + +function selectOptions(field: CriteriaField, translate: ReturnType["t"]) { + return field.options?.map(value => ({ + label: translate(`stock.screener.options.${field.label}.${value}`, { + defaultValue: value, + }), + value, + })); +} + +function textChange( + field: keyof Criteria, + onChange: StockCriteriaFieldProps["onChange"], +) { + return (event: ChangeEvent) => + onChange(field, event.currentTarget.value); +} + +const StockCriteriaField: FC = (props) => { + const { criteria, field, onChange } = props; + const { t } = useTranslation(); + const options = selectOptions(field, t); + if (field.type === "number") return ( + + ); + + if (options) return ( +