mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 08:01:10 +00:00
admin v2: implement design from ui_design/v2/plugin/admin.html
- Design system: v2 CSS variables (surface-0/1/2, border-hair, accent-amber/cyan/red/green/blue)
and utility classes (.btn, .inp, .pill, .chip, .bracket, .panel, .seg, .swatch,
.type-sq, .grid-bg, .ibtn, .checkbox, .tab); v1 az-* names aliased to v2 vars
so other pages still render. Google Fonts (IBM Plex Sans + JetBrains Mono)
loaded via <link> in index.html <head> to avoid FOUT.
- Header rebuilt to v2: amber wordmark + // divider, amber-bordered flight pill
with cyan live dot, tab-style nav with amber underline on active, LINK status
pill, cog + sign-out icon buttons.
- AdminPage rewritten to 3-column layout (340 / flex / 280):
- Detection Classes: search + ADD button, table with #/Name/Hex/Ops columns,
name-only inline edit with ringed swatch, sibling-row error alert.
- AI Recognition Engine + GPS Device Link panels with corner-bracket borders,
number steppers, segmented protocol control, dashed telemetry footers.
Hooks (useAiSettings, useGpsSettings) seed factory defaults so the UI is
interactive when GET fails (no backend).
- Default Aircrafts: P/C/F type chips, isDefault star toggle, + ADD AIRCRAFT
modal with model/type/resolution/maxMinutes/default fields.
- Co-located components: Modal (backdrop + ESC + body-scroll-lock),
NumberStepper (▲▼ with clamp on click but not on typing), ClassEditRow.
- Types: Aircraft extended with FixedWing + optional resolution/maxMinutes;
new AiRecognitionSettings/Telemetry, GpsDeviceSettings/Telemetry, GpsProtocol.
- Endpoints: /api/admin/ai-settings, /api/admin/gps-settings (+ /ping, /reconnect).
POST /api/flights/aircrafts (plural REST collection).
- MSW: stateful admin-settings handler with resetAdminSettingsSeed() wired into
tests/setup.ts. Aircraft seed expanded to 6 entries matching the mockup.
- i18n: full admin.{classes,aiEngine,gpsDevice,aircrafts} key sets in en+ua;
nav.dataset shortened to "Dataset"; obsolete users-management keys removed.
- Tests: new AdminPage AI/GPS/aircraft test suites; admin_class_edit selectors
updated for the name-only inline editor and the modal-based add flow.
This commit is contained in:
@@ -105,14 +105,12 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
// Act
|
||||
await clickEdit('1')
|
||||
|
||||
// Assert — form is visible inside row 1.
|
||||
// Assert — name input is visible inside row 1 (v2 minimal edit:
|
||||
// only the name is editable inline; shortName/color/maxSizeM are
|
||||
// preserved in form state and sent on save).
|
||||
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')
|
||||
@@ -246,31 +244,17 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
// Act
|
||||
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
|
||||
|
||||
// Assert — no PATCH; error alert rendered.
|
||||
// Assert — no PATCH; error alert rendered (v2 renders the alert in
|
||||
// a sibling tr below the edit row, not inside row1 itself).
|
||||
expect(patchCalls.length).toBe(0)
|
||||
const alert = within(row1).getByRole('alert')
|
||||
const alert = screen.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)
|
||||
})
|
||||
// The maxSizeM field is no longer editable inline in v2 (mockup shows
|
||||
// name-only). The original "non-positive maxSizeM" validation test is
|
||||
// removed — the constraint is now enforced by a separate edit-class
|
||||
// flow (not yet built) rather than inline.
|
||||
})
|
||||
|
||||
describe('AC-6: backend error is surfaced inline', () => {
|
||||
@@ -299,10 +283,11 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
// Act
|
||||
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
|
||||
|
||||
// Assert — PATCH happened, error rendered, form still open, no alert().
|
||||
// Assert — PATCH happened, error rendered (in a sibling tr), form
|
||||
// still open, no alert().
|
||||
await waitFor(() => expect(patchCount).toBe(1))
|
||||
const row1After = getRow('1')
|
||||
const alert = await within(row1After).findByRole('alert')
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent ?? '').toMatch(/update failed|не вдалося оновити/i)
|
||||
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
|
||||
expect(alertCalls).toBe(0)
|
||||
@@ -317,7 +302,7 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
// 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 }
|
||||
const NEW_CLASS: DetectionClass = { id: 3, name: 'fresh', shortName: '', color: '#FF9D3D', maxSizeM: 7, photoMode: 0 }
|
||||
server.use(
|
||||
http.post('/api/admin/classes', async ({ request }) => {
|
||||
postCalls.push({ body: await request.json() })
|
||||
@@ -332,13 +317,15 @@ describe('AZ-512 / AdminPage — inline detection-class edit', () => {
|
||||
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: '+' }))
|
||||
// Act — v2 layout: click the top "+ ADD" button to open an inline
|
||||
// add-row at the top of the table, type the name, click the save
|
||||
// (cyan checkmark, aria-label "Save") icon button.
|
||||
const classesPanel = getRow('1').closest('aside') as HTMLElement
|
||||
await userEvent.click(within(classesPanel).getByRole('button', { name: /^\+ add$|^\+ додати$/i }))
|
||||
const addRow = within(classesPanel).getByText('+', { selector: 'td' }).closest('tr') as HTMLElement
|
||||
const nameInput = within(addRow).getByPlaceholderText('Name') as HTMLInputElement
|
||||
await userEvent.type(nameInput, 'fresh')
|
||||
await userEvent.click(within(addRow).getByRole('button', { name: /^save$|^зберегти$/i }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(postCalls.length).toBe(1))
|
||||
|
||||
Vendored
+7
-4
@@ -1,8 +1,11 @@
|
||||
import type { Aircraft } from '../../src/types'
|
||||
|
||||
// Three aircraft with one default, per `seed_aircraft` in test-data.md.
|
||||
// Six aircraft matching the v2 admin mockup. AC-001 is the default.
|
||||
export const seedAircraft: Aircraft[] = [
|
||||
{ id: 'aircraft-1', model: 'Bayraktar TB2', type: 'Plane', isDefault: true },
|
||||
{ id: 'aircraft-2', model: 'DJI Mavic 3', type: 'Copter', isDefault: false },
|
||||
{ id: 'aircraft-3', model: 'Leleka-100', type: 'Plane', isDefault: false },
|
||||
{ id: 'AC-001', model: 'DJI Mavic 3', type: 'Copter', isDefault: true, resolution: '4K', maxMinutes: 46 },
|
||||
{ id: 'AC-002', model: 'Matrice 300 RTK', type: 'Copter', isDefault: false, resolution: '4K', maxMinutes: 55 },
|
||||
{ id: 'AC-003', model: 'Leleka-100', type: 'FixedWing', isDefault: false, resolution: 'HD', maxMinutes: 180 },
|
||||
{ id: 'AC-004', model: 'Fixed Wing Scout', type: 'Plane', isDefault: false, resolution: '1080P', maxMinutes: 95 },
|
||||
{ id: 'AC-005', model: 'Autel EVO II Pro', type: 'Copter', isDefault: false, resolution: '6K', maxMinutes: 40 },
|
||||
{ id: 'AC-006', model: 'PD-2 Recon', type: 'FixedWing', isDefault: false, resolution: 'HD', maxMinutes: 600 },
|
||||
]
|
||||
|
||||
Vendored
+5
-5
@@ -5,11 +5,11 @@ import type { Flight } from '../../src/types'
|
||||
// AC-08 timing assertions.
|
||||
|
||||
export const seedFlights: Flight[] = [
|
||||
{ id: 'flight-1', name: 'Recon Alpha', createdDate: '2026-05-01T10:00:00Z', aircraftId: 'aircraft-1' },
|
||||
{ id: 'flight-2', name: 'Recon Bravo', createdDate: '2026-05-02T11:30:00Z', aircraftId: 'aircraft-1' },
|
||||
{ id: 'flight-3', name: 'Survey Charlie', createdDate: '2026-05-03T14:15:00Z', aircraftId: 'aircraft-2' },
|
||||
{ id: 'flight-4', name: 'Patrol Delta', createdDate: '2026-05-04T09:45:00Z', aircraftId: 'aircraft-3' },
|
||||
{ id: 'flight-5', name: 'Strike Echo', createdDate: '2026-05-05T16:00:00Z', aircraftId: 'aircraft-1' },
|
||||
{ id: 'flight-1', name: 'Recon Alpha', createdDate: '2026-05-01T10:00:00Z', aircraftId: 'AC-001' },
|
||||
{ id: 'flight-2', name: 'Recon Bravo', createdDate: '2026-05-02T11:30:00Z', aircraftId: 'AC-001' },
|
||||
{ id: 'flight-3', name: 'Survey Charlie', createdDate: '2026-05-03T14:15:00Z', aircraftId: 'AC-002' },
|
||||
{ id: 'flight-4', name: 'Patrol Delta', createdDate: '2026-05-04T09:45:00Z', aircraftId: 'AC-003' },
|
||||
{ id: 'flight-5', name: 'Strike Echo', createdDate: '2026-05-05T16:00:00Z', aircraftId: 'AC-001' },
|
||||
]
|
||||
|
||||
export const liveGpsFlightId = 'flight-1'
|
||||
|
||||
+28
-16
@@ -6,11 +6,23 @@
|
||||
"TCP",
|
||||
"UDP",
|
||||
"Esc",
|
||||
"OK"
|
||||
"OK",
|
||||
"//",
|
||||
"|",
|
||||
"▾",
|
||||
"▲",
|
||||
"▼",
|
||||
"—"
|
||||
],
|
||||
"src/components/Header.tsx": [
|
||||
"No flights",
|
||||
"Filter..."
|
||||
"Filter...",
|
||||
"— SELECT —",
|
||||
"LINK",
|
||||
"Toggle language",
|
||||
"UA",
|
||||
"EN",
|
||||
"⚙"
|
||||
],
|
||||
"src/components/HelpModal.tsx": [
|
||||
"How to Annotate",
|
||||
@@ -36,20 +48,20 @@
|
||||
],
|
||||
"src/features/admin/AdminPage.tsx": [
|
||||
"Name",
|
||||
"Color",
|
||||
"Frame Period Recognition",
|
||||
"Frame Recognition Seconds",
|
||||
"Probability Threshold",
|
||||
"Device Address",
|
||||
"Port",
|
||||
"Protocol",
|
||||
"Email",
|
||||
"Role",
|
||||
"Status",
|
||||
"Annotator",
|
||||
"Admin",
|
||||
"Viewer",
|
||||
"Password"
|
||||
"#",
|
||||
"+",
|
||||
"0.0.0.0",
|
||||
"P",
|
||||
"C",
|
||||
"F",
|
||||
"%",
|
||||
"NMEA",
|
||||
"UBX",
|
||||
"MAVLINK",
|
||||
"SAT",
|
||||
"MIN",
|
||||
"Increment",
|
||||
"Decrement"
|
||||
],
|
||||
"src/features/annotations/AnnotationsSidebar.tsx": [
|
||||
"Download annotation"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { http } from 'msw'
|
||||
import { jsonResponse, noContent } from '../helpers'
|
||||
import type {
|
||||
AiRecognitionSettings,
|
||||
AiRecognitionTelemetry,
|
||||
GpsDeviceSettings,
|
||||
GpsDeviceTelemetry,
|
||||
} from '../../../src/types'
|
||||
|
||||
// Stateful MSW handlers for AI Recognition + GPS Device Link settings.
|
||||
// Seed mutates on PATCH so PING / RECONNECT / APPLY round-trips persist
|
||||
// within a session. `resetAdminSettingsSeed()` is invoked per-test from
|
||||
// tests/setup.ts so test isolation is preserved.
|
||||
|
||||
const DEFAULT_AI_SETTINGS: AiRecognitionSettings = {
|
||||
framesToRecognize: 4,
|
||||
minSecondsBetween: 2,
|
||||
minConfidence: 25,
|
||||
}
|
||||
|
||||
const DEFAULT_AI_TELEMETRY: AiRecognitionTelemetry = {
|
||||
model: 'YOLOV8-X',
|
||||
checkpoint: 'CKPT-241',
|
||||
lastRunAt: '2026-05-18T11:43:09Z',
|
||||
frames: 14228,
|
||||
avgConfidence: 71.4,
|
||||
}
|
||||
|
||||
const DEFAULT_GPS_SETTINGS: GpsDeviceSettings = {
|
||||
address: '192.168.1.100',
|
||||
port: 9001,
|
||||
protocol: 'NMEA',
|
||||
}
|
||||
|
||||
const DEFAULT_GPS_TELEMETRY: GpsDeviceTelemetry = {
|
||||
socket: 'UDP/192.168.1.100:9001',
|
||||
connected: true,
|
||||
fix: '3D',
|
||||
satellites: 11,
|
||||
hdop: 0.82,
|
||||
lastPacketMs: 12,
|
||||
}
|
||||
|
||||
let aiSettings: AiRecognitionSettings = { ...DEFAULT_AI_SETTINGS }
|
||||
let aiTelemetry: AiRecognitionTelemetry = { ...DEFAULT_AI_TELEMETRY }
|
||||
let gpsSettings: GpsDeviceSettings = { ...DEFAULT_GPS_SETTINGS }
|
||||
let gpsTelemetry: GpsDeviceTelemetry = { ...DEFAULT_GPS_TELEMETRY }
|
||||
|
||||
export function resetAdminSettingsSeed() {
|
||||
aiSettings = { ...DEFAULT_AI_SETTINGS }
|
||||
aiTelemetry = { ...DEFAULT_AI_TELEMETRY }
|
||||
gpsSettings = { ...DEFAULT_GPS_SETTINGS }
|
||||
gpsTelemetry = { ...DEFAULT_GPS_TELEMETRY }
|
||||
}
|
||||
|
||||
export const adminSettingsHandlers = [
|
||||
http.get('/api/admin/ai-settings', () =>
|
||||
jsonResponse({ settings: aiSettings, telemetry: aiTelemetry }),
|
||||
),
|
||||
|
||||
http.patch('/api/admin/ai-settings', async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Partial<AiRecognitionSettings>
|
||||
aiSettings = { ...aiSettings, ...body }
|
||||
return jsonResponse({ settings: aiSettings, telemetry: aiTelemetry })
|
||||
}),
|
||||
|
||||
http.get('/api/admin/gps-settings', () =>
|
||||
jsonResponse({ settings: gpsSettings, telemetry: gpsTelemetry }),
|
||||
),
|
||||
|
||||
http.patch('/api/admin/gps-settings', async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Partial<GpsDeviceSettings>
|
||||
gpsSettings = { ...gpsSettings, ...body }
|
||||
gpsTelemetry = {
|
||||
...gpsTelemetry,
|
||||
socket: `UDP/${gpsSettings.address}:${gpsSettings.port}`,
|
||||
}
|
||||
return jsonResponse({ settings: gpsSettings, telemetry: gpsTelemetry })
|
||||
}),
|
||||
|
||||
http.post('/api/admin/gps-settings/ping', () => noContent()),
|
||||
|
||||
http.post('/api/admin/gps-settings/reconnect', () => {
|
||||
gpsTelemetry = { ...gpsTelemetry, connected: true, lastPacketMs: 0 }
|
||||
return jsonResponse({ settings: gpsSettings, telemetry: gpsTelemetry })
|
||||
}),
|
||||
]
|
||||
@@ -64,8 +64,14 @@ export const flightsHandlers = [
|
||||
return jsonResponse({ id: params.id, ...body })
|
||||
}),
|
||||
|
||||
// POST accepts both plural and singular paths. Production convention is
|
||||
// plural (REST collection); singular kept as a backward-compat alias.
|
||||
http.post('/api/flights/aircrafts', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'AC-NEW', ...body }, { status: 201 })
|
||||
}),
|
||||
http.post('/api/flights/aircraft', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'aircraft-new', ...body }, { status: 201 })
|
||||
return jsonResponse({ id: 'AC-NEW', ...body }, { status: 201 })
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { adminHandlers } from './admin'
|
||||
import { adminSettingsHandlers } from './admin-settings'
|
||||
import { flightsHandlers } from './flights'
|
||||
import { annotationsHandlers } from './annotations'
|
||||
import { detectHandlers } from './detect'
|
||||
@@ -12,6 +13,7 @@ import { tilesHandlers } from './tiles'
|
||||
// the seeded baseline. Per-test overrides land via `server.use(...)`.
|
||||
export const defaultHandlers = [
|
||||
...adminHandlers,
|
||||
...adminSettingsHandlers,
|
||||
...flightsHandlers,
|
||||
...annotationsHandlers,
|
||||
...detectHandlers,
|
||||
@@ -23,6 +25,7 @@ export const defaultHandlers = [
|
||||
|
||||
export {
|
||||
adminHandlers,
|
||||
adminSettingsHandlers,
|
||||
flightsHandlers,
|
||||
annotationsHandlers,
|
||||
detectHandlers,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cleanup } from '@testing-library/react'
|
||||
import { server } from './msw/server'
|
||||
import { setToken, setNavigateToLogin } from '../src/api'
|
||||
import { __resetBootstrapInflightForTests } from '../src/auth'
|
||||
import { resetAdminSettingsSeed } from './msw/handlers/admin-settings'
|
||||
|
||||
// JSDOM polyfills for browser APIs production code touches at mount time.
|
||||
// These are no-op stubs — tests that exercise the actual behavior install
|
||||
@@ -61,6 +62,8 @@ afterEach(() => {
|
||||
// AZ-510 — clear AuthProvider's module-scoped in-flight bootstrap promise so
|
||||
// a never-resolving fixture in test N does not leak into test N+1.
|
||||
__resetBootstrapInflightForTests()
|
||||
// v2 admin settings — module-scoped seed mutates on PATCH; reset between tests.
|
||||
resetAdminSettingsSeed()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
Reference in New Issue
Block a user