Merge feat/domains-filters: фильтры доменов по шаблону и статусу, сортировка по имени зоны
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
|||||||
Reference in New Issue
Block a user