mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 13:41:12 +00:00
[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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user