feat(selection): rework results layout and add sector filter
- move the filter bar out of the list column into a full-width top section; list and detail stay side by side below on md+, stacking search > list > detail on small screens - add sector select fed by the run's concept-board aggregates; options show per-sector stock counts ordered by count desc, stale selections reset when the aggregate list changes - persist sector in the route search, results/runs query keys, and refresh sector aggregates when a selection run finishes
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
type SelectionResultsQuery,
|
type SelectionResultsQuery,
|
||||||
type SelectionRunAccepted,
|
type SelectionRunAccepted,
|
||||||
type SelectionRunRequest,
|
type SelectionRunRequest,
|
||||||
|
type SelectionSectors,
|
||||||
type SelectionStrategy,
|
type SelectionStrategy,
|
||||||
} from "./selection.types"
|
} from "./selection.types"
|
||||||
|
|
||||||
@@ -63,9 +64,27 @@ function buildSelectionQueryParams(query: SelectionResultsQuery) {
|
|||||||
if (query.search) params.set("search", query.search)
|
if (query.search) params.set("search", query.search)
|
||||||
if (query.category) params.set("category", query.category)
|
if (query.category) params.set("category", query.category)
|
||||||
if (query.sort) params.set("sort", query.sort)
|
if (query.sort) params.set("sort", query.sort)
|
||||||
|
if (query.sector) params.set("sector", query.sector)
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSelectionResultSectors(
|
||||||
|
strategy: SelectionStrategy,
|
||||||
|
targetTradeDate?: string,
|
||||||
|
sectorType: "concept" | "industry" = "concept",
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
strategy,
|
||||||
|
sector_type: sectorType,
|
||||||
|
})
|
||||||
|
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
|
||||||
|
return requestJson<SelectionSectors>(
|
||||||
|
`/api/v1/selection/sectors?${params.toString()}`,
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function triggerSelectionRun(
|
export function triggerSelectionRun(
|
||||||
request: SelectionRunRequest,
|
request: SelectionRunRequest,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ describe("selection query hooks", () => {
|
|||||||
"平安",
|
"平安",
|
||||||
"pullback",
|
"pullback",
|
||||||
"score_desc",
|
"score_desc",
|
||||||
|
"",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useEffect } from "react"
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getSelectionChart,
|
getSelectionChart,
|
||||||
|
getSelectionResultSectors,
|
||||||
getSelectionResults,
|
getSelectionResults,
|
||||||
getSelectionRun,
|
getSelectionRun,
|
||||||
triggerSelectionRun,
|
triggerSelectionRun,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
type SelectionResults,
|
type SelectionResults,
|
||||||
type SelectionResultsQuery,
|
type SelectionResultsQuery,
|
||||||
type SelectionRunRequest,
|
type SelectionRunRequest,
|
||||||
|
type SelectionSectors,
|
||||||
type SelectionStrategy,
|
type SelectionStrategy,
|
||||||
} from "./selection.types"
|
} from "./selection.types"
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ export const selectionResultsQueryKey = (
|
|||||||
query.search ?? "",
|
query.search ?? "",
|
||||||
query.category ?? "all",
|
query.category ?? "all",
|
||||||
query.sort ?? "code",
|
query.sort ?? "code",
|
||||||
|
query.sector ?? "",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const selectionRunQueryKey = (
|
export const selectionRunQueryKey = (
|
||||||
@@ -69,8 +72,15 @@ export const selectionRunQueryKey = (
|
|||||||
query.search ?? "",
|
query.search ?? "",
|
||||||
query.category ?? "all",
|
query.category ?? "all",
|
||||||
query.sort ?? "code",
|
query.sort ?? "code",
|
||||||
|
query.sector ?? "",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export const selectionSectorsQueryKey = (
|
||||||
|
strategy: SelectionStrategy,
|
||||||
|
targetTradeDate?: string,
|
||||||
|
sectorType: "concept" | "industry" = "concept",
|
||||||
|
) => ["selection", "sectors", strategy, targetTradeDate, sectorType] as const
|
||||||
|
|
||||||
export function flattenSelectionResults(
|
export function flattenSelectionResults(
|
||||||
data: InfiniteData<SelectionResults> | undefined,
|
data: InfiniteData<SelectionResults> | undefined,
|
||||||
): SelectionResults | undefined {
|
): SelectionResults | undefined {
|
||||||
@@ -120,6 +130,18 @@ export function useSelectionChart(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSelectionResultSectors(
|
||||||
|
strategy: SelectionStrategy,
|
||||||
|
targetTradeDate?: string,
|
||||||
|
sectorType: "concept" | "industry" = "concept",
|
||||||
|
) {
|
||||||
|
return useQuery<SelectionSectors, Error>({
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
getSelectionResultSectors(strategy, targetTradeDate, sectorType, signal),
|
||||||
|
queryKey: selectionSectorsQueryKey(strategy, targetTradeDate, sectorType),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useSelectionRun(
|
export function useSelectionRun(
|
||||||
runId: string | null,
|
runId: string | null,
|
||||||
resultQuery: SelectionResultsListQuery = defaultListQuery,
|
resultQuery: SelectionResultsListQuery = defaultListQuery,
|
||||||
@@ -173,9 +195,14 @@ export function invalidateSelectionResults(
|
|||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
) {
|
) {
|
||||||
return queryClient.invalidateQueries({
|
return Promise.all([
|
||||||
queryKey: ["selection", "results", strategy],
|
queryClient.invalidateQueries({
|
||||||
})
|
queryKey: ["selection", "results", strategy],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["selection", "sectors", strategy],
|
||||||
|
}),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextSelectionPageParam(
|
function nextSelectionPageParam(
|
||||||
|
|||||||
@@ -25,6 +25,27 @@ export interface SelectionResultsQuery {
|
|||||||
search?: string
|
search?: string
|
||||||
category?: Exclude<SelectionCategoryFilter, "all">
|
category?: Exclude<SelectionCategoryFilter, "all">
|
||||||
sort?: SelectionSort
|
sort?: SelectionSort
|
||||||
|
sector?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectionSectorType = "concept" | "industry"
|
||||||
|
|
||||||
|
export const selectionSectorTypes = ["concept", "industry"] as const
|
||||||
|
|
||||||
|
export interface SelectionSectorAggregate {
|
||||||
|
sector_code: string
|
||||||
|
sector_name: string
|
||||||
|
stock_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionSectors {
|
||||||
|
strategy: SelectionStrategy
|
||||||
|
target_trade_date: string | null
|
||||||
|
run_id: string | null
|
||||||
|
status: SelectionRunStatus
|
||||||
|
snapshot_trade_date: string | null
|
||||||
|
sector_type: SelectionSectorType
|
||||||
|
sectors: SelectionSectorAggregate[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectionRunStatus =
|
export type SelectionRunStatus =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||||
|
|
||||||
import { Input } from "@/shared/ui/input"
|
import { Input } from "@/shared/ui/input"
|
||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/ui/select"
|
} from "@/shared/ui/select"
|
||||||
|
|
||||||
import type { SelectionResults, SelectionSort } from "../api/selection.types"
|
import type {
|
||||||
|
SelectionResults,
|
||||||
|
SelectionSectorAggregate,
|
||||||
|
SelectionSort,
|
||||||
|
} from "../api/selection.types"
|
||||||
import { SignalDetailPanel } from "./signal-detail-panel"
|
import { SignalDetailPanel } from "./signal-detail-panel"
|
||||||
import { SignalRecordList } from "./signal-record-list"
|
import { SignalRecordList } from "./signal-record-list"
|
||||||
import {
|
import {
|
||||||
@@ -30,12 +34,15 @@ const SCORE_SORT_OPTIONS: ReadonlyArray<{
|
|||||||
{ label: "评分从低到高", value: "score_asc" },
|
{ label: "评分从低到高", value: "score_asc" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const ALL_SECTORS_VALUE = "all"
|
||||||
|
|
||||||
interface SelectionResultsWorkbenchProps {
|
interface SelectionResultsWorkbenchProps {
|
||||||
hasNextPage: boolean
|
hasNextPage: boolean
|
||||||
isFetchNextPageError: boolean
|
isFetchNextPageError: boolean
|
||||||
isFetchingNextPage: boolean
|
isFetchingNextPage: boolean
|
||||||
onLoadMore: () => void | Promise<unknown>
|
onLoadMore: () => void | Promise<unknown>
|
||||||
result: SelectionResults
|
result: SelectionResults
|
||||||
|
sectorOptions: ReadonlyArray<SelectionSectorAggregate>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectionResultsWorkbench({
|
export function SelectionResultsWorkbench({
|
||||||
@@ -44,12 +51,14 @@ export function SelectionResultsWorkbench({
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
result,
|
result,
|
||||||
|
sectorOptions,
|
||||||
}: SelectionResultsWorkbenchProps) {
|
}: SelectionResultsWorkbenchProps) {
|
||||||
const search = useSearch({ from: "/_workspace/selection" })
|
const search = useSearch({ from: "/_workspace/selection" })
|
||||||
const navigate = useNavigate({ from: "/selection" })
|
const navigate = useNavigate({ from: "/selection" })
|
||||||
const query = search.search ?? ""
|
const query = search.search ?? ""
|
||||||
const category = search.category ?? "all"
|
const category = search.category ?? "all"
|
||||||
const sort = search.sort ?? "code"
|
const sort = search.sort ?? "code"
|
||||||
|
const sector = search.sector
|
||||||
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
||||||
const loadMoreRequestPending = useRef(false)
|
const loadMoreRequestPending = useRef(false)
|
||||||
|
|
||||||
@@ -68,11 +77,32 @@ export function SelectionResultsWorkbench({
|
|||||||
const selectedStock =
|
const selectedStock =
|
||||||
visibleStocks.find((stock) => getStockKey(stock) === selectedKey) ??
|
visibleStocks.find((stock) => getStockKey(stock) === selectedKey) ??
|
||||||
visibleStocks[0]
|
visibleStocks[0]
|
||||||
|
const sectorSelectItems = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "全部板块", value: ALL_SECTORS_VALUE },
|
||||||
|
...sectorOptions.map((option) => ({
|
||||||
|
label: option.sector_name,
|
||||||
|
value: option.sector_code,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[sectorOptions],
|
||||||
|
)
|
||||||
|
|
||||||
|
// The aggregate list is the source of truth for valid sectors; drop a stale
|
||||||
|
// value left over from another strategy or trade date.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sector) return
|
||||||
|
if (sectorOptions.some((option) => option.sector_code === sector)) return
|
||||||
|
void navigate({
|
||||||
|
search: (previous) => ({ ...previous, sector: undefined }),
|
||||||
|
})
|
||||||
|
}, [navigate, sector, sectorOptions])
|
||||||
|
|
||||||
function updateSearch(next: {
|
function updateSearch(next: {
|
||||||
search?: string
|
search?: string
|
||||||
category?: SignalCategoryFilter
|
category?: SignalCategoryFilter
|
||||||
sort?: SelectionSort
|
sort?: SelectionSort
|
||||||
|
sector?: string
|
||||||
}) {
|
}) {
|
||||||
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
||||||
}
|
}
|
||||||
@@ -89,6 +119,10 @@ export function SelectionResultsWorkbench({
|
|||||||
updateSearch({ sort: value })
|
updateSearch({ sort: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSectorChange(value: string) {
|
||||||
|
updateSearch({ sector: value === ALL_SECTORS_VALUE ? undefined : value })
|
||||||
|
}
|
||||||
|
|
||||||
function requestLoadMore() {
|
function requestLoadMore() {
|
||||||
if (
|
if (
|
||||||
loadMoreRequestPending.current ||
|
loadMoreRequestPending.current ||
|
||||||
@@ -105,9 +139,9 @@ export function SelectionResultsWorkbench({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden md:grid md:grid-cols-[320px_minmax(0,1fr)]">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border/80 bg-card">
|
<section className="shrink-0 rounded-md border border-border/80 bg-card">
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
<div className="flex flex-wrap items-center gap-1.5 p-2.5">
|
||||||
<Input
|
<Input
|
||||||
aria-label="搜索命中股票"
|
aria-label="搜索命中股票"
|
||||||
className="h-11 min-w-0 flex-1 bg-background text-sm sm:max-w-[260px] md:h-8"
|
className="h-11 min-w-0 flex-1 bg-background text-sm sm:max-w-[260px] md:h-8"
|
||||||
@@ -116,6 +150,38 @@ export function SelectionResultsWorkbench({
|
|||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
items={sectorSelectItems}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (typeof value === "string") handleSectorChange(value)
|
||||||
|
}}
|
||||||
|
value={sector ?? ALL_SECTORS_VALUE}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
aria-label="筛选板块"
|
||||||
|
className="h-11 w-full bg-background text-sm sm:w-auto sm:min-w-36 md:h-8"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="筛选板块" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value={ALL_SECTORS_VALUE}>全部板块</SelectItem>
|
||||||
|
{sectorOptions.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.sector_code}
|
||||||
|
value={option.sector_code}
|
||||||
|
>
|
||||||
|
<span className="flex w-full items-center justify-between gap-3">
|
||||||
|
<span>{option.sector_name}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{option.stock_count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
items={signalCategoryOptions}
|
items={signalCategoryOptions}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -170,24 +236,28 @@ export function SelectionResultsWorkbench({
|
|||||||
筛选结果 {stocksTotal} 只
|
筛选结果 {stocksTotal} 只
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
|
||||||
<SignalRecordList
|
|
||||||
isFetchNextPageError={isFetchNextPageError}
|
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
|
||||||
onLoadMore={requestLoadMore}
|
|
||||||
onRetryLoadMore={() => {
|
|
||||||
loadMoreRequestPending.current = false
|
|
||||||
onLoadMore()
|
|
||||||
}}
|
|
||||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
|
||||||
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
|
||||||
stocks={visibleStocks}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<SignalDetailPanel stock={selectedStock} />
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden md:grid md:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border/80 bg-card">
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<SignalRecordList
|
||||||
|
isFetchNextPageError={isFetchNextPageError}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
onLoadMore={requestLoadMore}
|
||||||
|
onRetryLoadMore={() => {
|
||||||
|
loadMoreRequestPending.current = false
|
||||||
|
onLoadMore()
|
||||||
|
}}
|
||||||
|
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
||||||
|
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
||||||
|
stocks={visibleStocks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SignalDetailPanel stock={selectedStock} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { SelectionResultsPage } from "./selection-results-page"
|
|||||||
|
|
||||||
const routerNavigate = vi.hoisted(() => vi.fn())
|
const routerNavigate = vi.hoisted(() => vi.fn())
|
||||||
const useSelectionResults = vi.fn()
|
const useSelectionResults = vi.fn()
|
||||||
|
const useSelectionResultSectors = vi.fn()
|
||||||
const useSelectionChart = vi.fn()
|
const useSelectionChart = vi.fn()
|
||||||
const useStockSectorMembership = vi.fn()
|
const useStockSectorMembership = vi.fn()
|
||||||
const useSelectionRun = vi.fn()
|
const useSelectionRun = vi.fn()
|
||||||
@@ -31,6 +32,8 @@ vi.mock("@/features/selection/api/selection.query", async () => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
|
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
|
||||||
|
useSelectionResultSectors: (...args: unknown[]) =>
|
||||||
|
useSelectionResultSectors(...args),
|
||||||
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
|
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
|
||||||
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
|
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
|
||||||
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
||||||
@@ -169,6 +172,11 @@ describe("SelectionResultsPage", () => {
|
|||||||
isPending: false,
|
isPending: false,
|
||||||
})
|
})
|
||||||
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
|
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
|
||||||
|
useSelectionResultSectors.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
useSelectionRun.mockReturnValue(emptySelectionQuery())
|
useSelectionRun.mockReturnValue(emptySelectionQuery())
|
||||||
useTriggerSelectionRun.mockReturnValue({
|
useTriggerSelectionRun.mockReturnValue({
|
||||||
isError: false,
|
isError: false,
|
||||||
@@ -535,6 +543,45 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("filters by sector and shows per-sector counts", async () => {
|
||||||
|
useSelectionResultSectors.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
strategy: "zhixing_b1",
|
||||||
|
target_trade_date: "2026-08-08",
|
||||||
|
run_id: "run-1",
|
||||||
|
status: "success",
|
||||||
|
snapshot_trade_date: "2026-08-08",
|
||||||
|
sector_type: "concept",
|
||||||
|
sectors: [
|
||||||
|
{ sector_code: "BK0001.DC", sector_name: "机器人", stock_count: 3 },
|
||||||
|
{ sector_code: "BK0003.DC", sector_name: "数字经济", stock_count: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
|
const sectorTrigger = screen.getByRole("combobox", { name: "筛选板块" })
|
||||||
|
expect(sectorTrigger).toHaveTextContent("全部板块")
|
||||||
|
|
||||||
|
fireEvent.click(sectorTrigger)
|
||||||
|
const robotOption = await screen.findByRole("option", {
|
||||||
|
name: /机器人/,
|
||||||
|
})
|
||||||
|
expect(robotOption).toHaveTextContent("3")
|
||||||
|
fireEvent.pointerDown(robotOption, { pointerType: "mouse" })
|
||||||
|
fireEvent.click(robotOption)
|
||||||
|
|
||||||
|
expect(routerNavigate).toHaveBeenCalled()
|
||||||
|
const lastCall =
|
||||||
|
routerNavigate.mock.calls[routerNavigate.mock.calls.length - 1]
|
||||||
|
const searchUpdate = lastCall[0].search as (previous: {
|
||||||
|
sector?: string
|
||||||
|
}) => Record<string, unknown>
|
||||||
|
expect(searchUpdate({})).toEqual({ sector: "BK0001.DC" })
|
||||||
|
})
|
||||||
|
|
||||||
it("requests database-backed score sorting", async () => {
|
it("requests database-backed score sorting", async () => {
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRef, useState, type RefObject } from "react"
|
|||||||
import { PageLayout } from "@/app/layout/page-layout"
|
import { PageLayout } from "@/app/layout/page-layout"
|
||||||
import {
|
import {
|
||||||
flattenSelectionResults,
|
flattenSelectionResults,
|
||||||
|
useSelectionResultSectors,
|
||||||
useSelectionResults,
|
useSelectionResults,
|
||||||
useSelectionRun,
|
useSelectionRun,
|
||||||
useTriggerSelectionRun,
|
useTriggerSelectionRun,
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
selectionStrategies,
|
selectionStrategies,
|
||||||
type SelectionResults,
|
type SelectionResults,
|
||||||
type SelectionResultsQuery,
|
type SelectionResultsQuery,
|
||||||
|
type SelectionSectorAggregate,
|
||||||
|
type SelectionSectors,
|
||||||
type SelectionStrategy,
|
type SelectionStrategy,
|
||||||
} from "@/features/selection/api/selection.types"
|
} from "@/features/selection/api/selection.types"
|
||||||
import { Button } from "@/shared/ui/button"
|
import { Button } from "@/shared/ui/button"
|
||||||
@@ -71,6 +74,7 @@ export function SelectionResultsPage() {
|
|||||||
pageSize: selectionResultPageSize,
|
pageSize: selectionResultPageSize,
|
||||||
...(search.search ? { search: search.search } : {}),
|
...(search.search ? { search: search.search } : {}),
|
||||||
...(search.category !== "all" ? { category: search.category } : {}),
|
...(search.category !== "all" ? { category: search.category } : {}),
|
||||||
|
...(search.sector ? { sector: search.sector } : {}),
|
||||||
sort: search.sort,
|
sort: search.sort,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +83,10 @@ export function SelectionResultsPage() {
|
|||||||
targetTradeDate || undefined,
|
targetTradeDate || undefined,
|
||||||
resultQuery,
|
resultQuery,
|
||||||
)
|
)
|
||||||
|
const sectorsQuery = useSelectionResultSectors(
|
||||||
|
strategy,
|
||||||
|
targetTradeDate || undefined,
|
||||||
|
)
|
||||||
const resultsSnapshot = flattenSelectionResults(results.data)
|
const resultsSnapshot = flattenSelectionResults(results.data)
|
||||||
const persistedRunningRunId =
|
const persistedRunningRunId =
|
||||||
resultsSnapshot?.status === "running" ? resultsSnapshot.run_id : null
|
resultsSnapshot?.status === "running" ? resultsSnapshot.run_id : null
|
||||||
@@ -156,7 +164,11 @@ export function SelectionResultsPage() {
|
|||||||
setRerunDialogOpen(false)
|
setRerunDialogOpen(false)
|
||||||
trigger.reset()
|
trigger.reset()
|
||||||
void navigate({
|
void navigate({
|
||||||
search: (previous) => ({ ...previous, category: "all" }),
|
search: (previous) => ({
|
||||||
|
...previous,
|
||||||
|
category: "all",
|
||||||
|
sector: undefined,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +224,7 @@ export function SelectionResultsPage() {
|
|||||||
isFetchingNextPage={Boolean(listQuery.isFetchingNextPage)}
|
isFetchingNextPage={Boolean(listQuery.isFetchingNextPage)}
|
||||||
onLoadMore={() => listQuery.fetchNextPage()}
|
onLoadMore={() => listQuery.fetchNextPage()}
|
||||||
result={displayedResult}
|
result={displayedResult}
|
||||||
|
sectors={sectorsQuery.data}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -376,8 +389,11 @@ interface ResultStateProps {
|
|||||||
isFetchingNextPage: boolean
|
isFetchingNextPage: boolean
|
||||||
onLoadMore: () => void | Promise<unknown>
|
onLoadMore: () => void | Promise<unknown>
|
||||||
result: SelectionResults
|
result: SelectionResults
|
||||||
|
sectors: SelectionSectors | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_SECTOR_OPTIONS: ReadonlyArray<SelectionSectorAggregate> = []
|
||||||
|
|
||||||
function parseLocalTradeDate(value: string): Date | undefined {
|
function parseLocalTradeDate(value: string): Date | undefined {
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
||||||
if (!match) return undefined
|
if (!match) return undefined
|
||||||
@@ -511,6 +527,7 @@ function ResultState({
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
result,
|
result,
|
||||||
|
sectors,
|
||||||
}: ResultStateProps) {
|
}: ResultStateProps) {
|
||||||
return result.signal_count === 0 ? (
|
return result.signal_count === 0 ? (
|
||||||
<NoSignalState />
|
<NoSignalState />
|
||||||
@@ -521,6 +538,7 @@ function ResultState({
|
|||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
onLoadMore={onLoadMore}
|
onLoadMore={onLoadMore}
|
||||||
result={result}
|
result={result}
|
||||||
|
sectorOptions={sectors?.sectors ?? EMPTY_SECTOR_OPTIONS}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,11 @@ const selectionRoute = createRoute({
|
|||||||
)
|
)
|
||||||
? (rawSort as SelectionSort)
|
? (rawSort as SelectionSort)
|
||||||
: "code"
|
: "code"
|
||||||
return { page, pageSize, search: searchValue, category, sort }
|
const sector =
|
||||||
|
typeof search.sector === "string" && search.sector.trim()
|
||||||
|
? search.sector.trim().slice(0, 60)
|
||||||
|
: undefined
|
||||||
|
return { page, pageSize, search: searchValue, category, sort, sector }
|
||||||
},
|
},
|
||||||
component: SelectionResultsPage,
|
component: SelectionResultsPage,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user