Merge feature/selective-apply-order: выборочный Apply + удаления перед обновлениями

Пер-записевые чекбоксы (updates/prunes по ключам, select-all, prune opt-in);
удаления применяются ДО обновлений — Selectel больше не отвергает конфликт
CNAME/A на одном имени. Порядок deletes-first задокументирован как инвариант.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-05 15:32:54 +07:00
16 changed files with 617 additions and 111 deletions
@@ -0,0 +1,219 @@
# Пер-записевый выбор в Apply + порядок «удаления раньше обновлений» Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** (1) Дать выбирать чекбоксами конкретные записи для применения (updates и prunes), а не «всё или ничего». (2) Применять удаления (prunes) ДО обновлений (updates), иначе провайдер отвергает конфликтующие изменения (нельзя создать CNAME на имени, где ещё живёт A-запись).
**Architecture:** `ApplyRequest` из двух булевых превращается в два списка выбранных ключей записей. Каждая запись в diff-ответе получает стабильный `key` (нормализованный `ТИП имя.`), которым оперируют чекбоксы фронта и по которому бэк фильтрует. `service.Apply` собирает выбранные prunes, затем выбранные updates — провайдер применяет удаления первыми.
**Tech Stack:** Go (diff/service/api), React + Vite.
## Global Constraints
- Ключ записи: `RecordDiff.Key()` — нормализованный `ТИП имя.` (через `model.Record.Key()` по Type+Name). Фронт НЕ конструирует ключ сам — берёт `key` из ответа diff и возвращает его в Apply.
- Порядок применения — ИНВАРИАНТ: выбранные prunes (Delete) добавляются в набор ДО выбранных updates (Add/Update). Не опция.
- Только actionable-записи выбираемы; read-only (NS/SOA) чекбоксов не имеют и никогда не применяются.
- Multi-tenancy/IDOR: Apply уже скоуплен по projectID (resolve по pid) — не регрессировать.
- Комментарии в Go — на английском; в web — как в окружающих файлах. TDD. НЕ коммитить реальную сборку `internal/web/dist/*`.
---
### Task 1: Backend — ключи записей + выборочный Apply с порядком deletes-first
**Files:**
- Modify: `internal/diff/diff.go` (RecordDiff.Key), `internal/api/dto.go` (recordView.Key + applyRequest), `internal/service/service.go` (ApplyRequest + Apply), `internal/api/handlers.go` (handleApply маппинг)
- Test: `internal/diff/diff_test.go`, `internal/service/service_test.go`, `internal/api/*_test.go`
**Interfaces:**
- Produces: `diff.RecordDiff.Key() string`; `recordView.Key`; `service.ApplyRequest{Updates []string, Prunes []string}`.
- Consumes: `model.Record.Key()`; `Changeset.Updates()/Prunes()`.
- [ ] **Step 1: Тест RecordDiff.Key**
`internal/diff/diff_test.go`:
```go
func TestRecordDiffKeyNormalizes(t *testing.T) {
d := RecordDiff{Kind: Delete, Type: model.A, Name: "Mail.Example.COM"}
if got := d.Key(); got != "A mail.example.com." {
t.Fatalf("key: %q", got)
}
}
```
Run: `go test ./internal/diff/... -run RecordDiffKey` — Ожидание FAIL.
- [ ] **Step 2: RecordDiff.Key**
`internal/diff/diff.go`:
```go
// Key is the stable identifier of the RRset this diff targets, normalised the
// same way as model.Record.Key ("TYPE name."). Used to select individual diffs
// for a partial apply. Works for every Kind (Delete has no Desired, Add has no
// Actual) because Type/Name are always populated.
func (d RecordDiff) Key() string {
return model.Record{Type: d.Type, Name: d.Name}.Key()
}
```
Run: `go test ./internal/diff/... -run RecordDiffKey` — Ожидание PASS.
- [ ] **Step 3: recordView.Key + applyRequest**
`internal/api/dto.go`:
```go
type recordView struct {
Key string `json:"key"`
Kind string `json:"kind"`
Type string `json:"type"`
Name string `json:"name"`
Desired []string `json:"desired,omitempty"`
Actual []string `json:"actual,omitempty"`
ReadOnly bool `json:"readOnly"`
}
```
`toRecordView` — установить `Key: d.Key()` (первым полем).
```go
type applyRequest struct {
Updates []string `json:"updates"`
Prunes []string `json:"prunes"`
}
```
(убрать старые ApplyUpdates/ApplyPrunes bool.)
- [ ] **Step 4: service.ApplyRequest + deletes-first selective**
`internal/service/service.go`:
```go
type ApplyRequest struct {
Updates []string // record keys (RecordDiff.Key) to add/update
Prunes []string // record keys to delete
}
```
`Apply`:
```go
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
if err != nil {
return diff.Changeset{}, err
}
selPrunes := toSet(req.Prunes)
selUpdates := toSet(req.Updates)
var toApply []diff.RecordDiff
// Deletes first: the provider rejects an Add/Update whose name still has a
// conflicting record (e.g. a CNAME cannot be created while an A on the same
// name exists). Pruning the old records before applying updates avoids that.
for _, d := range cs.Prunes() {
if selPrunes[d.Key()] {
toApply = append(toApply, d)
}
}
for _, d := range cs.Updates() {
if selUpdates[d.Key()] {
toApply = append(toApply, d)
}
}
applied := diff.Changeset{Diffs: toApply}
if len(toApply) > 0 {
if err := p.ApplyChanges(ctx, creds, ref.ZoneID, applied); err != nil {
return diff.Changeset{}, err
}
}
return applied, nil
}
func toSet(keys []string) map[string]bool {
m := make(map[string]bool, len(keys))
for _, k := range keys {
m[k] = true
}
return m
}
```
(`ApplyChanges` итерирует `cs.Diffs` в порядке слайса — сверено; порядок toApply сохраняется, prunes применяются первыми.)
- [ ] **Step 5: handleApply маппинг**
`internal/api/handlers.go` `handleApply`:
```go
cs, err := a.Svc.Apply(r.Context(), pid, did, service.ApplyRequest{
Updates: req.Updates, Prunes: req.Prunes,
})
```
(пустое тело → пустые списки → ничего не применяется; сохранить существующую обработку EOF/битого JSON.)
- [ ] **Step 6: Тесты**
- `service_test.go`: changeset с 1 update + 1 prune; `Apply{Prunes:[pruneKey]}` → применён только prune; `Apply{Updates:[updKey]}` → только update; `Apply{Updates:[updKey], Prunes:[pruneKey]}`**порядок toApply: prune ПЕРВЫМ, update вторым** (проверь через фейковый провайдер, записывающий порядок полученных Diffs — критический тест конфликта CNAME/A). Невыбранные ключи не применяются.
- `api_test.go`: POST `/apply` с `{"prunes":["A gitlocator.com."]}` → Svc.Apply получил Prunes с этим ключом, Updates пусто.
- [ ] **Step 7: Прогон и коммит**
Run: `go build ./... && go test ./internal/...`. Ожидание PASS.
```bash
git add internal/
git commit -m "feat(apply): per-record selection + deletes-before-updates ordering"
```
---
### Task 2: Frontend — чекбоксы на записях, выборочный Apply
**Files:**
- Modify: `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/hooks/useApi.ts`, `web/src/components/DiffView.tsx`, `web/src/pages/DomainDiffPage.tsx`
- Test: `web/src/components/DiffView.test.tsx`, `web/src/pages/DomainDiffPage.test.tsx`
**Interfaces:**
- Consumes: `recordView.key`; `POST /apply {updates:[], prunes:[]}`.
- Produces: чекбокс на каждой update/prune записи; выбор → Apply шлёт ключи; deletes-first обеспечен бэком.
- [ ] **Step 1: types + client + hooks**
`types.ts`: `RecordView += key: string`; `ApplyRequest``{ updates: string[]; prunes: string[] }`.
`client.ts` `applyDomain(projectId, id, body: ApplyRequest)` — тело `{updates, prunes}` (уже сериализует body).
`useApi.ts` `useApplyDomain` — тип body обновится; onSuccess как есть (инвалидация check).
- [ ] **Step 2: DiffView — чекбоксы + select-all**
`DiffView.tsx`: сделать секции Updates/Prunes выбираемыми. Расширить проп:
```tsx
export function DiffView({
changeset,
selectedUpdates, selectedPrunes, // Set<string>
onToggleUpdate, onTogglePrune, // (key: string) => void
onToggleAllUpdates, onToggleAllPrunes, // (checked: boolean) => void
footerExtra,
}: { ... })
```
- В `Section` для tone `update`/`delete` — чекбокс в заголовке секции (select-all: отмечен если все выбраны, indeterminate если часть) и чекбокс в каждой `RecordRow` (отмечен по `selected.has(record.key)`). Для tone `readonly` — без чекбоксов (не выбираемо).
- Используй существующий `Checkbox` из `@/components/ui/checkbox`. Ключ строки — `record.key`.
- Сохрани визуальный стиль; чекбокс слева от badge типа, выравнивание аккуратное.
- [ ] **Step 3: DomainDiffPage — состояние выбора + Apply**
`DomainDiffPage.tsx`:
- Состояние: `selectedUpdates: Set<string>`, `selectedPrunes: Set<string>`.
- При загрузке changeset — инициализировать: `selectedUpdates` = все ключи updates (default on), `selectedPrunes` = пусто (default off, удаление opt-in). Пересчитывать при смене changeset (useEffect на changeset).
- Тогглы: add/remove ключ; select-all: заполнить/очистить набор ключей секции.
- Убрать старый общий `applyPrunes` чекбокс и `pruneWarning`, завязанный на него. Вместо — предупреждение, если `selectedPrunes.size > 0`: «Будет удалено записей: N. Действие необратимо.»
- Apply: `apply.mutate({ updates: [...selectedUpdates], prunes: [...selectedPrunes] })`.
- Кнопка Apply disabled, если `selectedUpdates.size + selectedPrunes.size === 0`; текст статуса «Готово к применению» / «Изменений для применения нет».
- [ ] **Step 4: Тесты**
- `DiffView.test.tsx`: чекбоксы рендерятся для update/prune записей, не для read-only; клик по чекбоксу вызывает onToggle с `record.key`; select-all в заголовке.
- `DomainDiffPage.test.tsx`: по умолчанию updates отмечены, prunes сняты; отметка prune → предупреждение о количестве; Apply шлёт выбранные `{updates:[...], prunes:[...]}`; снятие всех → Apply disabled.
- [ ] **Step 5: Прогон и коммит**
Run: `cd web && npm run test -- --run && npx tsc --noEmit && npm run build`.
```bash
cd .. && git checkout internal/web/dist/index.html
git add web/src/
git commit -m "feat(web): per-record apply checkboxes with select-all; prune opt-in"
```
---
## Итоговая проверка
- `go build ./... && go test ./...` — PASS.
- `cd web && npm run test -- --run && npm run build` — PASS.
- Ручная: на домене с конфликтом (A `mail` + желаемый CNAME `mail`) отметить prune A `mail` и update CNAME `mail`, Apply → удаление проходит раньше, CNAME создаётся без ошибки провайдера; чекбоксы позволяют применить подмножество записей.
+9 -6
View File
@@ -84,12 +84,15 @@ func TestCheckEndpoint(t *testing.T) {
} }
} }
func TestApplyDefaultsPruneFalse(t *testing.T) { // TestApplySendsSelectedKeys covers the per-record selection request shape:
// POST /apply with only "prunes" set must reach the service with that key in
// Prunes and an empty Updates.
func TestApplySendsSelectedKeys(t *testing.T) {
a, m := newTestAPI() a, m := newTestAPI()
router := NewRouter(a) router := NewRouter(a)
did := uuid.New().String() did := uuid.New().String()
body := `{"applyUpdates":true}` // applyPrunes отсутствует → false body := `{"prunes":["A gitlocator.com."]}`
req := requestWithSessionCookie(http.MethodPost, req := requestWithSessionCookie(http.MethodPost,
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply", "/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
strings.NewReader(body)) strings.NewReader(body))
@@ -99,7 +102,7 @@ func TestApplyDefaultsPruneFalse(t *testing.T) {
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String()) t.Fatalf("status %d body %s", w.Code, w.Body.String())
} }
if m.lastReq.ApplyPrunes != false || m.lastReq.ApplyUpdates != true { if len(m.lastReq.Prunes) != 1 || m.lastReq.Prunes[0] != "A gitlocator.com." || len(m.lastReq.Updates) != 0 {
t.Fatalf("apply request mismatch: %+v", m.lastReq) t.Fatalf("apply request mismatch: %+v", m.lastReq)
} }
} }
@@ -117,8 +120,8 @@ func TestApplyEmptyBodyOK(t *testing.T) {
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String()) t.Fatalf("status %d body %s", w.Code, w.Body.String())
} }
if m.lastReq.ApplyPrunes != false { if len(m.lastReq.Updates) != 0 || len(m.lastReq.Prunes) != 0 {
t.Fatalf("expected ApplyPrunes=false for empty body, got %+v", m.lastReq) t.Fatalf("expected empty Updates/Prunes for empty body, got %+v", m.lastReq)
} }
} }
@@ -127,7 +130,7 @@ func TestApplyMalformedBody(t *testing.T) {
router := NewRouter(a) router := NewRouter(a)
did := uuid.New().String() did := uuid.New().String()
body := `{"applyUpdates":` body := `{"updates":`
req := requestWithSessionCookie(http.MethodPost, req := requestWithSessionCookie(http.MethodPost,
"/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply", "/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply",
strings.NewReader(body)) strings.NewReader(body))
+4 -3
View File
@@ -40,11 +40,12 @@ func toAuthResponse(u store.User, p store.Project) authResponse {
} }
type applyRequest struct { type applyRequest struct {
ApplyUpdates bool `json:"applyUpdates"` Updates []string `json:"updates"`
ApplyPrunes bool `json:"applyPrunes"` Prunes []string `json:"prunes"`
} }
type recordView struct { type recordView struct {
Key string `json:"key"`
Kind string `json:"kind"` Kind string `json:"kind"`
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` Name string `json:"name"`
@@ -61,7 +62,7 @@ type changesetResponse struct {
} }
func toRecordView(d diff.RecordDiff) recordView { func toRecordView(d diff.RecordDiff) recordView {
rv := recordView{Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly} rv := recordView{Key: d.Key(), Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly}
if d.Desired != nil { if d.Desired != nil {
rv.Desired = d.Desired.Values rv.Desired = d.Desired.Values
} }
+4 -3
View File
@@ -66,15 +66,16 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
var req applyRequest var req applyRequest
if r.Body != nil { if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
// пустое тело допустимо → значения по умолчанию (prune=false); // пустое тело допустимо → значения по умолчанию (пустые списки, ничего
// любая другая ошибка decode (битый JSON, неверные типы) → 400 // не применяется); любая другая ошибка decode (битый JSON, неверные
// типы) → 400
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeErr(w, http.StatusBadRequest, "invalid request body") writeErr(w, http.StatusBadRequest, "invalid request body")
return return
} }
} }
cs, err := a.Svc.Apply(r.Context(), pid, did, service.ApplyRequest{ cs, err := a.Svc.Apply(r.Context(), pid, did, service.ApplyRequest{
ApplyUpdates: req.ApplyUpdates, ApplyPrunes: req.ApplyPrunes, Updates: req.Updates, Prunes: req.Prunes,
}) })
if err != nil { if err != nil {
log.Printf("api: apply failed: %v", err) log.Printf("api: apply failed: %v", err)
+8
View File
@@ -21,6 +21,14 @@ type RecordDiff struct {
ReadOnly bool // NS/SOA — shown but never applied ReadOnly bool // NS/SOA — shown but never applied
} }
// Key is the stable identifier of the RRset this diff targets, normalised the
// same way as model.Record.Key ("TYPE name."). Used to select individual diffs
// for a partial apply. Works for every Kind (Delete has no Desired, Add has no
// Actual) because Type/Name are always populated.
func (d RecordDiff) Key() string {
return model.Record{Type: d.Type, Name: d.Name}.Key()
}
type Changeset struct { type Changeset struct {
Diffs []RecordDiff Diffs []RecordDiff
} }
+10
View File
@@ -22,6 +22,16 @@ func find(cs Changeset, key string) *RecordDiff {
return nil return nil
} }
// TestRecordDiffKeyNormalizes pins RecordDiff.Key() down to the same
// normalization as model.Record.Key() (lowercase + trailing dot), for a
// Delete diff (which has no Desired, only Type/Name populated directly).
func TestRecordDiffKeyNormalizes(t *testing.T) {
d := RecordDiff{Kind: Delete, Type: model.A, Name: "Mail.Example.COM"}
if got := d.Key(); got != "A mail.example.com." {
t.Fatalf("key: %q", got)
}
}
func TestDiffAddUpdateDeleteInSync(t *testing.T) { func TestDiffAddUpdateDeleteInSync(t *testing.T) {
tmpl := []model.Record{ tmpl := []model.Record{
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync
+6
View File
@@ -25,6 +25,12 @@ type Provider interface {
Name() string Name() string
ListZones(ctx context.Context, creds Credentials) ([]Zone, error) ListZones(ctx context.Context, creds Credentials) ([]Zone, error)
GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error) GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error)
// ApplyChanges MUST apply cs.Diffs in the order they are given and must
// not reorder or group them by Kind. The caller (service.Apply)
// deliberately places Delete diffs before Add/Update diffs, because some
// providers (e.g. Selectel) reject creating a CNAME on a name where a
// conflicting A record still exists. Implementations should apply diffs
// sequentially in the given order rather than batching by kind.
ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs diff.Changeset) error ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs diff.Changeset) error
// Validate checks the credentials are usable (e.g. a trial auth), so a // Validate checks the credentials are usable (e.g. a trial auth), so a
// bad account is rejected at creation time rather than at first import. // bad account is rejected at creation time rather than at first import.
+26 -7
View File
@@ -50,8 +50,8 @@ type Recorder interface {
} }
type ApplyRequest struct { type ApplyRequest struct {
ApplyUpdates bool Updates []string // record keys (RecordDiff.Key) to add/update
ApplyPrunes bool Prunes []string // record keys to delete
} }
type DomainService struct { type DomainService struct {
@@ -128,18 +128,29 @@ func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uui
return recs, nil return recs, nil
} }
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes. // Apply applies exactly the diffs whose keys are selected in req.Updates and
// req.Prunes. Selected prunes are added to the applied set BEFORE selected
// updates: deletes first is an invariant, not an option — the provider
// rejects an Add/Update whose name still has a conflicting record (e.g. a
// CNAME cannot be created while an A on the same name exists), so pruning the
// old records before applying updates avoids that.
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) { func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID) p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
if err != nil { if err != nil {
return diff.Changeset{}, err return diff.Changeset{}, err
} }
selPrunes := toSet(req.Prunes)
selUpdates := toSet(req.Updates)
var toApply []diff.RecordDiff var toApply []diff.RecordDiff
if req.ApplyUpdates { for _, d := range cs.Prunes() {
toApply = append(toApply, cs.Updates()...) if selPrunes[d.Key()] {
toApply = append(toApply, d)
}
}
for _, d := range cs.Updates() {
if selUpdates[d.Key()] {
toApply = append(toApply, d)
} }
if req.ApplyPrunes {
toApply = append(toApply, cs.Prunes()...)
} }
applied := diff.Changeset{Diffs: toApply} applied := diff.Changeset{Diffs: toApply}
if len(toApply) > 0 { if len(toApply) > 0 {
@@ -149,3 +160,11 @@ func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID
} }
return applied, nil return applied, nil
} }
func toSet(keys []string) map[string]bool {
m := make(map[string]bool, len(keys))
for _, k := range keys {
m[k] = true
}
return m
}
+48 -18
View File
@@ -125,39 +125,69 @@ func TestZoneRecordsReadsProviderDirectly(t *testing.T) {
} }
} }
func TestApplyRespectsPruneGuard(t *testing.T) { // TestApplySelectsByKeyAndOrdersPrunesBeforeUpdates covers the two goals of
// зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат // selective apply: (1) only diffs whose key is present in the request are
// applied, and (2) when both an update and a prune are selected, the prune
// (Delete) must land BEFORE the update in the applied Changeset — this is the
// regression guard for the provider rejecting an Add/Update whose name still
// conflicts with an existing record (e.g. a CNAME cannot be created while an
// A on the same name still exists).
func TestApplySelectsByKeyAndOrdersPrunesBeforeUpdates(t *testing.T) {
// zone: a needs updating (9.9.9.9 -> 1.1.1.1), b is an extra record not in
// the template (prune candidate).
actual := []model.Record{ actual := []model.Record{
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, {Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"9.9.9.9"}},
{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}}, {Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}},
} }
tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{ tmpl := dto.TemplateDoc{Records: []dto.RecordDTO{
{Type: "A", Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // in sync {Type: "A", Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}, // update
}} }}
// applyPrunes=false → удаление b НЕ применяется const updKey = "A a.example.com."
const pruneKey = "A b.example.com."
// Only the prune selected -> only the delete is applied.
svc, fp := setup(t, actual, tmpl) svc, fp := setup(t, actual, tmpl)
if _, err := svc.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{ApplyUpdates: true, ApplyPrunes: false}); err != nil { if _, err := svc.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{Prunes: []string{pruneKey}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, d := range fp.applied.Diffs { if len(fp.applied.Diffs) != 1 || fp.applied.Diffs[0].Kind != diff.Delete {
if d.Kind == diff.Delete { t.Fatalf("expected only the selected prune applied, got %+v", fp.applied.Diffs)
t.Fatalf("prune must be skipped when ApplyPrunes=false, applied: %+v", fp.applied.Diffs)
}
} }
// applyPrunes=true → удаление b применяется // Only the update selected -> only the update is applied.
svc2, fp2 := setup(t, actual, tmpl) svc2, fp2 := setup(t, actual, tmpl)
if _, err := svc2.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{ApplyUpdates: true, ApplyPrunes: true}); err != nil { if _, err := svc2.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{Updates: []string{updKey}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
var sawDelete bool if len(fp2.applied.Diffs) != 1 || fp2.applied.Diffs[0].Kind != diff.Update {
for _, d := range fp2.applied.Diffs { t.Fatalf("expected only the selected update applied, got %+v", fp2.applied.Diffs)
if d.Kind == diff.Delete && d.Name == "b.example.com." {
sawDelete = true
} }
// Nothing selected -> nothing applied.
svc3, fp3 := setup(t, actual, tmpl)
if _, err := svc3.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{}); err != nil {
t.Fatal(err)
} }
if !sawDelete { if len(fp3.applied.Diffs) != 0 {
t.Fatalf("prune must be applied when ApplyPrunes=true, applied: %+v", fp2.applied.Diffs) t.Fatalf("expected nothing applied when nothing is selected, got %+v", fp3.applied.Diffs)
}
// CRITICAL: both selected -> the prune (Delete) must be applied FIRST,
// the update SECOND. Regressing this order reintroduces the
// CNAME/A-conflict bug where the provider rejects the update because the
// stale conflicting record hasn't been deleted yet.
svc4, fp4 := setup(t, actual, tmpl)
if _, err := svc4.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{Updates: []string{updKey}, Prunes: []string{pruneKey}}); err != nil {
t.Fatal(err)
}
if len(fp4.applied.Diffs) != 2 {
t.Fatalf("expected both selected diffs applied, got %+v", fp4.applied.Diffs)
}
if fp4.applied.Diffs[0].Kind != diff.Delete {
t.Fatalf("expected prune (Delete) FIRST in applied order, got %+v", fp4.applied.Diffs)
}
if fp4.applied.Diffs[1].Kind != diff.Update {
t.Fatalf("expected update SECOND in applied order, got %+v", fp4.applied.Diffs)
} }
} }
+4 -3
View File
@@ -99,12 +99,13 @@ describe("api client", () => {
await expect(api.listDomains(PROJECT_ID)).rejects.toThrow(UnauthorizedError) await expect(api.listDomains(PROJECT_ID)).rejects.toThrow(UnauthorizedError)
}) })
it("applies with prune flag using projectId, id, body order", async () => { it("applies with selected record keys using projectId, id, body order", async () => {
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
await api.applyDomain(PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }) await api.applyDomain(PROJECT_ID, "d1", { updates: ["A a."], prunes: ["A b."] })
const [url, opts] = spy.mock.calls[0] const [url, opts] = spy.mock.calls[0]
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1/apply`) expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1/apply`)
expect(String((opts as RequestInit).body)).toContain("applyPrunes") expect(String((opts as RequestInit).body)).toContain("prunes")
expect(JSON.parse(String((opts as RequestInit).body))).toEqual({ updates: ["A a."], prunes: ["A b."] })
}) })
it("checkDomain(projectId, id) hits project-scoped check path", async () => { it("checkDomain(projectId, id) hits project-scoped check path", async () => {
+2 -1
View File
@@ -38,6 +38,7 @@ export interface CreateChannelInput { type: string; config: object; secret: stri
export interface CheckRun { id?: string; createdAt: string; result: object } export interface CheckRun { id?: string; createdAt: string; result: object }
export interface RecordView { export interface RecordView {
key: string // stable "TYPE name." identifier — used to select this record for Apply
kind: string // add | update | delete | in_sync kind: string // add | update | delete | in_sync
type: string type: string
name: string name: string
@@ -51,4 +52,4 @@ export interface ChangesetResponse {
readOnly: RecordView[] readOnly: RecordView[]
inSyncCount: number inSyncCount: number
} }
export interface ApplyRequest { applyUpdates: boolean; applyPrunes: boolean } export interface ApplyRequest { updates: string[]; prunes: string[] }
+96 -7
View File
@@ -1,16 +1,34 @@
import { render, screen } from "@testing-library/react" import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { DiffView } from "./DiffView" import { DiffView } from "./DiffView"
import type { ChangesetResponse } from "@/api/types" import type { ChangesetResponse } from "@/api/types"
const cs: ChangesetResponse = { const cs: ChangesetResponse = {
updates: [{ kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }], updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }],
prunes: [{ kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }], prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
readOnly: [{ kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }], readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
inSyncCount: 3, inSyncCount: 3,
} }
function noop() { /* unused in most tests */ }
function renderDiff(overrides: Partial<Parameters<typeof DiffView>[0]> = {}) {
return render(
<DiffView
changeset={cs}
selectedUpdates={new Set(["A www.example.com."])}
selectedPrunes={new Set()}
onToggleUpdate={noop}
onTogglePrune={noop}
onToggleAllUpdates={noop}
onToggleAllPrunes={noop}
{...overrides}
/>,
)
}
test("renders all sections with counts", () => { test("renders all sections with counts", () => {
render(<DiffView changeset={cs} />) renderDiff()
expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument() expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument()
expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument() expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument()
// Anchored (vs. the brief's bare /example\.com\./) — "www.example.com." and // Anchored (vs. the brief's bare /example\.com\./) — "www.example.com." and
@@ -23,7 +41,7 @@ test("renders all sections with counts", () => {
}) })
test("marks read-only records", () => { test("marks read-only records", () => {
render(<DiffView changeset={cs} />) renderDiff()
expect(screen.getByText(/NS/)).toBeInTheDocument() expect(screen.getByText(/NS/)).toBeInTheDocument()
}) })
@@ -35,6 +53,7 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () =>
const csWithDkim: ChangesetResponse = { const csWithDkim: ChangesetResponse = {
updates: [ updates: [
{ {
key: "TXT default._domainkey.example.com.",
kind: "update", kind: "update",
type: "TXT", type: "TXT",
name: "default._domainkey.example.com.", name: "default._domainkey.example.com.",
@@ -47,7 +66,7 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () =>
readOnly: [], readOnly: [],
inSyncCount: 0, inSyncCount: 0,
} }
render(<DiffView changeset={csWithDkim} />) renderDiff({ changeset: csWithDkim, selectedUpdates: new Set() })
expect(screen.getByText(new RegExp(longValue))).toBeInTheDocument() expect(screen.getByText(new RegExp(longValue))).toBeInTheDocument()
}) })
@@ -60,7 +79,77 @@ test("does not crash when changeset fields are null", () => {
readOnly: null, readOnly: null,
inSyncCount: 5, inSyncCount: 5,
} as unknown as ChangesetResponse } as unknown as ChangesetResponse
render(<DiffView changeset={nullish} />) renderDiff({ changeset: nullish, selectedUpdates: new Set() })
expect(screen.getByText(/5/)).toBeInTheDocument() expect(screen.getByText(/5/)).toBeInTheDocument()
expect(screen.getByText(/in sync/)).toBeInTheDocument() expect(screen.getByText(/in sync/)).toBeInTheDocument()
}) })
test("renders a checkbox for update and prune rows but not for read-only rows", () => {
renderDiff()
// 2 select-all (update + prune headers) + 2 row checkboxes (one update, one prune).
// Read-only section contributes none: no select-all, no row checkbox.
const checkboxes = screen.getAllByRole("checkbox")
expect(checkboxes).toHaveLength(4)
const updateRowCheckbox = screen.getByRole("checkbox", { name: /www\.example\.com\./ })
expect(updateRowCheckbox).toBeInTheDocument()
const pruneRowCheckbox = screen.getByRole("checkbox", { name: /old\.example\.com\./ })
expect(pruneRowCheckbox).toBeInTheDocument()
expect(screen.queryByRole("checkbox", { name: /example\.com\..*NS|NS.*example\.com\./ })).not.toBeInTheDocument()
})
test("clicking an update row checkbox calls onToggleUpdate with the record key", async () => {
const onToggleUpdate = vi.fn()
const user = userEvent.setup()
renderDiff({ onToggleUpdate })
await user.click(screen.getByRole("checkbox", { name: /www\.example\.com\./ }))
expect(onToggleUpdate).toHaveBeenCalledWith("A www.example.com.")
})
test("clicking a prune row checkbox calls onTogglePrune with the record key", async () => {
const onTogglePrune = vi.fn()
const user = userEvent.setup()
renderDiff({ onTogglePrune })
await user.click(screen.getByRole("checkbox", { name: /old\.example\.com\./ }))
expect(onTogglePrune).toHaveBeenCalledWith("A old.example.com.")
})
test("select-all header checkbox is checked when all rows in the section are selected", () => {
renderDiff({ selectedUpdates: new Set(["A www.example.com."]) })
const selectAll = screen.getByRole("checkbox", { name: /выбрать все.*updates/i })
expect(selectAll).toHaveAttribute("aria-checked", "true")
})
test("select-all header checkbox calls onToggleAllUpdates(true) when clicked while none selected", async () => {
const onToggleAllUpdates = vi.fn()
const user = userEvent.setup()
renderDiff({ selectedUpdates: new Set(), onToggleAllUpdates })
await user.click(screen.getByRole("checkbox", { name: /выбрать все.*updates/i }))
expect(onToggleAllUpdates).toHaveBeenCalledWith(true)
})
test("select-all header checkbox is indeterminate when only some update rows are selected", () => {
const csWithMultipleUpdates: ChangesetResponse = {
updates: [
{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false },
{ key: "A api.example.com.", kind: "update", type: "A", name: "api.example.com.", desired: ["1.1.1.2"], actual: ["9.9.9.8"], readOnly: false },
{ key: "A cdn.example.com.", kind: "update", type: "A", name: "cdn.example.com.", desired: ["1.1.1.3"], actual: ["9.9.9.7"], readOnly: false },
],
prunes: [],
readOnly: [],
inSyncCount: 0,
}
renderDiff({
changeset: csWithMultipleUpdates,
// Partial selection: one of three keys — neither all nor none — is what
// must drive the header checkbox into the indeterminate ("mixed") state.
selectedUpdates: new Set(["A www.example.com."]),
})
const selectAll = screen.getByRole("checkbox", { name: /выбрать все.*updates/i })
expect(selectAll).toHaveAttribute("aria-checked", "mixed")
})
+89 -9
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react" import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { ChangesetResponse, RecordView } from "@/api/types" import type { ChangesetResponse, RecordView } from "@/api/types"
@@ -40,9 +41,23 @@ function Values({ values }: { values?: string[] }) {
return <>{values.join(", ")}</> return <>{values.join(", ")}</>
} }
function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) { function RecordRow({
record,
tone,
checked,
onToggle,
}: {
record: RecordView
tone: Tone
checked?: boolean
onToggle?: (key: string) => void
}) {
const meta = TONE_META[tone] const meta = TONE_META[tone]
const showArrow = tone !== "delete" const showArrow = tone !== "delete"
// Read-only records aren't selectable — onToggle is only passed for
// update/delete sections. Presence of onToggle is the selectability flag,
// not the tone, so this stays correct if a tone's selectability ever changes.
const selectable = onToggle !== undefined
return ( return (
<div <div
@@ -52,9 +67,18 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
)} )}
style={{ borderLeftColor: meta.dot }} style={{ borderLeftColor: meta.dot }}
> >
{/* Top line: type badge, name, read-only flag — always single-line, {/* Top line: (optional) checkbox, type badge, name, read-only flag —
never affected by how long the record values are. */} always single-line, never affected by how long the record values are. */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{selectable && (
<Checkbox
checked={checked ?? false}
onCheckedChange={() => onToggle!(record.key)}
aria-label={`${tone === "delete" ? "Удалить" : "Применить"} ${record.type} ${record.name}`}
className="shrink-0"
/>
)}
<Badge <Badge
variant="outline" variant="outline"
className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground" className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground"
@@ -81,8 +105,14 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
unbreakable value like a DKIM key wraps within the row's own unbreakable value like a DKIM key wraps within the row's own
width instead of stretching it — a flex item's content can width instead of stretching it — a flex item's content can
otherwise refuse to shrink below its intrinsic width. Indented otherwise refuse to shrink below its intrinsic width. Indented
to align under the name (badge width + gap). */} to align under the name (badge width + gap, plus checkbox width +
<div className="font-dns hidden pl-14 text-xs leading-relaxed break-all text-muted-foreground sm:block"> gap when this row is selectable). */}
<div
className={cn(
"font-dns hidden text-xs leading-relaxed break-all text-muted-foreground sm:block",
selectable ? "pl-[5.25rem]" : "pl-14",
)}
>
<Values values={record.actual} /> <Values values={record.actual} />
{showArrow && ( {showArrow && (
<> <>
@@ -104,16 +134,36 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
function Section({ function Section({
tone, tone,
records, records,
selected,
onToggle,
onToggleAll,
}: { }: {
tone: Tone tone: Tone
records: RecordView[] records: RecordView[]
selected?: Set<string>
onToggle?: (key: string) => void
onToggleAll?: (checked: boolean) => void
}) { }) {
const meta = TONE_META[tone] const meta = TONE_META[tone]
const Icon = meta.icon const Icon = meta.icon
// Read-only (NS/SOA) records are never selectable — only update/delete
// sections receive selection props from DiffView.
const selectable = tone !== "readonly" && !!selected && !!onToggle && !!onToggleAll
const allSelected = selectable && records.length > 0 && records.every((r) => selected!.has(r.key))
const someSelected = selectable && records.some((r) => selected!.has(r.key))
const indeterminate = someSelected && !allSelected
return ( return (
<section aria-label={meta.label} className="flex flex-col gap-2"> <section aria-label={meta.label} className="flex flex-col gap-2">
<header className="flex items-center gap-2 px-0.5"> <header className="flex items-center gap-2 px-0.5">
{selectable && records.length > 0 && (
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onCheckedChange={(v) => onToggleAll!(v === true)}
aria-label={`Выбрать все — ${meta.label}`}
/>
)}
<Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} /> <Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} />
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase"> <h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
{meta.label} {meta.label}
@@ -134,8 +184,14 @@ function Section({
meta.ring, meta.ring,
)} )}
> >
{records.map((record, i) => ( {records.map((record) => (
<RecordRow key={`${record.type}-${record.name}-${i}`} record={record} tone={tone} /> <RecordRow
key={record.key}
record={record}
tone={tone}
checked={selectable ? selected!.has(record.key) : undefined}
onToggle={selectable ? onToggle : undefined}
/>
))} ))}
</div> </div>
)} )}
@@ -145,17 +201,41 @@ function Section({
export function DiffView({ export function DiffView({
changeset, changeset,
selectedUpdates,
selectedPrunes,
onToggleUpdate,
onTogglePrune,
onToggleAllUpdates,
onToggleAllPrunes,
footerExtra, footerExtra,
}: { }: {
changeset: ChangesetResponse changeset: ChangesetResponse
selectedUpdates: Set<string>
selectedPrunes: Set<string>
onToggleUpdate: (key: string) => void
onTogglePrune: (key: string) => void
onToggleAllUpdates: (checked: boolean) => void
onToggleAllPrunes: (checked: boolean) => void
footerExtra?: ReactNode footerExtra?: ReactNode
}) { }) {
// Defensive: a field may arrive as null (e.g. a nil slice from an older // Defensive: a field may arrive as null (e.g. a nil slice from an older
// backend) — normalise to [] so Section never calls .length/.map on null. // backend) — normalise to [] so Section never calls .length/.map on null.
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Section tone="update" records={changeset.updates ?? []} /> <Section
<Section tone="delete" records={changeset.prunes ?? []} /> tone="update"
records={changeset.updates ?? []}
selected={selectedUpdates}
onToggle={onToggleUpdate}
onToggleAll={onToggleAllUpdates}
/>
<Section
tone="delete"
records={changeset.prunes ?? []}
selected={selectedPrunes}
onToggle={onTogglePrune}
onToggleAll={onToggleAllPrunes}
/>
<Section tone="readonly" records={changeset.readOnly ?? []} /> <Section tone="readonly" records={changeset.readOnly ?? []} />
<div className="flex items-center justify-between gap-3 border-t border-border pt-4"> <div className="flex items-center justify-between gap-3 border-t border-border pt-4">
+1 -1
View File
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary", "peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-indeterminate:border-primary data-indeterminate:bg-primary/40 data-indeterminate:text-primary-foreground",
className className
)} )}
{...props} {...props}
+38 -9
View File
@@ -41,10 +41,10 @@ beforeEach(() => {
vi.spyOn(api, "listDomains").mockResolvedValue([domainWithTemplate]) vi.spyOn(api, "listDomains").mockResolvedValue([domainWithTemplate])
}) })
test("apply sends applyPrunes=false by default, true only after opting in", async () => { test("default selection: updates checked, prunes unchecked; apply sends only selected keys", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({ vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [{ kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }], updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
prunes: [{ kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }], prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
readOnly: [], inSyncCount: 0, readOnly: [], inSyncCount: 0,
}) })
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
@@ -52,22 +52,51 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
const applyBtn = await screen.findByRole("button", { name: /apply/i }) const updateRowCheckbox = await screen.findByRole("checkbox", { name: /a\.$/ })
const pruneRowCheckbox = screen.getByRole("checkbox", { name: /b\.$/ })
expect(updateRowCheckbox).toHaveAttribute("aria-checked", "true")
expect(pruneRowCheckbox).toHaveAttribute("aria-checked", "false")
expect(screen.queryByText(/будет удалено записей/i)).not.toBeInTheDocument()
const applyBtn = screen.getByRole("button", { name: /apply/i })
await user.click(applyBtn) await user.click(applyBtn)
await waitFor(() => expect(applySpy).toHaveBeenCalled()) await waitFor(() => expect(applySpy).toHaveBeenCalled())
expect(applySpy.mock.calls[0]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: false }]) expect(applySpy.mock.calls[0]).toEqual([PROJECT_ID, "d1", { updates: ["A a."], prunes: [] }])
// отметить prune → появляется предупреждение с количеством, и Apply шлёт его ключ тоже
await user.click(pruneRowCheckbox)
const warning = screen.getByRole("alert")
expect(warning).toHaveTextContent(/будет удалено записей:\s*1/i)
// включить prune и применить снова
const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i })
await user.click(pruneToggle)
await user.click(screen.getByRole("button", { name: /apply/i })) await user.click(screen.getByRole("button", { name: /apply/i }))
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2)) await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }]) expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { updates: ["A a."], prunes: ["A b."] }])
// домен с шаблоном: записи зоны не нужны для диффа — запрос не должен уходить к провайдеру // домен с шаблоном: записи зоны не нужны для диффа — запрос не должен уходить к провайдеру
expect(zoneRecordsSpy).not.toHaveBeenCalled() expect(zoneRecordsSpy).not.toHaveBeenCalled()
}) })
test("deselecting all records disables Apply", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
prunes: [],
readOnly: [], inSyncCount: 0,
})
vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
const user = userEvent.setup()
renderPage()
const applyBtn = await screen.findByRole("button", { name: /apply/i })
expect(applyBtn).not.toBeDisabled()
expect(screen.getByText(/готово к применению/i)).toBeInTheDocument()
const updateRowCheckbox = screen.getByRole("checkbox", { name: /a\.$/ })
await user.click(updateRowCheckbox)
expect(applyBtn).toBeDisabled()
expect(screen.getByText(/изменений для применения нет/i)).toBeInTheDocument()
})
test("пока список доменов грузится — показан общий лоадер, а не баннер об отсутствии шаблона", async () => { test("пока список доменов грузится — показан общий лоадер, а не баннер об отсутствии шаблона", async () => {
let resolveListDomains: (domains: Domain[]) => void let resolveListDomains: (domains: Domain[]) => void
vi.spyOn(api, "listDomains").mockReturnValue( vi.spyOn(api, "listDomains").mockReturnValue(
+52 -43
View File
@@ -1,11 +1,9 @@
import { useId, useState } from "react" import { useEffect, useState } from "react"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react" import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
import { DiffView } from "@/components/DiffView" import { DiffView } from "@/components/DiffView"
import { DomainHistory } from "@/components/DomainHistory" import { DomainHistory } from "@/components/DomainHistory"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { import {
Table, Table,
TableBody, TableBody,
@@ -37,17 +35,48 @@ export function DomainDiffPage() {
// (успешный ответ), что шаблона нет. // (успешный ответ), что шаблона нет.
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate) const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
const createTemplateFromZone = useCreateTemplateFromZone() const createTemplateFromZone = useCreateTemplateFromZone()
const [applyPrunes, setApplyPrunes] = useState(false) const [selectedUpdates, setSelectedUpdates] = useState<Set<string>>(new Set())
const pruneCheckboxId = useId() const [selectedPrunes, setSelectedPrunes] = useState<Set<string>>(new Set())
const changeset = check.data const changeset = check.data
const hasPrunes = (changeset?.prunes?.length ?? 0) > 0
const hasUpdates = (changeset?.updates?.length ?? 0) > 0 // Re-derive the selection whenever the changeset changes (initial load,
const pruneWarning = applyPrunes && hasPrunes // recheck, or apply's own invalidation): updates default to fully selected
// (safe — they only bring the zone in line with the template), prunes
// default to empty (deletion is opt-in and irreversible).
useEffect(() => {
setSelectedUpdates(new Set((changeset?.updates ?? []).map((r) => r.key)))
setSelectedPrunes(new Set())
}, [changeset])
const recordList = zoneRecords.data ?? [] const recordList = zoneRecords.data ?? []
const hasSelection = selectedUpdates.size + selectedPrunes.size > 0
function toggleUpdate(key: string) {
setSelectedUpdates((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
function togglePrune(key: string) {
setSelectedPrunes((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
function toggleAllUpdates(checked: boolean) {
setSelectedUpdates(checked ? new Set((changeset?.updates ?? []).map((r) => r.key)) : new Set())
}
function toggleAllPrunes(checked: boolean) {
setSelectedPrunes(checked ? new Set((changeset?.prunes ?? []).map((r) => r.key)) : new Set())
}
function onApply() { function onApply() {
apply.mutate({ applyUpdates: true, applyPrunes }) apply.mutate({ updates: [...selectedUpdates], prunes: [...selectedPrunes] })
} }
function onCreateTemplateFromZone() { function onCreateTemplateFromZone() {
@@ -197,36 +226,18 @@ export function DomainDiffPage() {
{hasTemplate && changeset && ( {hasTemplate && changeset && (
<> <>
<DiffView changeset={changeset} /> <DiffView
changeset={changeset}
selectedUpdates={selectedUpdates}
selectedPrunes={selectedPrunes}
onToggleUpdate={toggleUpdate}
onTogglePrune={togglePrune}
onToggleAllUpdates={toggleAllUpdates}
onToggleAllPrunes={toggleAllPrunes}
/>
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4"> <div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
<Label {selectedPrunes.size > 0 && (
htmlFor={pruneCheckboxId}
className="flex items-start gap-2.5 text-sm font-normal"
>
<Checkbox
id={pruneCheckboxId}
aria-label="prune — удалить лишние записи"
checked={applyPrunes}
onCheckedChange={(v) => setApplyPrunes(v === true)}
className="mt-0.5"
style={
applyPrunes
? ({ borderColor: "var(--diff-delete)", background: "var(--diff-delete)" } as React.CSSProperties)
: undefined
}
/>
<span className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">
Prune удалить записи, которых нет в шаблоне
</span>
<span className="text-xs text-muted-foreground">
По умолчанию выключено. Apply меняет только записи из шаблона.
</span>
</span>
</Label>
{pruneWarning && (
<div <div
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs" className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
style={{ style={{
@@ -237,8 +248,8 @@ export function DomainDiffPage() {
> >
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} /> <TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
<span> <span>
Будет безвозвратно удалено записей:{" "} Будет удалено записей:{" "}
<span className="font-dns font-semibold">{changeset.prunes.length}</span>. Действие необратимо. <span className="font-dns font-semibold">{selectedPrunes.size}</span>. Действие необратимо.
</span> </span>
</div> </div>
)} )}
@@ -252,12 +263,10 @@ export function DomainDiffPage() {
</span> </span>
) : ( ) : (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{hasUpdates || (applyPrunes && hasPrunes) {hasSelection ? "Готово к применению" : "Изменений для применения нет"}
? "Готово к применению"
: "Изменений для применения нет"}
</span> </span>
)} )}
<Button onClick={onApply} disabled={apply.isPending}> <Button onClick={onApply} disabled={apply.isPending || !hasSelection}>
{apply.isPending ? ( {apply.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} /> <Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : ( ) : (