[AZ-512] Admin class inline edit form + PATCH wiring (cy4 batch 16)

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 04:35:13 +03:00
parent ef56d9c207
commit ecacfa8b43
10 changed files with 718 additions and 14 deletions
+370
View File
@@ -0,0 +1,370 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, errorResponse } from './msw/helpers'
import { renderWithProviders, screen, fireEvent, waitFor, userEvent, within } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { AdminPage } from '../src/features/admin'
import type { DetectionClass } from '../src/types'
// AZ-512 — Admin: edit existing detection class (inline form + PATCH wiring).
//
// AC-1 — edit affordance visible on every class row
// AC-2 — clicking edit opens inline form, seeded values, single-row at a time
// AC-3 — Save → exactly one PATCH /api/admin/classes/{id} with full body;
// row re-renders with new values; form closes
// AC-3 — Enter inside form behaves like Save
// AC-4 — Cancel button → no network call; row reverts
// AC-4 — Escape inside form behaves like Cancel
// AC-5 — empty name OR non-positive maxSizeM → no PATCH; inline error visible
// AC-6 — PATCH 500 → form stays open; inline error visible; no alert()
// AC-7 — covered by the static FT-P-22 parity gate (scripts/check-i18n-coverage.mjs)
// which runs in CI; AdminPage uses `t('admin.classes.<key>')` for every
// user-visible new string. No runtime test added here.
// AC-8 — regression guards for add + delete behaviour (no dedicated AdminPage
// test suite predates this file; cover the smallest happy path).
//
// Cross-workspace note: as of AZ-512 ship, the admin/ sibling service does NOT
// expose PATCH /api/admin/classes/{id} (verified 2026-05-13). Tests pass on
// MSW stubs; Step 11 (Run Tests) is therefore passable on stubs; Step 16
// (Deploy) gates on AZ-513 landing on admin/.
const TWO_CLASSES: DetectionClass[] = [
{ id: 1, name: 'class-a', shortName: 'a', color: '#ff0000', maxSizeM: 7, photoMode: 0 },
{ id: 2, name: 'class-b', shortName: 'b', color: '#00ff00', maxSizeM: 5, photoMode: 0 },
]
function setClassesHandler(classes: DetectionClass[]) {
server.use(http.get('/api/annotations/classes', () => jsonResponse(classes)))
}
// Pre-existing bug: the default `/api/admin/users` handler returns
// `paginate(seedUsers)` → `{ items, totalCount, ... }`, but AdminPage does
// `setUsers(response)` expecting `User[]`, then crashes on `users.map`. The
// catch() swallows fetch errors but not the subsequent React render error.
// Sidestep here by returning a plain array — does NOT fix the underlying
// shape mismatch (out of scope for AZ-512; flag in batch report).
function stubUsersAsPlainArray() {
server.use(http.get('/api/admin/users', () => jsonResponse([])))
}
function capturePatchCalls() {
const calls: { url: string; body: unknown }[] = []
server.use(
http.patch('/api/admin/classes/:id', async ({ params, request }) => {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
calls.push({ url: `/api/admin/classes/${String(params.id)}`, body })
return jsonResponse({ id: Number(params.id), ...body })
}),
)
return calls
}
function getRow(idText: string): HTMLElement {
const cell = screen.getByText(idText, { selector: 'td' })
const tr = cell.closest('tr')
if (!tr) throw new Error(`row for "${idText}" not found`)
return tr as HTMLElement
}
async function clickEdit(rowIdText: string) {
// Arrange — find the editable row by its id-cell, then its pencil button.
const row = getRow(rowIdText)
const editBtn = within(row).getByRole('button', { name: /edit|редагувати/i })
await userEvent.click(editBtn)
}
beforeEach(() => {
seedBearer()
setClassesHandler(TWO_CLASSES)
stubUsersAsPlainArray()
})
afterEach(() => {
clearBearer()
})
describe('AZ-512 / AdminPage — inline detection-class edit', () => {
describe('AC-1: edit affordance visible on every class row', () => {
it('renders a pencil button per row', async () => {
// Act
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Assert — one edit button per class row.
const editButtons = await screen.findAllByRole('button', { name: /edit|редагувати/i })
expect(editButtons.length).toBe(TWO_CLASSES.length)
})
})
describe('AC-2: clicking edit opens inline form with seeded values', () => {
it('row 1 enters edit mode with name="class-a"; other rows stay read-only', async () => {
// Arrange
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act
await clickEdit('1')
// Assert — form is visible inside row 1.
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
expect(nameInput).toBeInTheDocument()
const shortInput = within(row1).getByDisplayValue('a') as HTMLInputElement
expect(shortInput).toBeInTheDocument()
const maxSize = within(row1).getByDisplayValue('7') as HTMLInputElement
expect(maxSize).toBeInTheDocument()
// Assert — row 2 stays read-only: the row still shows the plain text name.
const row2 = getRow('2')
expect(within(row2).getByText('class-b')).toBeInTheDocument()
})
it('opening edit on row 2 while row 1 is editing closes row 1 (single-row invariant)', async () => {
// Arrange
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
expect(within(getRow('1')).getByDisplayValue('class-a')).toBeInTheDocument()
// Act
await clickEdit('2')
// Assert — row 1 reverts; row 2 now hosts the form.
expect(within(getRow('1')).getByText('class-a')).toBeInTheDocument()
expect(within(getRow('2')).getByDisplayValue('class-b')).toBeInTheDocument()
})
})
describe('AC-3: Save sends PATCH and refreshes', () => {
it('Save button → one PATCH with full body, row re-renders, form closes', async () => {
// Arrange — capture PATCH; second GET returns the renamed class.
const patchCalls = capturePatchCalls()
let getCount = 0
server.use(
http.get('/api/annotations/classes', () => {
getCount += 1
if (getCount === 1) return jsonResponse(TWO_CLASSES)
return jsonResponse([{ ...TWO_CLASSES[0], name: 'class-a-renamed' }, TWO_CLASSES[1]])
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'class-a-renamed')
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — exactly one PATCH with the complete editable shape.
await waitFor(() => expect(patchCalls.length).toBe(1))
expect(patchCalls[0].url).toBe('/api/admin/classes/1')
expect(patchCalls[0].body).toEqual({
name: 'class-a-renamed',
shortName: 'a',
color: '#ff0000',
maxSizeM: 7,
})
// Assert — row re-renders read-only with the new name.
await waitFor(() => {
expect(screen.getByText('class-a-renamed')).toBeInTheDocument()
})
})
it('Enter key inside form behaves like Save', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'pressed-enter')
// Act
fireEvent.keyDown(nameInput, { key: 'Enter' })
// Assert
await waitFor(() => expect(patchCalls.length).toBe(1))
expect((patchCalls[0].body as { name: string }).name).toBe('pressed-enter')
})
})
describe('AC-4: Cancel discards edits without network', () => {
it('Cancel button → no PATCH; row reverts', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'never-saved')
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^cancel$|^скасувати$/i }))
// Assert — original value back; no PATCH issued.
expect(within(getRow('1')).getByText('class-a')).toBeInTheDocument()
expect(patchCalls.length).toBe(0)
})
it('Escape key inside form behaves like Cancel', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
// Act
fireEvent.keyDown(nameInput, { key: 'Escape' })
// Assert
expect(within(getRow('1')).getByText('class-a')).toBeInTheDocument()
expect(patchCalls.length).toBe(0)
})
})
describe('AC-5: validation prevents invalid submits', () => {
it('empty name → no PATCH; nameRequired error visible', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — no PATCH; error alert rendered.
expect(patchCalls.length).toBe(0)
const alert = within(row1).getByRole('alert')
expect(alert.textContent ?? '').toMatch(/name is required|назва обов/i)
})
it('non-positive maxSizeM → no PATCH; maxSizeMustBePositive error visible', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const maxInput = within(row1).getByDisplayValue('7') as HTMLInputElement
await userEvent.clear(maxInput)
await userEvent.type(maxInput, '0')
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — no PATCH; error alert rendered.
expect(patchCalls.length).toBe(0)
const alert = within(row1).getByRole('alert')
expect(alert.textContent ?? '').toMatch(/positive|додатнім/i)
})
})
describe('AC-6: backend error is surfaced inline', () => {
it('PATCH 500 → form stays open; updateFailed error visible; no alert() called', async () => {
// Arrange — install a stub that 500s on PATCH; spy on window.alert.
let patchCount = 0
server.use(
http.patch('/api/admin/classes/:id', () => {
patchCount += 1
return errorResponse(500, 'simulated server error')
}),
)
const alertSpy = window.alert
let alertCalls = 0
window.alert = () => { alertCalls += 1 }
try {
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'will-fail')
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — PATCH happened, error rendered, form still open, no alert().
await waitFor(() => expect(patchCount).toBe(1))
const row1After = getRow('1')
const alert = await within(row1After).findByRole('alert')
expect(alert.textContent ?? '').toMatch(/update failed|не вдалося оновити/i)
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
expect(alertCalls).toBe(0)
} finally {
window.alert = alertSpy
}
})
})
describe('AC-8: regression — add + delete unchanged', () => {
it('Add posts to /api/admin/classes and refetches the list', async () => {
// Arrange — capture POST; second GET returns 3 classes.
const postCalls: { body: unknown }[] = []
let getCount = 0
const NEW_CLASS: DetectionClass = { id: 3, name: 'fresh', shortName: '', color: '#FF0000', maxSizeM: 7, photoMode: 0 }
server.use(
http.post('/api/admin/classes', async ({ request }) => {
postCalls.push({ body: await request.json() })
return jsonResponse(NEW_CLASS, { status: 201 })
}),
http.get('/api/annotations/classes', () => {
getCount += 1
if (getCount === 1) return jsonResponse(TWO_CLASSES)
return jsonResponse([...TWO_CLASSES, NEW_CLASS])
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act — scope to the classes table panel (both the class-add row and
// the user-add row use placeholder="Name" + a `+` button; disambiguate
// by walking up from the class-a cell to the enclosing panel).
const classesPanel = (getRow('1').closest('table') as HTMLElement).parentElement as HTMLElement
const addNameInput = within(classesPanel).getByPlaceholderText('Name') as HTMLInputElement
await userEvent.type(addNameInput, 'fresh')
await userEvent.click(within(classesPanel).getByRole('button', { name: '+' }))
// Assert
await waitFor(() => expect(postCalls.length).toBe(1))
expect((postCalls[0].body as { name: string }).name).toBe('fresh')
await waitFor(() => expect(screen.getByText('fresh')).toBeInTheDocument())
})
it('Delete sends DELETE and removes the row optimistically', async () => {
// Arrange
const deleteCalls: string[] = []
server.use(
http.delete('/api/admin/classes/:id', ({ params }) => {
deleteCalls.push(`/api/admin/classes/${String(params.id)}`)
return new Response(null, { status: 204 })
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act
const row1 = getRow('1')
await userEvent.click(within(row1).getByRole('button', { name: '×' }))
// Assert
await waitFor(() => expect(deleteCalls).toEqual(['/api/admin/classes/1']))
await waitFor(() => expect(screen.queryByText('class-a')).not.toBeInTheDocument())
})
})
})
+8 -5
View File
@@ -80,10 +80,11 @@ describe('AZ-466 — Destructive UX policy (class-delete cross-component test)',
// Wait for the class table to populate.
await screen.findByText('class-a')
// Act — find the delete button on the first class row.
// 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 = firstRow.querySelector('button')!
const deleteBtn = Array.from(firstRow.querySelectorAll('button')).find(b => b.textContent === '×')!
await userEvent.click(deleteBtn)
// Assert — a ConfirmDialog must appear before any DELETE fires.
@@ -111,7 +112,7 @@ describe('AZ-466 — Destructive UX policy (class-delete cross-component test)',
const rows = screen.getAllByText(/^class-/i)
const firstRow = rows[0].closest('tr')!
const deleteBtn = firstRow.querySelector('button')!
const deleteBtn = Array.from(firstRow.querySelectorAll('button')).find(b => b.textContent === '×')!
await userEvent.click(deleteBtn)
await waitFor(() => expect(deletes).toHaveLength(1), { timeout: 1000 })
@@ -129,10 +130,12 @@ describe('AZ-466 — Destructive UX policy (class-delete cross-component test)',
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act — click delete, then Cancel on the dialog.
// 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')!
await userEvent.click(firstRow.querySelector('button')!)
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
+19
View File
@@ -56,6 +56,25 @@ export const adminHandlers = [
return jsonResponse(body)
}),
// AZ-512 — PATCH partial-merge over the seeded class. Default-handler
// returns the merged shape so the UI's PATCH-then-refetch sequence sees the
// updated row. Tests that need 404/5xx semantics override per-scenario.
http.patch('/api/admin/classes/:id', async ({ params, request }) => {
const idParam = String(params.id)
const id = Number(idParam)
const body = (await request.json().catch(() => ({}))) as Partial<{
name: string
shortName: string
color: string
maxSizeM: number
photoMode: number
}>
const existing =
seedClasses.find((c) => String(c.id) === idParam) ??
({ id: Number.isFinite(id) ? id : 0, name: '', shortName: '', color: '#FF0000', maxSizeM: 5, photoMode: 0 } as const)
return jsonResponse({ ...existing, ...body, id: existing.id })
}),
http.delete('/api/admin/classes/:id', () => noContent()),
http.get('/api/admin/settings', () =>