mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11: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>
371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
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())
|
||
})
|
||
})
|
||
})
|