[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
+114 -4
View File
@@ -1,9 +1,12 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, type KeyboardEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { api, endpoints } from '../../api'
import { ConfirmDialog } from '../../components'
import type { DetectionClass, Aircraft, User } from '../../types'
type EditForm = { name: string; shortName: string; color: string; maxSizeM: number }
type EditErrorKind = 'nameRequired' | 'maxSizeMustBePositive' | 'updateFailed'
export default function AdminPage() {
const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([])
@@ -12,6 +15,12 @@ export default function AdminPage() {
const [newClass, setNewClass] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'Annotator' })
const [deactivateId, setDeactivateId] = useState<string | null>(null)
// AZ-512 — inline edit state. Single `editingId` (not per-row) so opening
// one row's editor implicitly closes any other (Risk 3 mitigation).
const [editingId, setEditingId] = useState<number | null>(null)
const [editForm, setEditForm] = useState<EditForm>({ name: '', shortName: '', color: '#FF0000', maxSizeM: 0 })
const [editError, setEditError] = useState<EditErrorKind | null>(null)
const [editSaving, setEditSaving] = useState(false)
useEffect(() => {
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
@@ -32,6 +41,44 @@ export default function AdminPage() {
setClasses(prev => prev.filter(c => c.id !== id))
}
const handleStartEdit = (c: DetectionClass) => {
setEditingId(c.id)
setEditForm({ name: c.name, shortName: c.shortName, color: c.color, maxSizeM: c.maxSizeM })
setEditError(null)
setEditSaving(false)
}
const handleCancelEdit = () => {
setEditingId(null)
setEditError(null)
setEditSaving(false)
}
const handleUpdateClass = async () => {
if (editingId === null || editSaving) return
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
if (!(editForm.maxSizeM > 0)) { setEditError('maxSizeMustBePositive'); return }
setEditError(null)
setEditSaving(true)
try {
// Risk 2 mitigation — always send the complete form so backend PATCH
// semantics (full-replace vs partial-merge) don't matter.
await api.patch(endpoints.admin.class(editingId), editForm)
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
setClasses(updated)
setEditingId(null)
} catch {
setEditError('updateFailed')
} finally {
setEditSaving(false)
}
}
const handleEditKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter') { e.preventDefault(); void handleUpdateClass() }
else if (e.key === 'Escape') { e.preventDefault(); handleCancelEdit() }
}
const handleAddUser = async () => {
if (!newUser.email || !newUser.password) return
await api.post(endpoints.admin.users(), newUser)
@@ -56,7 +103,7 @@ export default function AdminPage() {
<div className="flex h-full overflow-y-auto p-4 gap-4">
{/* Detection classes */}
<div className="w-[340px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes')}</h2>
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes.title')}</h2>
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
<table className="w-full text-xs">
<thead>
@@ -68,12 +115,75 @@ export default function AdminPage() {
</tr>
</thead>
<tbody>
{classes.map(c => (
{classes.map(c => c.id === editingId ? (
<tr key={c.id} className="border-b border-az-border text-az-text bg-az-bg/40" data-editing-row={c.id}>
<td className="px-2 py-1 align-top">{c.id}</td>
<td colSpan={3} className="px-2 py-1">
<div className="flex flex-wrap gap-1 items-center" onKeyDown={handleEditKeyDown}>
<input
autoFocus
data-field="name"
value={editForm.name}
onChange={e => setEditForm(p => ({ ...p, name: e.target.value }))}
className="flex-1 min-w-[80px] bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
/>
<input
data-field="shortName"
value={editForm.shortName}
onChange={e => setEditForm(p => ({ ...p, shortName: e.target.value }))}
className="w-12 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
/>
<input
type="color"
data-field="color"
value={editForm.color}
onChange={e => setEditForm(p => ({ ...p, color: e.target.value }))}
className="w-7 h-6 border-0 bg-transparent cursor-pointer"
/>
<input
type="number"
data-field="maxSizeM"
value={editForm.maxSizeM}
onChange={e => setEditForm(p => ({ ...p, maxSizeM: Number(e.target.value) }))}
className="w-14 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
/>
<button
onClick={() => void handleUpdateClass()}
disabled={editSaving}
className="bg-az-orange text-white px-2 py-0.5 rounded disabled:opacity-50"
>
{t('admin.classes.save')}
</button>
<button
onClick={handleCancelEdit}
disabled={editSaving}
className="bg-az-bg border border-az-border text-az-text px-2 py-0.5 rounded disabled:opacity-50"
>
{t('admin.classes.cancel')}
</button>
</div>
{editError && (
<div role="alert" className="mt-1 text-az-red">
{t(`admin.classes.${editError}`)}
</div>
)}
</td>
</tr>
) : (
<tr key={c.id} className="border-b border-az-border text-az-text">
<td className="px-2 py-1">{c.id}</td>
<td className="px-2 py-1">{c.name}</td>
<td className="px-2 py-1 text-center"><span className="inline-block w-3 h-3 rounded-full" style={{ backgroundColor: c.color }} /></td>
<td className="px-2 py-1"><button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button></td>
<td className="px-2 py-1 text-right whitespace-nowrap">
<button
onClick={() => handleStartEdit(c)}
aria-label={t('admin.classes.edit')}
className="text-az-muted hover:text-az-orange mr-1"
>
{'\u270E'}
</button>
<button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button>
</td>
</tr>
))}
</tbody>
+9 -1
View File
@@ -114,7 +114,15 @@
},
"admin": {
"title": "Admin",
"classes": "Detection Classes",
"classes": {
"title": "Detection Classes",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"nameRequired": "Name is required",
"maxSizeMustBePositive": "Max size must be a positive number",
"updateFailed": "Update failed. Please try again."
},
"aiSettings": "AI Recognition Settings",
"gpsSettings": "GPS Device Settings",
"aircrafts": "Default Aircrafts",
+9 -1
View File
@@ -114,7 +114,15 @@
},
"admin": {
"title": "Адмін",
"classes": "Класи детекцій",
"classes": {
"title": "Класи детекцій",
"edit": "Редагувати",
"save": "Зберегти",
"cancel": "Скасувати",
"nameRequired": "Назва обов'язкова",
"maxSizeMustBePositive": "Максимальний розмір має бути додатнім числом",
"updateFailed": "Не вдалося оновити. Спробуйте ще раз."
},
"aiSettings": "AI Налаштування",
"gpsSettings": "GPS Пристрій",
"aircrafts": "Літальні апарати",