Merge feat/domains-filters: фильтры доменов по шаблону и статусу, сортировка по имени зоны

This commit is contained in:
2026-08-19 19:30:09 +07:00
5 changed files with 222 additions and 3 deletions
+1 -1
View File
@@ -192,7 +192,7 @@ func (q *Queries) ImportDomain(ctx context.Context, arg ImportDomainParams) (Dom
} }
const listDomains = `-- name: ListDomains :many const listDomains = `-- name: ListDomains :many
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE project_id = $1 ORDER BY created_at SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE project_id = $1 ORDER BY zone_name
` `
func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) { func (q *Queries) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) {
+1 -1
View File
@@ -17,7 +17,7 @@ RETURNING *;
SELECT * FROM domains WHERE id = $1 AND project_id = $2; SELECT * FROM domains WHERE id = $1 AND project_id = $2;
-- name: ListDomains :many -- name: ListDomains :many
SELECT * FROM domains WHERE project_id = $1 ORDER BY created_at; SELECT * FROM domains WHERE project_id = $1 ORDER BY zone_name;
-- name: DeleteDomain :exec -- name: DeleteDomain :exec
DELETE FROM domains WHERE id = $1 AND project_id = $2; DELETE FROM domains WHERE id = $1 AND project_id = $2;
+39
View File
@@ -2,6 +2,7 @@ package store
import ( import (
"context" "context"
"reflect"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
@@ -506,3 +507,41 @@ func TestImportDomainsOnlyTouchesItsOwnAccount(t *testing.T) {
t.Fatalf("expected both accounts' domains to survive, got %+v", left) t.Fatalf("expected both accounts' domains to survive, got %+v", left)
} }
} }
// TestListDomains_SortedByZoneName pins the list order: domains come back
// alphabetically by zone name, never in creation order. The domains page
// relies on this — its filters reorder nothing, so the store is the single
// place the order is decided.
func TestListDomains_SortedByZoneName(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
})
if err != nil {
t.Fatal(err)
}
// Inserted in deliberately non-alphabetical order (one import call: a
// per-zone call would prune the previously imported domains), so a query
// still sorting by created_at would return zulu first.
if _, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
{ID: "z1", Name: "zulu.example.com"},
{ID: "z2", Name: "mike.example.com"},
{ID: "z3", Name: "alpha.example.com"},
}); err != nil {
t.Fatal(err)
}
list, err := s.ListDomains(ctx, defaultProject)
if err != nil {
t.Fatal(err)
}
got := make([]string, 0, len(list))
for _, d := range list {
got = append(got, d.ZoneName)
}
want := []string{"alpha.example.com", "mike.example.com", "zulu.example.com"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected domains sorted by zone name %v, got %v", want, got)
}
}
+82
View File
@@ -152,3 +152,85 @@ test("кнопки удаления домена нет", async () => {
expect(screen.queryByRole("button", { name: /удалить/i })).not.toBeInTheDocument() 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()
})
+99 -1
View File
@@ -25,8 +25,31 @@ import {
useSetDomainTemplate, useSetDomainTemplate,
useTemplates, useTemplates,
} from "@/hooks/useApi" } from "@/hooks/useApi"
import type { Domain } from "@/api/types"
const NO_TEMPLATE = "__none__" 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() { export function DomainsPage() {
const domains = useDomains() const domains = useDomains()
@@ -39,6 +62,30 @@ export function DomainsPage() {
const templateList = templates.data ?? [] const templateList = templates.data ?? []
const domainList = domains.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 [importAccountId, setImportAccountId] = useState<string | null>(null)
const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null
@@ -109,6 +156,52 @@ export function DomainsPage() {
)} )}
</div> </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 && ( {setTemplate.isError && (
<span role="alert" className="font-dns text-xs text-destructive"> <span role="alert" className="font-dns text-xs text-destructive">
{setTemplate.error?.message} {setTemplate.error?.message}
@@ -119,6 +212,11 @@ export function DomainsPage() {
<Inbox className="size-6" strokeWidth={1.5} /> <Inbox className="size-6" strokeWidth={1.5} />
Доменов пока нет импортируйте зоны из учётной записи. Доменов пока нет импортируйте зоны из учётной записи.
</div> </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> <Table>
<TableHeader> <TableHeader>
@@ -131,7 +229,7 @@ export function DomainsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{domainList.map((d) => { {visibleDomains.map((d) => {
const templateItems = [ const templateItems = [
{ value: NO_TEMPLATE, label: "Без шаблона" }, { value: NO_TEMPLATE, label: "Без шаблона" },
...templateList.map((t) => ({ value: t.id, label: t.name })), ...templateList.map((t) => ({ value: t.id, label: t.name })),