mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 06:51:10 +00:00
ecacfa8b43
Implements the cycle-3-deferred AZ-512 task under the user-authorized
Option B path (MSW-stubbed; live deploy gates at Step 16 on AZ-513).
Code:
- src/features/admin/AdminPage.tsx — inline edit affordance:
editingId/editForm state, handleStartEdit/Cancel/Update, Enter+Escape
keyboard handling, colspan row swap when editing, pencil (✎) button
per row. Full-body PATCH (Risk 2). Single editingId enforces the
one-row-at-a-time invariant (Risk 3). Disabled buttons during the
in-flight PATCH (Risk 4). Inline role="alert" on validation/server
errors (no alert() per Finding B4 anti-pattern).
- src/i18n/{en,ua}.json — `admin.classes` flat → nested with `title`
+ 6 new keys (edit, save, cancel, nameRequired,
maxSizeMustBePositive, updateFailed). Parity gate FT-P-22 PASS.
Test infrastructure:
- tests/msw/handlers/admin.ts — PATCH /api/admin/classes/:id
partial-merge handler.
- tests/admin_class_edit.test.tsx — 12 tests covering AC-1..AC-6
+ AC-8 (AC-7 satisfied by static FT-P-22 gate).
- tests/destructive_ux.test.tsx — adjacent-hygiene selector fix
at 3 call sites: the new ✎ button moved the first-button
position; targeting × explicitly preserves the existing
it.fails()/control semantics.
Docs:
- _docs/02_document/components/08_admin/description.md — recorded
edit affordance + PATCH wiring + AZ-513 cross-workspace note.
- _docs/03_implementation/batch_16_cycle4_report.md
- _docs/03_implementation/implementation_report_admin_class_edit_cycle4.md
- _docs/02_tasks/todo → done — AZ-512 archived.
Quality gates: 32 files / 243 tests / 13 quarantined skips PASS;
all 35 static checks PASS (FT-P-22/23, STC-ARCH-01/02, STC-SEC*,
banned-deps incl. SEC1B/C/D).
Cross-workspace dependency: admin/ AZ-513 (POST + PATCH + DELETE
/classes routes) NOT yet shipped. Step 11 (Run Tests) passes on
stubs; Step 16 (Deploy) holds until AZ-513 lands live. Leftover
record at _docs/_process_leftovers/2026-05-13_az-512-admin-
classes-prereq.md stays open.
Discovered pre-existing bug (NOT bundled): tests/msw/handlers/
admin.ts returns paginate(seedUsers) for GET /api/admin/users,
but AdminPage consumes as flat User[] → users.map crash. Test
files use the same flat-array workaround
destructive_ux.test.tsx documented. Flagged in batch + impl
reports for separate triage.
Co-authored-by: Cursor <cursoragent@cursor.com>
170 lines
7.9 KiB
TypeScript
170 lines
7.9 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
||
import { http } from 'msw'
|
||
import { server } from './msw/server'
|
||
import { jsonResponse, noContent } from './msw/helpers'
|
||
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||
import { seedBearer, clearBearer } from './helpers/auth'
|
||
import { AdminPage } from '../src/features/admin'
|
||
|
||
// AZ-466 — Destructive UX policy (cross-component half)
|
||
//
|
||
// AC-1 (FT-P-26): clicking Delete on a class → confirming → DELETE fires AFTER confirm.
|
||
// AC-2 (FT-N-07): clicking Delete → Cancel → NO DELETE fires.
|
||
// AC-4 (FT-P-27 / NFT-SEC-08): static check enumerates every destructive surface
|
||
// and asserts each one mounts a `<ConfirmDialog>`.
|
||
// The static side lives in `scripts/run-tests.sh` /
|
||
// `scripts/check-banned-deps.mjs` (`STC-SEC8`).
|
||
// The runtime mirror is one of the cases below.
|
||
// AC-5 (NFT-SEC-07): no `alert()` in `src/`. Static side enforces this; runtime
|
||
// side here only documents the current allowlist. The runtime
|
||
// test would require renderring every component that calls
|
||
// alert — out of black-box scope. Static check `STC-SEC7`
|
||
// handles enforcement.
|
||
//
|
||
// Production drift (`src/features/admin/AdminPage.tsx:30-33` and table row
|
||
// line 76):
|
||
// `handleDeleteClass` directly calls `api.delete` without gating through
|
||
// `<ConfirmDialog>`. The class-delete row's `<button onClick=...>` triggers
|
||
// the network mutation immediately. FT-P-26 + FT-N-07 are recorded as
|
||
// `it.fails()` until production wraps `handleDeleteClass` behind ConfirmDialog
|
||
// (Phase B feature task).
|
||
|
||
const SEED_CLASSES = [
|
||
{ id: 1, name: 'class-a', shortName: 'a', color: '#ff0000', maxSizeM: 7 },
|
||
{ id: 2, name: 'class-b', shortName: 'b', color: '#00ff00', maxSizeM: 5 },
|
||
]
|
||
|
||
interface CapturedDelete {
|
||
url: string
|
||
classId: string
|
||
}
|
||
|
||
function captureClassDelete(): { deletes: CapturedDelete[] } {
|
||
const deletes: CapturedDelete[] = []
|
||
server.use(
|
||
http.delete('/api/admin/classes/:id', ({ request, params }) => {
|
||
deletes.push({ url: new URL(request.url).pathname, classId: String(params.id) })
|
||
return noContent()
|
||
}),
|
||
// AdminPage bootstrap: classes (annotations service), aircrafts, users.
|
||
// NOTE: `AdminPage` reads `/api/admin/users` as a flat User[]
|
||
// (`api.get<User[]>` then `users.map`) — but the suite-default MSW
|
||
// wraps `seedUsers` in `paginate(...)`. That's a documented
|
||
// production-vs-suite drift (admin handler should expose flat in dev).
|
||
// For this destructive-UX test we only care about class-delete
|
||
// wiring, so the override returns a flat empty array to keep
|
||
// AdminPage from crashing on `users.map`.
|
||
http.get('/api/annotations/classes', () => jsonResponse(SEED_CLASSES)),
|
||
http.get('/api/flights/aircrafts', () => jsonResponse([])),
|
||
http.get('/api/admin/users', () => jsonResponse([])),
|
||
// AuthContext bootstraps with GET /api/admin/auth/refresh; tests using
|
||
// <ProtectedRoute>-less render still mount AuthProvider. Return 401 so
|
||
// the unauth path resolves quickly and bootstrap finishes.
|
||
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
||
)
|
||
return { deletes }
|
||
}
|
||
|
||
describe('AZ-466 — Destructive UX policy (class-delete cross-component test)', () => {
|
||
beforeEach(() => {
|
||
seedBearer()
|
||
})
|
||
|
||
describe('AC-1 (FT-P-26) — happy path: Delete → Confirm → DELETE fires', () => {
|
||
it.fails(
|
||
'class-delete prompts a `<ConfirmDialog>` BEFORE issuing the DELETE',
|
||
async () => {
|
||
// Arrange
|
||
const { deletes } = captureClassDelete()
|
||
renderWithProviders(<AdminPage />)
|
||
// Wait for the class table to populate.
|
||
await screen.findByText('class-a')
|
||
|
||
// Act — find the delete button on the first class row. AZ-512 added
|
||
// an edit (✎) button alongside the delete (×); select by text.
|
||
const rows = screen.getAllByText(/^class-/i)
|
||
const firstRow = rows[0].closest('tr')!
|
||
const deleteBtn = Array.from(firstRow.querySelectorAll('button')).find(b => b.textContent === '×')!
|
||
await userEvent.click(deleteBtn)
|
||
|
||
// Assert — a ConfirmDialog must appear before any DELETE fires.
|
||
// Drift: AdminPage's `handleDeleteClass` issues api.delete directly
|
||
// (no ConfirmDialog wired). The DELETE fires immediately and the
|
||
// dialog never appears.
|
||
const dialog = await screen.findByRole('dialog', undefined, { timeout: 1000 })
|
||
expect(dialog).toBeInTheDocument()
|
||
expect(deletes).toHaveLength(0)
|
||
|
||
// Confirm via the dialog → DELETE fires now.
|
||
const confirm = screen.getAllByRole('button').find(b => /confirm/i.test(b.textContent ?? ''))!
|
||
await userEvent.click(confirm)
|
||
await waitFor(() => expect(deletes).toHaveLength(1), { timeout: 1000 })
|
||
clearBearer()
|
||
},
|
||
)
|
||
|
||
it('control: production today bypasses ConfirmDialog and deletes immediately', async () => {
|
||
// Pin the current (drift) one-click delete behavior. When AC-1 lands,
|
||
// this control flips red and is removed.
|
||
const { deletes } = captureClassDelete()
|
||
renderWithProviders(<AdminPage />)
|
||
await screen.findByText('class-a')
|
||
|
||
const rows = screen.getAllByText(/^class-/i)
|
||
const firstRow = rows[0].closest('tr')!
|
||
const deleteBtn = Array.from(firstRow.querySelectorAll('button')).find(b => b.textContent === '×')!
|
||
await userEvent.click(deleteBtn)
|
||
|
||
await waitFor(() => expect(deletes).toHaveLength(1), { timeout: 1000 })
|
||
expect(deletes[0].url).toMatch(/\/api\/admin\/classes\/\d+/)
|
||
clearBearer()
|
||
})
|
||
})
|
||
|
||
describe('AC-2 (FT-N-07) — cancel path: Delete → Cancel → NO DELETE fires', () => {
|
||
it.fails(
|
||
'class-delete with Cancel via the ConfirmDialog suppresses the DELETE entirely',
|
||
async () => {
|
||
// Arrange
|
||
const { deletes } = captureClassDelete()
|
||
renderWithProviders(<AdminPage />)
|
||
await screen.findByText('class-a')
|
||
|
||
// Act — click delete, then Cancel on the dialog. AZ-512 added an
|
||
// edit (✎) button alongside the delete (×); select by text.
|
||
const rows = screen.getAllByText(/^class-/i)
|
||
const firstRow = rows[0].closest('tr')!
|
||
const deleteBtn = Array.from(firstRow.querySelectorAll('button')).find(b => b.textContent === '×')!
|
||
await userEvent.click(deleteBtn)
|
||
|
||
// Drift: the dialog never appears today. The find call fails first
|
||
// (no `role="dialog"` ever mounts), but even if it did, cancel would
|
||
// need to suppress a DELETE that today already fired synchronously.
|
||
const dialog = await screen.findByRole('dialog', undefined, { timeout: 1000 })
|
||
expect(dialog).toBeInTheDocument()
|
||
const cancel = screen.getAllByRole('button').find(b => /cancel/i.test(b.textContent ?? ''))!
|
||
await userEvent.click(cancel)
|
||
|
||
// Assert — NO DELETE was issued.
|
||
await new Promise(r => setTimeout(r, 50))
|
||
expect(deletes).toHaveLength(0)
|
||
clearBearer()
|
||
},
|
||
)
|
||
})
|
||
|
||
describe('AC-4 (FT-P-27 / NFT-SEC-08) — destructive surfaces enumeration', () => {
|
||
// The runtime side of FT-P-27 / NFT-SEC-08 is a multi-component static
|
||
// walk. Implementing it as a Vitest test would require rendering every
|
||
// production page and asserting every destructive surface mounts a
|
||
// ConfirmDialog. That is the static check's job (`STC-SEC8` in
|
||
// `scripts/run-tests.sh` calling `check-banned-deps.mjs --kind=destructive_unguarded`).
|
||
// We pin one runtime example here (AdminPage's class-delete) above to
|
||
// catch regressions on a known-current drift surface.
|
||
it.skip(
|
||
'QUARANTINE — full enumeration is enforced by STC-SEC8 (static check); per-surface runtime tests follow per-feature in Phase B',
|
||
() => {},
|
||
)
|
||
})
|
||
})
|