feat(web): filter domains by template and status
The domains list had no way to narrow itself down, so an operator with dozens of zones had to scan the whole table to find the drifted ones. Add two client-side filters above the table (the full list already arrives in one request, so server-side filtering would only add contract surface). The status filter compares against the same derived value the row's badge renders — a domain with no template shows "без шаблона" and must not match "drift" just because it kept a stale last_check_status from before its template was detached, so that derivation lives in one function used by both. Sorting is now decided in the store: ListDomains orders by zone_name instead of created_at, so every consumer of the list gets the same order and the filters never reorder anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
This commit is contained in:
@@ -152,3 +152,85 @@ test("кнопки удаления домена нет", async () => {
|
||||
|
||||
expect(screen.queryByRole("button", { name: /удалить/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("фильтр по шаблону оставляет только домены с этим шаблоном", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
|
||||
|
||||
expect(screen.getByText("test.org.")).toBeInTheDocument()
|
||||
expect(screen.queryByText("example.com.")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("фильтр по шаблону «Без шаблона» оставляет только непривязанные домены", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^без шаблона$/i }))
|
||||
|
||||
expect(screen.getByText("example.com.")).toBeInTheDocument()
|
||||
expect(screen.queryByText("test.org.")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("фильтр по статусу оставляет только домены с этим статусом", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /фильтр по статусу/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^in sync$/i }))
|
||||
|
||||
expect(screen.getByText("test.org.")).toBeInTheDocument()
|
||||
expect(screen.queryByText("example.com.")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// d1 не имеет шаблона, но хранит протухший lastCheckStatus="drift". Бейдж
|
||||
// показывает «без шаблона», поэтому и фильтр обязан считать его таким же —
|
||||
// иначе фильтр и таблица говорят разное об одной строке.
|
||||
test("домен без шаблона не попадает в фильтр «drift», хотя хранит такой статус", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /фильтр по статусу/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^drift$/i }))
|
||||
|
||||
expect(screen.queryByText("example.com.")).not.toBeInTheDocument()
|
||||
expect(await screen.findByText(/под фильтр ничего не попало/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("комбинация фильтров сужает список до пересечения", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
|
||||
await user.click(screen.getByRole("combobox", { name: /фильтр по статусу/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^без шаблона$/i }))
|
||||
|
||||
expect(await screen.findByText(/под фильтр ничего не попало/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("счётчик показывает, сколько доменов отфильтровано", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
expect(screen.getByText(/показано 2 из 2/i)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /фильтр по шаблону/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
|
||||
|
||||
expect(screen.getByText(/показано 1 из 2/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -25,8 +25,31 @@ import {
|
||||
useSetDomainTemplate,
|
||||
useTemplates,
|
||||
} from "@/hooks/useApi"
|
||||
import type { Domain } from "@/api/types"
|
||||
|
||||
const NO_TEMPLATE = "__none__"
|
||||
// Значения «фильтр не задан». Отдельные от NO_TEMPLATE: «без шаблона» — это
|
||||
// осознанный выбор пользователя, а не отсутствие фильтра.
|
||||
const ANY_TEMPLATE = "__any_template__"
|
||||
const ANY_STATUS = "__any_status__"
|
||||
|
||||
const STATUS_FILTER_ITEMS = [
|
||||
{ value: ANY_STATUS, label: "Все статусы" },
|
||||
{ value: "in_sync", label: "in sync" },
|
||||
{ value: "drift", label: "drift" },
|
||||
{ value: "error", label: "error" },
|
||||
{ value: "unknown", label: "unknown" },
|
||||
{ value: "no_template", label: "без шаблона" },
|
||||
]
|
||||
|
||||
// Статус, который пользователь реально видит в бейдже строки: у домена без
|
||||
// шаблона диффа нет, поэтому его хранимый lastCheckStatus (возможно,
|
||||
// протухший с тех пор, как шаблон отвязали) не показывается — и не должен
|
||||
// фильтроваться. Единственная точка вычисления: иначе фильтр и таблица
|
||||
// разойдутся в том, что за строка перед пользователем.
|
||||
function displayStatus(domain: Domain): string {
|
||||
return domain.templateId ? (domain.lastCheckStatus ?? "unknown") : "no_template"
|
||||
}
|
||||
|
||||
export function DomainsPage() {
|
||||
const domains = useDomains()
|
||||
@@ -39,6 +62,30 @@ export function DomainsPage() {
|
||||
const templateList = templates.data ?? []
|
||||
const domainList = domains.data ?? []
|
||||
|
||||
const [templateFilter, setTemplateFilter] = useState<string>(ANY_TEMPLATE)
|
||||
const [statusFilter, setStatusFilter] = useState<string>(ANY_STATUS)
|
||||
|
||||
// Порядок задаёт бэкенд (ListDomains сортирует по zone_name) — фильтры
|
||||
// только выбрасывают строки, никогда не пересортировывают.
|
||||
const visibleDomains = domainList.filter((d) => {
|
||||
if (templateFilter === NO_TEMPLATE && d.templateId) return false
|
||||
if (
|
||||
templateFilter !== ANY_TEMPLATE &&
|
||||
templateFilter !== NO_TEMPLATE &&
|
||||
d.templateId !== templateFilter
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (statusFilter !== ANY_STATUS && displayStatus(d) !== statusFilter) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const templateFilterItems = [
|
||||
{ value: ANY_TEMPLATE, label: "Все шаблоны" },
|
||||
{ value: NO_TEMPLATE, label: "Без шаблона" },
|
||||
...templateList.map((t) => ({ value: t.id, label: t.name })),
|
||||
]
|
||||
|
||||
const [importAccountId, setImportAccountId] = useState<string | null>(null)
|
||||
const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null
|
||||
|
||||
@@ -109,6 +156,52 @@ export function DomainsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Шаблон</span>
|
||||
<Select
|
||||
items={templateFilterItems}
|
||||
value={templateFilter}
|
||||
onValueChange={(v) => setTemplateFilter(v as string)}
|
||||
>
|
||||
<SelectTrigger aria-label="Фильтр по шаблону" size="sm" className="min-w-48">
|
||||
<SelectValue placeholder="Все шаблоны" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateFilterItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Статус</span>
|
||||
<Select
|
||||
items={STATUS_FILTER_ITEMS}
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => setStatusFilter(v as string)}
|
||||
>
|
||||
<SelectTrigger aria-label="Фильтр по статусу" size="sm" className="min-w-44">
|
||||
<SelectValue placeholder="Все статусы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_FILTER_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<span className="font-dns ml-auto text-xs text-muted-foreground">
|
||||
Показано {visibleDomains.length} из {domainList.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{setTemplate.isError && (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
{setTemplate.error?.message}
|
||||
@@ -119,6 +212,11 @@ export function DomainsPage() {
|
||||
<Inbox className="size-6" strokeWidth={1.5} />
|
||||
Доменов пока нет — импортируйте зоны из учётной записи.
|
||||
</div>
|
||||
) : visibleDomains.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<Inbox className="size-6" strokeWidth={1.5} />
|
||||
Под фильтр ничего не попало — измените шаблон или статус.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -131,7 +229,7 @@ export function DomainsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domainList.map((d) => {
|
||||
{visibleDomains.map((d) => {
|
||||
const templateItems = [
|
||||
{ value: NO_TEMPLATE, label: "Без шаблона" },
|
||||
...templateList.map((t) => ({ value: t.id, label: t.name })),
|
||||
|
||||
Reference in New Issue
Block a user