mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 06:31:11 +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:
@@ -14,7 +14,7 @@
|
||||
|
||||
| Export | Notes |
|
||||
|--------|-------|
|
||||
| `AdminPage()` | Top-level route component. Sub-sections: Users, Detection Classes, AI Settings, GPS Settings, Aircraft default. |
|
||||
| `AdminPage()` | Top-level route component. Sub-sections: Users, Detection Classes, AI Settings, GPS Settings, Aircraft default. Detection Classes table supports the full CRUD surface — add, **edit** (AZ-512 inline form on row click of the ✎ button; PATCH `/api/admin/classes/{id}` with full body per Risk-2 mitigation; Enter saves, Escape cancels; inline validation for empty name and non-positive maxSizeM; closes Architecture Vision P12), delete. |
|
||||
|
||||
## 3. External API Specification
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|--------|------|---------|
|
||||
| GET / POST / PUT / DELETE | `/api/admin/users` | User CRUD |
|
||||
| GET | `/api/annotations/classes` | Read class list (note: read uses `annotations/`, write uses `admin/`) |
|
||||
| POST / PUT / DELETE | `/api/admin/classes` | Class CRUD |
|
||||
| POST / PATCH / DELETE | `/api/admin/classes` | Class CRUD. PATCH `/api/admin/classes/{id}` powers the inline edit affordance (AZ-512) and accepts a full or partial body of `{ name?, shortName?, color?, maxSizeM? }`. **Cross-workspace note**: as of AZ-512 ship, the live `admin/` service still owes the write routes (POST + PATCH + DELETE) per **AZ-513** on `admin/`; UI ships against MSW stubs until that lands. |
|
||||
| GET / PUT | `/api/admin/settings/ai` | AI service config |
|
||||
| GET / PUT | `/api/admin/settings/gps` | GPS device config |
|
||||
| GET / PUT | `/api/admin/settings/aircraft-default` | Aircraft default |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Admin: edit existing detection class (inline form + PATCH wiring)
|
||||
|
||||
> **STATUS (2026-05-13, cycle 4 reactivation)**: User-authorized **Option B** path (from the Cross-Workspace Verification gate below). AZ-513 verified NOT-yet-shipped on admin/ as of 2026-05-13T04:17+03:00 (no `MapPost|MapPatch|MapDelete` against `classes` in `Azaion.AdminApi/Program.cs`). User intends to ship AZ-513 in parallel during the same cycle. UI implementation proceeds with MSW-stubbed tests; Step 11 (Run Tests) will be passable on stubs; Step 16 (Deploy) gating against the live admin/ service must wait until AZ-513 lands. See `_docs/_process_leftovers/2026-05-13_az-512-admin-classes-prereq.md` for cross-workspace history.
|
||||
> **STATUS (2026-05-13, cycle 4 close)**: **DONE in UI** via user-authorized **Option B** path. Implementation lives in cycle 4 batch 16 — see `_docs/03_implementation/batch_16_cycle4_report.md` and `_docs/03_implementation/implementation_report_admin_class_edit_cycle4.md`. 12 vitest tests pass (8/8 ACs covered); all static gates pass. **Live deploy gates at Step 16 on AZ-513** (admin/ workspace must ship `POST | PATCH | DELETE /classes` and deploy before UI prod cutover). Leftover record `_docs/_process_leftovers/2026-05-13_az-512-admin-classes-prereq.md` stays open until that point.
|
||||
|
||||
**Task**: AZ-512_admin_edit_detection_class
|
||||
**Name**: Admin — edit existing detection class
|
||||
@@ -0,0 +1,89 @@
|
||||
# Batch Report
|
||||
|
||||
**Batch**: 16
|
||||
**Cycle**: 4 (autodev existing-code Step 10)
|
||||
**Tasks**: [AZ-512]
|
||||
**Date**: 2026-05-13
|
||||
**Reactivation context**: AZ-512 was deferred to backlog at the end of cycle 3 (Cross-Workspace Verification BLOCKING gate failed — `admin/` service does not expose `/classes` write routes). User authorized **Option B** (MSW-stubbed UI ahead of admin/ AZ-513 shipping) at cycle 4 entry. Task moved `backlog/` → `todo/` in commit `ef56d9c`.
|
||||
|
||||
## Task Results
|
||||
|
||||
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|
||||
|------|--------|---------------|-------|-------------|--------|
|
||||
| AZ-512_admin_edit_detection_class | Done | 5 production + test + 1 doc | 12 passed | 8/8 ACs covered | 1 noted (pre-existing) |
|
||||
|
||||
### Files modified
|
||||
|
||||
| Path | Type | Change |
|
||||
|------|------|--------|
|
||||
| `src/features/admin/AdminPage.tsx` | OWNED (08_admin) | Added inline edit affordance: `editingId` / `editForm` / `editError` / `editSaving` state; handlers (`handleStartEdit`, `handleCancelEdit`, `handleUpdateClass`, `handleEditKeyDown`); colspan row swap when editing; pencil (✎) button on read-only rows. Updated `t('admin.classes')` → `t('admin.classes.title')`. |
|
||||
| `src/i18n/en.json` | spec-authorized (00_foundation) | Restructured `admin.classes` from flat string to nested object (`title` + 6 new keys: `edit`, `save`, `cancel`, `nameRequired`, `maxSizeMustBePositive`, `updateFailed`). |
|
||||
| `src/i18n/ua.json` | spec-authorized (00_foundation) | Ukrainian mirror of the same 7 keys (FT-P-22 parity gate PASS). |
|
||||
| `tests/msw/handlers/admin.ts` | test-infra | Added `http.patch('/api/admin/classes/:id', ...)` partial-merge handler; existing PUT handler retained (dead code, not introduced by this task). |
|
||||
| `tests/admin_class_edit.test.tsx` | new | 12 tests covering AC-1..AC-6, AC-8 (AC-7 covered by static FT-P-22 gate). |
|
||||
| `tests/destructive_ux.test.tsx` | adjacent hygiene | Fixed `firstRow.querySelector('button')` selector at 3 call sites — my ✎ button became the first button in the row; replaced with `Array.from(querySelectorAll('button')).find(b => b.textContent === '×')` to deliberately target the delete (×) button. Pre-existing `it.fails()` semantics preserved. |
|
||||
| `_docs/02_document/components/08_admin/description.md` | spec-authorized (per task Scope.Included) | Recorded edit affordance + PATCH wiring in Internal Interfaces table and External API table; cross-referenced AZ-513 prerequisite. |
|
||||
|
||||
### Files NOT modified (scope discipline)
|
||||
|
||||
| Path | Reason |
|
||||
|------|--------|
|
||||
| `src/api/endpoints.ts` | Task constraint: reuse existing `endpoints.admin.class(id)` builder; no new endpoint helper for PATCH (same URL as DELETE). |
|
||||
| `src/api/client.ts` | `api.patch()` helper already exists. |
|
||||
| `_docs/02_document/architecture.md` | Architecture-level wire-shape table update belongs in Step 13 (Update Docs), not Step 10. |
|
||||
| AdminPage delete-confirm wiring | Out of scope (Finding B4 — explicitly excluded per task spec Scope.Excluded). |
|
||||
| Settings/Users sections | Out of scope (separate concerns per task spec Scope.Excluded). |
|
||||
|
||||
## AC Test Coverage: All covered (8 of 8)
|
||||
|
||||
| AC | Test name | Notes |
|
||||
|----|-----------|-------|
|
||||
| AC-1 | `renders a pencil button per row` | One edit affordance per class row |
|
||||
| AC-2 | `row 1 enters edit mode with name="class-a"; other rows stay read-only` + `single-row invariant` | Seeded values + Risk 3 mitigation |
|
||||
| AC-3 | `Save button → one PATCH with full body, row re-renders, form closes` + `Enter key inside form behaves like Save` | Risk 2 mitigation: full-body always |
|
||||
| AC-4 | `Cancel button → no PATCH; row reverts` + `Escape key inside form behaves like Cancel` | No network in either path |
|
||||
| AC-5 | `empty name → no PATCH; nameRequired error visible` + `non-positive maxSizeM → no PATCH; maxSizeMustBePositive error visible` | Validation-before-submit |
|
||||
| AC-6 | `PATCH 500 → form stays open; updateFailed error visible; no alert() called` | Risk 4 mitigation: disabled buttons during PATCH; spy on `window.alert` |
|
||||
| AC-7 | (static) `FT-P-22 (key parity): PASS` | `scripts/check-i18n-coverage.mjs --parity-only` |
|
||||
| AC-8 | `Add posts to /api/admin/classes and refetches the list` + `Delete sends DELETE and removes the row optimistically` | Regression guards |
|
||||
|
||||
## Code Review Verdict: PASS (inline self-review)
|
||||
|
||||
A formal `/code-review` skill run was not invoked for this single-task batch (3 pts, tight scope, all spec ACs verified). The self-review checked: file ownership respected, no silent error swallowing, no `alert()` usage (STC-SEC7 confirms), no banned-deps literals (STC-SEC1B/C/D confirm), i18n parity + coverage (FT-P-22/23 confirm), architecture compliance (STC-ARCH-01/02 confirm), single-responsibility handlers, no spec drift, no dependencies on un-shipped admin/ work in the test layer.
|
||||
|
||||
If a cumulative review is required at Step 14.5 (every K=3 batches), this is the 1st batch of cycle 4 — cumulative review fires at batch 18.
|
||||
|
||||
## Auto-Fix Attempts: 0
|
||||
|
||||
No PASS-with-warnings or FAIL findings during self-review.
|
||||
|
||||
## Stuck Agents: None
|
||||
|
||||
Single task, ~7 file edits, no rewrites without progress. The one i18n-coverage failure (3 raw English aria-labels) was fixed in a single targeted swap (aria-label → data-field) without regressing the spec's aria-label-on-edit-button NFR.
|
||||
|
||||
## Test Suite Result
|
||||
|
||||
| Suite | Result |
|
||||
|-------|--------|
|
||||
| `bun run test` (full vitest) | **32 files passed, 243 tests passed, 13 quarantined skips** (cycle 3 baseline preserved) |
|
||||
| `bash scripts/run-tests.sh --static-only` | **All 35 static checks PASS** including FT-P-22, FT-P-23, STC-ARCH-01/02, STC-SEC1/2/3/4/7/8/13/14, STC-SEC1B/C/D, banned-deps, etc. |
|
||||
|
||||
## Pre-existing bug noted (NOT fixed this batch)
|
||||
|
||||
While writing the new test file, I discovered that `tests/msw/handlers/admin.ts` returns `paginate(seedUsers)` (= `{ items, totalCount, page, pageSize }`) for `GET /api/admin/users`, but `AdminPage.tsx:19` does `api.get<User[]>(...).then(setUsers)` expecting a flat array. The catch swallows fetch errors but NOT the subsequent `users.map is not a function` render error.
|
||||
|
||||
- **Impact in tests**: any test that mounts the full `<AdminPage />` without overriding the users handler crashes. Today, `destructive_ux.test.tsx:50-59` already overrides `/api/admin/users` with `jsonResponse([])` and documents the drift with the same comment shape; my new `tests/admin_class_edit.test.tsx` adds the same override (`stubUsersAsPlainArray()`).
|
||||
- **Impact in production**: depends on what the live `admin/` service actually returns (flat or paginated). If paginated, the Users table is broken end-to-end against the live service — analogous to the pre-existing AZ-513 add/delete situation. If flat, only the test fixture is wrong.
|
||||
- **Recommendation**: a separate UI-workspace ticket to either (a) align the MSW handler with the live admin/ shape (and fix `AdminPage.users` consumption if needed), or (b) introduce a paginated-response unwrap in the api client. NOT bundled with AZ-512 per scope discipline (`coderule.mdc`).
|
||||
|
||||
## Cross-workspace dependency reminder
|
||||
|
||||
AZ-512 ships in this batch but the **live admin/ service does not yet expose** `POST | PATCH | DELETE /api/admin/classes(/{id})` (verified 2026-05-13: zero `MapPost|MapPatch|MapDelete` against `classes` in `admin/Azaion.AdminApi/Program.cs`). Per the user-chosen Option B path:
|
||||
|
||||
- **Step 11 (Run Tests)** passes on MSW stubs.
|
||||
- **Step 16 (Deploy)** gates on **AZ-513** landing on the admin/ workspace AND that build being deployed to whichever environment(s) the UI is promoted into. The leftover record at `_docs/_process_leftovers/2026-05-13_az-512-admin-classes-prereq.md` remains open until that point.
|
||||
- The existing pre-existing-broken Add and Delete affordances on `AdminPage`'s class table also start working end-to-end the moment AZ-513 ships.
|
||||
|
||||
## Next Batch
|
||||
|
||||
None planned in this cycle (cycle 4 was entered for AZ-512 reactivation only). After Step 11 (Run Tests) confirms the test suite still passes, autodev auto-chains through Steps 12 → 13 → 14 → 15 → 16 → 17. The Deploy gate (Step 16) will surface the admin/ AZ-513 dependency before any prod cutover.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Implementation Report — Admin Class Edit (Cycle 4)
|
||||
|
||||
**Date**: 2026-05-13
|
||||
**Cycle**: 4 (autodev existing-code Step 10 → Step 17 loop)
|
||||
**Epic**: AZ-509 (Phase B cycle 3 carry-over — UI workspace cycle 3 deliverables; AZ-512 was the cycle 3 deferred task brought into cycle 4 under user-authorized Option B)
|
||||
**Tasks**: [AZ-512]
|
||||
**Batches**: 1 (batch_16_cycle4)
|
||||
**Outcome**: PASS — single-task cycle, all ACs covered, full test suite green, all static gates green.
|
||||
|
||||
## Summary
|
||||
|
||||
Cycle 4 was entered as a small surgical cycle to **reactivate AZ-512** — the "edit existing detection class" affordance that was deferred to backlog at the end of cycle 3 because the `admin/` sibling service does not expose the underlying CRUD routes for detection classes.
|
||||
|
||||
At cycle 4 entry the user explicitly chose Option B from the original AZ-512 Cross-Workspace Verification gate: implement the UI inline edit form against MSW-stubbed PATCH semantics while AZ-513 ships in parallel on the admin/ workspace. The UI is therefore complete and tested today; the live deploy gate (Step 16) holds until AZ-513 lands on admin/ and that build deploys to whichever environments the UI is promoted into.
|
||||
|
||||
## Tasks Delivered
|
||||
|
||||
| Task | Name | Complexity | Status |
|
||||
|------|------|-----------|--------|
|
||||
| AZ-512 | Admin — edit existing detection class (inline form + PATCH wiring) | 3 | Done (MSW-stubbed; live wire shape gates at Step 16 on AZ-513) |
|
||||
|
||||
**Total complexity delivered**: 3 points.
|
||||
|
||||
## Acceptance Criteria Status
|
||||
|
||||
8 of 8 ACs covered. See `batch_16_cycle4_report.md` for the per-AC test mapping. Highlights:
|
||||
|
||||
- AC-1, AC-2 — edit affordance + single-row invariant verified.
|
||||
- AC-3 — Save (button + Enter) sends exactly one PATCH with the full editable body (Risk 2 mitigation: full body always sent so backend partial-merge vs full-replace semantics are equivalent for the UI).
|
||||
- AC-4 — Cancel (button + Escape) emits zero network requests.
|
||||
- AC-5 — empty name AND non-positive `maxSizeM` both block the PATCH and surface inline `role="alert"` errors.
|
||||
- AC-6 — 500 response keeps the form open, surfaces an inline error, leaves the user's draft intact, and confirms `window.alert` is NOT called.
|
||||
- AC-7 — static FT-P-22 i18n parity gate PASS; six new `admin.classes.*` keys exist in both `en.json` and `ua.json`.
|
||||
- AC-8 — regression guards for the existing Add and Delete affordances both pass.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
| Gate | Result | Notes |
|
||||
|------|--------|-------|
|
||||
| Full vitest suite | PASS — 32 files, 243 tests, 13 quarantined skips | `bun run test` |
|
||||
| `scripts/run-tests.sh --static-only` | PASS — all 35 static checks | i18n parity + coverage, arch imports, api literals, banned-deps (incl. STC-SEC1B/C/D), destructive UX surface registry, performance regex, etc. |
|
||||
| ReadLints on touched files | PASS — no introduced lint errors | `AdminPage.tsx`, MSW handler, test file, doc |
|
||||
| File ownership envelope | PASS — only `08_admin` OWNED files + spec-authorized exceptions (i18n bundles, tests, admin description doc) | |
|
||||
| AZ-512 Cross-Workspace Verification | DEFERRED — Option B path active (MSW-stubbed) | Live deploy gates at Step 16 on AZ-513 |
|
||||
|
||||
## Product Implementation Completeness Gate (Step 15)
|
||||
|
||||
| Task | Verdict | Evidence |
|
||||
|------|---------|----------|
|
||||
| AZ-512 | **PASS** | Task promises are UI-only and are implemented in production source (`src/features/admin/AdminPage.tsx`). No named external runtime dependency beyond the existing `api.patch()` helper. No unresolved placeholder/stub/TODO/scaffold markers in the touched files. The "cross-workspace prerequisite" is an external system (admin/ workspace) explicitly out-of-scope-from-the-UI per the task spec; the leftover entry tracks it and the Step 16 gate enforces it. No remediation tasks created. |
|
||||
|
||||
Final implementation report can therefore be written here (this file) without further gate-driven loops.
|
||||
|
||||
## Handoff to Test Run (Step 11)
|
||||
|
||||
The full vitest suite was already run during batch verification and passed cleanly. Per `implement` skill Step 16:
|
||||
|
||||
> If the next flow step is `Run Tests`, record a handoff in the final implementation report and let `.cursor/skills/test-run/SKILL.md` own the full-suite gate to avoid duplicate full runs.
|
||||
|
||||
Step 11 (Run Tests) is the next autodev step. The test-run skill should pick up here and run its own formal gate; the result of my pre-flight run is purely advisory.
|
||||
|
||||
## Discovered pre-existing bug (NOT fixed this batch)
|
||||
|
||||
`tests/msw/handlers/admin.ts:39` returns `paginate(seedUsers)` for `GET /api/admin/users`, but `AdminPage.tsx:19` consumes the response as a flat `User[]`. The mismatch is silently caught at the fetch layer but surfaces as a `users.map is not a function` render crash when the response is bound to state. The destructive-ux test fixture documents the same drift and overrides the handler with a flat array; my new test file uses the same workaround.
|
||||
|
||||
This is logged for the user to triage as a separate UI-workspace ticket — fixing it requires deciding which side (handler shape vs UI consumption) reflects the live admin/ service's behavior, and that determination belongs to the admin/-side conversation, not this batch's scope.
|
||||
|
||||
## Cross-workspace coordination point
|
||||
|
||||
When **AZ-513** ships on the `admin/` workspace AND that build is deployed to the environments the UI is promoted into:
|
||||
|
||||
1. The Step 16 (Deploy) gate in this cycle (or any future cycle re-running it) un-blocks for AZ-512 prod cutover.
|
||||
2. The existing pre-existing-broken Add and Delete affordances on `AdminPage` ALSO start working end-to-end against the live service for free.
|
||||
3. The leftover record at `_docs/_process_leftovers/2026-05-13_az-512-admin-classes-prereq.md` becomes deletable.
|
||||
4. The Step 16 leftovers-replay step should additionally verify the admin/-side `GET /api/admin/users` response shape and, depending on outcome, file the separate UI-workspace ticket flagged above.
|
||||
|
||||
## Cycle 4 metrics snapshot
|
||||
|
||||
| Metric | Value | Δ vs cycle 3 |
|
||||
|--------|-------|--------------|
|
||||
| Tasks attempted | 1 (AZ-512) | −2 |
|
||||
| Tasks delivered | 1 | −1 |
|
||||
| Tasks deferred at spec gate | 0 (deferred-at-gate pattern resolved via user Option B authorization) | −1 |
|
||||
| Total batches | 1 | −2 |
|
||||
| Total complexity points planned | 3 | −6 |
|
||||
| Total complexity points delivered | 3 | −3 |
|
||||
| Source files mutated | 2 production + 2 test + 2 doc/i18n + 1 test-infra = ~7 | n/a (single-task shape) |
|
||||
|
||||
## Files Reference
|
||||
|
||||
- `src/features/admin/AdminPage.tsx` — inline edit affordance.
|
||||
- `src/i18n/en.json`, `src/i18n/ua.json` — `admin.classes` flat → nested with 6 new keys.
|
||||
- `tests/msw/handlers/admin.ts` — PATCH partial-merge handler.
|
||||
- `tests/admin_class_edit.test.tsx` — 12 tests covering AC-1..AC-6 + AC-8.
|
||||
- `tests/destructive_ux.test.tsx` — adjacent-hygiene selector tightening for the existing class-delete `it.fails()` and `control` tests (my ✎ button moved the first-button position).
|
||||
- `_docs/02_document/components/08_admin/description.md` — recorded edit affordance + PATCH wiring.
|
||||
- `_docs/03_implementation/batch_16_cycle4_report.md` — per-batch detail.
|
||||
@@ -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
@@ -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
@@ -114,7 +114,15 @@
|
||||
},
|
||||
"admin": {
|
||||
"title": "Адмін",
|
||||
"classes": "Класи детекцій",
|
||||
"classes": {
|
||||
"title": "Класи детекцій",
|
||||
"edit": "Редагувати",
|
||||
"save": "Зберегти",
|
||||
"cancel": "Скасувати",
|
||||
"nameRequired": "Назва обов'язкова",
|
||||
"maxSizeMustBePositive": "Максимальний розмір має бути додатнім числом",
|
||||
"updateFailed": "Не вдалося оновити. Спробуйте ще раз."
|
||||
},
|
||||
"aiSettings": "AI Налаштування",
|
||||
"gpsSettings": "GPS Пристрій",
|
||||
"aircrafts": "Літальні апарати",
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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', () =>
|
||||
|
||||
Reference in New Issue
Block a user