fix(ui): подключить фильтры статистики с проверкой срезов
This commit is contained in:
1 parent
52af82af42
commit
4fd844f977
5 files changed
+72
-37
No files matched your search
@@ -26,8 +26,7 @@ import {
|
||||
type StatisticsSliceKind,
|
||||
} from "@/components/data-grids/statistics-breakdown-data-grid"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import type { Filter } from "@/components/reui/filters"
|
||||
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
|
||||
import { StatisticsFilters } from "@/components/statistics/statistics-filters"
|
||||
import {
|
||||
isStatisticsPivotDim,
|
||||
isStatisticsSliceKind,
|
||||
@@ -129,29 +128,6 @@ function readSlices(sp: URLSearchParams): CubeSlices {
|
||||
return next
|
||||
}
|
||||
|
||||
function slicesToFilters(slices: CubeSlices): Filter[] {
|
||||
return SLICE_KEYS.flatMap((key) => {
|
||||
const val = slices[key]
|
||||
if (!val) return []
|
||||
return [{ id: key, field: key, operator: "is", values: [val] }]
|
||||
})
|
||||
}
|
||||
|
||||
function filtersToSlices(filters: Filter[]): CubeSlices {
|
||||
const next: CubeSlices = {}
|
||||
for (const f of filters) {
|
||||
const raw = String(f.values[0] ?? "").trim()
|
||||
if (!raw) continue
|
||||
if (f.field === "country") next.country = raw.toUpperCase().slice(0, 2)
|
||||
else if (f.field === "service") next.service = raw
|
||||
else if (f.field === "asn") next.asn = raw.replace(/[^\d]/g, "")
|
||||
else if (f.field === "serverId") next.serverId = raw
|
||||
else if (f.field === "userId") next.userId = raw
|
||||
else if (f.field === "iface") next.iface = raw
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
|
||||
const serverId = slices.serverId ? Number(slices.serverId) : undefined
|
||||
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
|
||||
@@ -261,7 +237,6 @@ function StatisticsPageInner() {
|
||||
|
||||
const range = useMemo(() => readRange(searchParams), [searchParams])
|
||||
const slices = useMemo(() => readSlices(searchParams), [searchParams])
|
||||
const filters = useMemo(() => slicesToFilters(slices), [slices])
|
||||
const dim = useMemo(() => readDim(searchParams), [searchParams])
|
||||
const view = useMemo(() => readView(searchParams), [searchParams])
|
||||
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
|
||||
@@ -506,9 +481,7 @@ function StatisticsPageInner() {
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
filters={filters}
|
||||
onFiltersChange={(next) => setSlices(filtersToSlices(next))}
|
||||
filterFields={STATISTICS_FILTER_FIELDS}
|
||||
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
|
||||
countLabel={countLabel}
|
||||
/>
|
||||
<SliceChips
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { QueryFilters } from "@/components/data-grids/query-filters"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
|
||||
import { filterQueryToSlices, slicesToFilterQuery, type StatisticsSlices } from "@/lib/data-filters/statistics-filter-query"
|
||||
|
||||
/** Parent keys this editor by URL slices so external navigation resets drafts. */
|
||||
export function StatisticsFilters({ slices, onApply }: {
|
||||
slices: StatisticsSlices
|
||||
onApply: (slices: StatisticsSlices) => void
|
||||
}) {
|
||||
const [query, setQuery] = useState(() => slicesToFilterQuery(slices))
|
||||
const result = filterQueryToSlices(query)
|
||||
return <div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<QueryFilters fields={STATISTICS_FILTER_FIELDS} query={query} onQueryChange={setQuery} />
|
||||
<Button size="sm" variant="outline" disabled={result.status !== "valid"}
|
||||
onClick={() => { if (result.status === "valid") onApply(result.slices) }}>
|
||||
Применить срез
|
||||
</Button>
|
||||
</div>
|
||||
{result.status === "invalid" && <p role="alert" className="text-xs text-destructive">{result.error}</p>}
|
||||
{result.status === "draft" && <p role="status" className="text-xs text-muted-foreground">Завершите условие. Текущий срез сохранён.</p>}
|
||||
</div>
|
||||
}
|
||||
@@ -338,6 +338,14 @@ BGP, backups и BackupsHistory используют FilterQuery и соврем
|
||||
|
||||
Квота после подготовки: 49% пятичасовой и 97% недельной использовано. Сохранить подготовку локальным коммитом без push. Чужие изменения skill/AGENTS/.codex не включать.
|
||||
|
||||
## Этап 4 — блок 13: подключение Filters статистики (2026-09-14)
|
||||
|
||||
StatisticsFilters подключён вместо старого массива Filter. Поля предлагают только is, адаптер дополнительно отклоняет неподдерживаемые выражения. Локальный черновик не изменяет URL до явного «Применить срез»; invalid/draft блокируют применение и сохраняют действующие срезы. Ключ редактора зависит от URL-срезов, поэтому внешний переход сбрасывает черновик. Старые функции slicesToFilters/filtersToSlices страницы удалены.
|
||||
|
||||
TypeScript до и после первого браузерного прогона и scoped ESLint прошли. Расширенный браузерный сценарий статистики прошёл: сортировка/переход в срез, восстановление фильтра из URL, редактирование без изменения URL, применение, сброс, отклонение RUS без обрезания до RU и внешняя смена страны на DE. В первом расширенном прогоне исправлен неоднозначный локатор alert; повтор — 1 passed. Проверки через HTTP-моки.
|
||||
|
||||
На последнем чтении использовано 62% пятичасовой и 99% недельной квоты. Старые Filters/fallback/helper ещё физически остаются, поэтому этап 4 НЕ завершён. Следующий шаг — удалить неиспользуемый fallback DataPageToolbar и старые Filters/evaluator после проверки импортов, обновить тест совместимости, затем выполнить полную регрессию и production build. Сохранить этот блок локальным коммитом без push.
|
||||
|
||||
## Карта миграции (исходная инвентаризация этапа 0)
|
||||
|
||||
Текущая связка: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. Актуальный ReUI MCP ранее подтвердил useTable/dataGridFeatures для Data Grid и дерево FilterQuery вместо filters/onChange для Filters. Перед установкой перепроверить совместимые версии, сохранить семантику существующих фильтров (простые условия → AND) и закрепить lockfile.
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
import type { FilterFieldConfig } from "@/components/reui/filters"
|
||||
import type { FilterField } from "@/components/reui/filters/filters-types"
|
||||
|
||||
export const STATISTICS_FILTER_FIELDS: FilterFieldConfig[] = [
|
||||
export const STATISTICS_FILTER_FIELDS: FilterField[] = [
|
||||
{
|
||||
key: "country",
|
||||
id: "country",
|
||||
label: "Страна",
|
||||
type: "text",
|
||||
placeholder: "ISO, напр. US",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
key: "service",
|
||||
id: "service",
|
||||
label: "Сервис",
|
||||
type: "text",
|
||||
placeholder: "https, dns…",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
key: "asn",
|
||||
id: "asn",
|
||||
label: "ASN",
|
||||
type: "text",
|
||||
placeholder: "номер ASN",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
key: "serverId",
|
||||
id: "serverId",
|
||||
label: "Сервер",
|
||||
type: "text",
|
||||
placeholder: "id сервера",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
id: "userId",
|
||||
label: "Пользователь",
|
||||
type: "text",
|
||||
placeholder: "id пользователя",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
{
|
||||
key: "iface",
|
||||
id: "iface",
|
||||
label: "Интерфейс",
|
||||
type: "text",
|
||||
placeholder: "ether1",
|
||||
defaultOperator: "is",
|
||||
operators: [{ value: "is", label: "равно" }],
|
||||
},
|
||||
]
|
||||
@@ -107,4 +107,25 @@ test("Статистика: CompactDataGrid сохраняет 12 строк, с
|
||||
await expect(rows.first()).toContainText("Пользователь 12")
|
||||
await rows.first().click()
|
||||
await expect(page).toHaveURL(/userId=user-12/)
|
||||
await expect(page.getByRole("button", { name: "user-12", exact: true })).toBeVisible()
|
||||
await page.getByRole("button", { name: "user-12", exact: true }).click()
|
||||
await page.getByPlaceholder("id пользователя").fill("user-2")
|
||||
await page.getByPlaceholder("id пользователя").press("Enter")
|
||||
await expect(page).toHaveURL(/userId=user-12/)
|
||||
await page.getByRole("button", { name: "Применить срез", exact: true }).click()
|
||||
await expect(page).toHaveURL(/userId=user-2/)
|
||||
await page.getByRole("button", { name: "Очистить", exact: true }).click()
|
||||
await expect(page).toHaveURL(/userId=user-2/)
|
||||
await page.getByRole("button", { name: "Применить срез", exact: true }).click()
|
||||
await expect(page).not.toHaveURL(/userId=/)
|
||||
await page.goto("/statistics?from=2026-09-01&to=2026-09-13&country=RU")
|
||||
await page.getByRole("button", { name: "RU", exact: true }).click()
|
||||
await page.getByPlaceholder("ISO, напр. US").fill("RUS")
|
||||
await page.getByPlaceholder("ISO, напр. US").press("Enter")
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Укажите двухбуквенный код страны." })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Применить срез", exact: true })).toBeDisabled()
|
||||
await expect(page).toHaveURL(/country=RU(?:&|$)/)
|
||||
await page.evaluate(() => window.history.pushState(null, "", "/statistics?country=DE"))
|
||||
await expect(page.getByRole("button", { name: "DE", exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Укажите двухбуквенный код страны.", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
Reference in new issue
Block a user