diff --git a/internal/store/db/domains.sql.go b/internal/store/db/domains.sql.go index a9afa71..075cd59 100644 --- a/internal/store/db/domains.sql.go +++ b/internal/store/db/domains.sql.go @@ -192,7 +192,7 @@ func (q *Queries) ImportDomain(ctx context.Context, arg ImportDomainParams) (Dom } 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) { diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index f406bff..87b0d89 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -17,7 +17,7 @@ RETURNING *; SELECT * FROM domains WHERE id = $1 AND project_id = $2; -- 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 DELETE FROM domains WHERE id = $1 AND project_id = $2; diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 89fd964..9b874af 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "reflect" "testing" "github.com/google/uuid" @@ -506,3 +507,41 @@ func TestImportDomainsOnlyTouchesItsOwnAccount(t *testing.T) { 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) + } +} diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx index 96c6947..7b9c7dd 100644 --- a/web/src/pages/DomainsPage.test.tsx +++ b/web/src/pages/DomainsPage.test.tsx @@ -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() +}) diff --git a/web/src/pages/DomainsPage.tsx b/web/src/pages/DomainsPage.tsx index c15462b..fc2aefb 100644 --- a/web/src/pages/DomainsPage.tsx +++ b/web/src/pages/DomainsPage.tsx @@ -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(ANY_TEMPLATE) + const [statusFilter, setStatusFilter] = useState(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(null) const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null @@ -109,6 +156,52 @@ export function DomainsPage() { )} +
+
+ Шаблон + +
+ +
+ Статус + +
+ + + Показано {visibleDomains.length} из {domainList.length} + +
+ {setTemplate.isError && ( {setTemplate.error?.message} @@ -119,6 +212,11 @@ export function DomainsPage() { Доменов пока нет — импортируйте зоны из учётной записи. + ) : visibleDomains.length === 0 ? ( +
+ + Под фильтр ничего не попало — измените шаблон или статус. +
) : ( @@ -131,7 +229,7 @@ export function DomainsPage() { - {domainList.map((d) => { + {visibleDomains.map((d) => { const templateItems = [ { value: NO_TEMPLATE, label: "Без шаблона" }, ...templateList.map((t) => ({ value: t.id, label: t.name })),