From cfffb4bdd76783b3d4fe6d21f0e38d7dc9906caa Mon Sep 17 00:00:00 2001 From: Armen Rohalov Date: Tue, 26 May 2026 00:25:27 +0300 Subject: [PATCH] settings v2: implement design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite SettingsPage to 5-panel v2 layout: Tenant, Directories, Aircrafts, Language, Session — corner-bracket panels, sticky footer pinned to viewport bottom (Cancel + Save Changes), live dirty-state indicator. - Wire try/catch/finally + role="alert" in save handler so AZ-477's three it.fails contract tests flip to passing; remove the obsolete v1-drift control test and its unhandledRejection harness. - Add EN/UA language toggle; persist to localStorage('azaion.lang') and read on i18n init. Export LANG_STORAGE_KEY from src/i18n. - Add Add-Aircraft flow (reuses admin Modal) and view-only star default toggle. - Extend the v2 design system with .btn-danger-ghost, .star, .path-wrap/.browse classes. Scope settings.html-spec button proportions (padding 7px 14px, weight 400, letter-spacing 0.10em, line-height 1.5) under .settings-page so the admin spec is unaffected. - Restore module-scoped bootstrapInflight declaration in src/auth/AuthContext.tsx (deleted in 2a62415 while references remained — every test using tests/setup.ts was throwing ReferenceError). --- src/auth/AuthContext.tsx | 8 + src/features/settings/SettingsPage.tsx | 765 ++++++++++++++++++++++--- src/i18n/en.json | 34 +- src/i18n/i18n.ts | 14 +- src/i18n/index.ts | 2 +- src/i18n/ua.json | 34 +- src/index.css | 80 ++- tests/settings_resilience.test.tsx | 201 ++----- 8 files changed, 922 insertions(+), 216 deletions(-) diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index 90a64eb..8382bd0 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -16,6 +16,14 @@ export function useAuth() { return useContext(AuthContext) } +// React 18+ StrictMode double-invokes effects in dev (mount → cleanup → mount), +// and the backend rotates the refresh cookie on every successful POST. Two +// concurrent bootstraps would race the rotation and leave the second one with +// a stale cookie. The module-scoped in-flight promise lets the second mount +// await the first's network round-trip instead of duplicating it. Risk 4 in +// AZ-510 spec. +let bootstrapInflight: Promise | null = null + export function __resetBootstrapInflightForTests(): void { bootstrapInflight = null } diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 6ebac15..2164de7 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -1,106 +1,727 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' import { api, endpoints } from '../../api' +import { useAuth } from '../../auth' +import { LANG_STORAGE_KEY } from '../../i18n' import type { SystemSettings, DirectorySettings, Aircraft } from '../../types' +import { Modal } from '../admin/Modal' + +type Lang = 'en' | 'ua' +const I18N_BUNDLE_VERSION = 'v2.4.1' +const DASH = '—' + +type AircraftDraft = { + model: string + type: Aircraft['type'] + resolution: string + maxMinutes: number + isDefault: boolean +} +const NEW_AIRCRAFT_DEFAULTS: AircraftDraft = { + model: '', type: 'Copter', resolution: '4K', maxMinutes: 30, isDefault: false, +} +const AIRCRAFT_TYPES = ['Plane', 'Copter', 'FixedWing'] as const +const RESOLUTIONS = ['HD', '1080P', '4K', '6K'] as const +const TYPE_LEGEND_KEY: Record = { + Plane: 'legendPlane', Copter: 'legendCopter', FixedWing: 'legendFixedW', +} +const TYPE_CHIP_COLOR: Record = { + Plane: 'var(--accent-blue)', + Copter: 'var(--accent-green)', + FixedWing: 'var(--accent-amber)', +} +const TYPE_CHIP_BORDER: Record = { + Plane: 'rgba(78,158,255,0.45)', + Copter: 'rgba(61,220,132,0.45)', + FixedWing: 'rgba(255,157,61,0.45)', +} + +function FolderIcon() { + return ( + + + + ) +} +function SignOutIcon() { + return ( + + + + + + ) +} +function CheckIcon() { + return ( + + + + ) +} + +function dirtyTenant(a: SystemSettings | null, b: SystemSettings | null): boolean { + if (!a || !b) return false + return ( + a.militaryUnit !== b.militaryUnit || + a.name !== b.name || + a.defaultCameraWidth !== b.defaultCameraWidth || + a.defaultCameraFoV !== b.defaultCameraFoV + ) +} + +function dirtyDirs(a: DirectorySettings | null, b: DirectorySettings | null): boolean { + if (!a || !b) return false + return ( + a.imagesDir !== b.imagesDir || + a.labelsDir !== b.labelsDir || + a.thumbnailsDir !== b.thumbnailsDir + ) +} export default function SettingsPage() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() + const { user, logout } = useAuth() + const navigate = useNavigate() + const [system, setSystem] = useState(null) + const [systemInitial, setSystemInitial] = useState(null) const [dirs, setDirs] = useState(null) + const [dirsInitial, setDirsInitial] = useState(null) const [aircrafts, setAircrafts] = useState([]) const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + const lang: Lang = i18n.language === 'ua' ? 'ua' : 'en' + + const [aircraftModalOpen, setAircraftModalOpen] = useState(false) + const [aircraftDraft, setAircraftDraft] = useState(NEW_AIRCRAFT_DEFAULTS) + const [aircraftSaving, setAircraftSaving] = useState(false) + const [aircraftError, setAircraftError] = useState<'modelRequired' | 'saveFailed' | null>(null) useEffect(() => { - api.get(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {}) - api.get(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {}) + api.get(endpoints.annotations.settingsSystem()).then(s => { + setSystem(s) + setSystemInitial(s) + }).catch(() => {}) + api.get(endpoints.annotations.settingsDirectories()).then(d => { + setDirs(d) + setDirsInitial(d) + }).catch(() => {}) api.get(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {}) }, []) - const saveSystem = async () => { - if (!system) return + const tenantDirty = useMemo(() => dirtyTenant(system, systemInitial), [system, systemInitial]) + const dirsDirty = useMemo(() => dirtyDirs(dirs, dirsInitial), [dirs, dirsInitial]) + const anyDirty = tenantDirty || dirsDirty + + const dirtyLabel = useMemo(() => { + if (tenantDirty && dirsDirty) return `${t('settings.unitTenant')} · ${t('settings.unitDirectories')}` + if (tenantDirty) return t('settings.unitTenant') + if (dirsDirty) return t('settings.unitDirectories') + return '' + }, [tenantDirty, dirsDirty, t]) + + const save = async () => { setSaving(true) - await api.put(endpoints.annotations.settingsSystem(), system) - setSaving(false) + setSaveError(null) + try { + const tasks: Promise[] = [] + if (tenantDirty && system) tasks.push(api.put(endpoints.annotations.settingsSystem(), system)) + if (dirsDirty && dirs) tasks.push(api.put(endpoints.annotations.settingsDirectories(), dirs)) + await Promise.all(tasks) + if (system) setSystemInitial(system) + if (dirs) setDirsInitial(dirs) + } catch { + setSaveError(t('settings.saveError')) + } finally { + setSaving(false) + } } - const saveDirs = async () => { - if (!dirs) return - setSaving(true) - await api.put(endpoints.annotations.settingsDirectories(), dirs) - setSaving(false) + const cancel = () => { + setSystem(systemInitial) + setDirs(dirsInitial) + setSaveError(null) } const handleToggleDefault = async (a: Aircraft) => { - await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault }) - setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x)) + try { + await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault }) + setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x)) + } catch { + // best-effort — keep UI consistent on failure + } } - const field = (label: string, value: string | number | null | undefined, onChange: (v: string) => void, type = 'text') => ( + const changeLanguage = async (next: Lang) => { + await i18n.changeLanguage(next) + try { localStorage.setItem(LANG_STORAGE_KEY, next) } catch { /* private mode etc. */ } + } + + const handleSignOutEverywhere = async () => { + await logout() + navigate('/login') + } + + const openAircraftModal = () => { + setAircraftDraft(NEW_AIRCRAFT_DEFAULTS) + setAircraftError(null) + setAircraftModalOpen(true) + } + const closeAircraftModal = () => { + if (aircraftSaving) return + setAircraftModalOpen(false) + } + const saveAircraft = async () => { + if (!aircraftDraft.model.trim()) { setAircraftError('modelRequired'); return } + setAircraftError(null) + setAircraftSaving(true) + try { + const created = await api.post(endpoints.flights.aircrafts(), aircraftDraft) + setAircrafts(prev => { + if (created.isDefault) return [...prev.map(p => ({ ...p, isDefault: false })), created] + return [...prev, created] + }) + setAircraftModalOpen(false) + } catch { + setAircraftError('saveFailed') + } finally { + setAircraftSaving(false) + } + } + + return ( +
+
+ +
+ +
+
+

{t('settings.tenant')}

+ 01 +
+ +
+ setSystem(p => p ? { ...p, militaryUnit: v } : p)} + /> + setSystem(p => p ? { ...p, name: v } : p)} + /> +
+ setSystem(p => p ? { ...p, defaultCameraWidth: v } : p)} + /> + setSystem(p => p ? { ...p, defaultCameraFoV: v } : p)} + /> +
+
+
+
+ +
+
+

{t('settings.directories')}

+ 02 +
+ +
+ setDirs(p => p ? { ...p, imagesDir: v } : p)} + /> + setDirs(p => p ? { ...p, labelsDir: v } : p)} + /> + setDirs(p => p ? { ...p, thumbnailsDir: v } : p)} + /> +
+ {t('settings.storageFree')} + {DASH} +
+
+
+
+ +
+
+
+

{t('settings.aircrafts')}

+ 03 + + · {aircrafts.length} {t('settings.aircraftsRegistered')} + +
+ +
+ + + + + + + + + + + {aircrafts.map((a, idx) => ( + + + + + + ))} + {aircrafts.length === 0 && ( + + + + )} + +
+ {t('settings.colModel')} + + {t('settings.colType')} + + {t('settings.colDefault')} +
{a.model} + + + void handleToggleDefault(a)} + aria-label={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')} + title={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')} + /> +
{DASH}
+
+
+ +
+ +
+ +
+
+
+

{t('settings.language')}

+ 04 +
+ + {t('settings.locale')} · {lang === 'ua' ? 'UK-UA' : 'EN-US'} + +
+ +
+
+ + +
+
+ {t('settings.languageHint')} + + {t('settings.languageNote')} + +
+
+ + + {t('settings.languageBundle')}{' '} + {I18N_BUNDLE_VERSION} + +
+
+
+
+ +
+
+
+

{t('settings.session')}

+ 05 +
+ {t('settings.sessionActive')} +
+ +
+
+ {t('settings.lastLogin')} + + {DASH} + + + {user?.email ?? DASH} + +
+ +
+
+
+ +
+ +
+ +
+
+
+ {anyDirty ? ( + <> + + + {t('settings.unsavedChanges')}{' '} + {dirtyLabel} + + + ) : null} +
+ {saveError && ( +
+ {saveError} +
+ )} +
+ + +
+
+
+ + + + + + } + > +
+ + setAircraftDraft(p => ({ ...p, model: e.target.value }))} + placeholder="DJI Mavic 3" + aria-label={t('admin.aircrafts.fieldModel')} + /> +
+ +
+ +
+ {AIRCRAFT_TYPES.map(typ => ( + + ))} +
+
+ +
+
+ + +
+
+ + setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))} + style={{ textAlign: 'right' }} + aria-label={t('admin.aircrafts.fieldMaxMinutes')} + /> +
+
+ + + + {aircraftError && ( +
+ {t(`admin.aircrafts.${aircraftError}`)} +
+ )} +
+
+ ) +} + +// ===== Sub-components ===== + +function BracketPanel({ className, children }: { className?: string; children: ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function FieldLabel({ label, hint, hintColor }: { label: string; hint?: string; hintColor?: string }) { + return ( +
+ + {hint && ( + {hint} + )} +
+ ) +} + +function FieldText({ + label, hint, value, onChange, +}: { + label: string + hint?: string + value: string + onChange: (v: string) => void +}) { + return (
- + onChange(e.target.value)} - className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none focus:border-az-orange" + aria-label={label} />
) +} +function FieldNumber({ + label, hint, suffix, value, onChange, step, +}: { + label: string + hint?: string + suffix: string + value: number + onChange: (v: number) => void + step?: string +}) { return ( -
- {/* Tenant config */} -
-

{t('settings.tenant')}

- {system && ( -
- {field('Military Unit', system.militaryUnit, v => setSystem(p => p ? { ...p, militaryUnit: v } : p))} - {field('Name', system.name, v => setSystem(p => p ? { ...p, name: v } : p))} - {field('Default Camera Width', system.defaultCameraWidth, v => setSystem(p => p ? { ...p, defaultCameraWidth: parseInt(v) || 0 } : p), 'number')} - {field('Default Camera FoV', system.defaultCameraFoV, v => setSystem(p => p ? { ...p, defaultCameraFoV: parseFloat(v) || 0 } : p), 'number')} - -
- )} -
- - {/* Directories */} -
-

{t('settings.directories')}

- {dirs && ( -
- {field('Videos Dir', dirs.videosDir, v => setDirs(p => p ? { ...p, videosDir: v } : p))} - {field('Images Dir', dirs.imagesDir, v => setDirs(p => p ? { ...p, imagesDir: v } : p))} - {field('Labels Dir', dirs.labelsDir, v => setDirs(p => p ? { ...p, labelsDir: v } : p))} - {field('Results Dir', dirs.resultsDir, v => setDirs(p => p ? { ...p, resultsDir: v } : p))} - {field('Thumbnails Dir', dirs.thumbnailsDir, v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p))} - {field('GPS Sat Dir', dirs.gpsSatDir, v => setDirs(p => p ? { ...p, gpsSatDir: v } : p))} - {field('GPS Route Dir', dirs.gpsRouteDir, v => setDirs(p => p ? { ...p, gpsRouteDir: v } : p))} - -
- )} -
- - {/* Aircrafts */} -
-

{t('settings.aircrafts')}

-
- {aircrafts.map(a => ( -
- {a.model} - - {a.type} - - -
- ))} -
+
+ +
+ onChange(step ? parseFloat(e.target.value) || 0 : parseInt(e.target.value) || 0)} + aria-label={label} + style={{ paddingRight: 36 }} + /> + + {suffix} +
) } + +function PathField({ + label, statusLabel, statusColor, browseLabel, value, onChange, +}: { + label: string + statusLabel: string + statusColor: string + browseLabel: string + value: string + onChange: (v: string) => void +}) { + return ( +
+ +
+ + onChange(e.target.value)} + aria-label={label} + /> + +
+
+ ) +} + +function AircraftTypeChip({ type, label }: { type: Aircraft['type']; label: string }) { + const color = TYPE_CHIP_COLOR[type] + return ( + + + {label} + + ) +} + +function StarButton({ + active, onClick, ...rest +}: { + active: boolean + onClick: () => void +} & React.ButtonHTMLAttributes) { + return ( + + ) +} diff --git a/src/i18n/en.json b/src/i18n/en.json index b577bd8..314f911 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -189,7 +189,39 @@ "tenant": "Tenant Configuration", "directories": "Directories", "aircrafts": "Aircrafts", - "save": "Save" + "save": "Save", + "militaryUnit": "Military Unit", + "unitName": "Name", + "camWidth": "Cam Width", + "camFoV": "Cam FoV", + "required": "REQ", + "imagesDir": "Images Dir", + "labelsDir": "Labels Dir", + "thumbnailsDir": "Thumbnails Dir", + "mounted": "MOUNTED", + "cache": "CACHE", + "browse": "Browse", + "storageFree": "Storage Free", + "aircraftsRegistered": "REGISTERED", + "addAircraft": "Add Aircraft", + "colModel": "Model", + "colType": "Type", + "colDefault": "Default", + "language": "Language", + "languageHint": "Affects all UI text", + "languageNote": "Detection class names also use the localized field from seed data.", + "languageBundle": "i18n BUNDLE", + "locale": "Locale", + "session": "Session", + "sessionActive": "ACTIVE", + "lastLogin": "Last Login", + "signOutEverywhere": "Sign out everywhere", + "cancel": "Cancel", + "saveChanges": "Save Changes", + "saveError": "Save failed. Please try again.", + "unsavedChanges": "Unsaved changes detected in", + "unitTenant": "TENANT", + "unitDirectories": "DIRECTORIES" }, "common": { "confirm": "Confirm", diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index 3aeeac0..8ea4bf0 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -3,9 +3,21 @@ import { initReactI18next } from 'react-i18next' import en from './en.json' import ua from './ua.json' +export const LANG_STORAGE_KEY = 'azaion.lang' + +function readPersistedLanguage(): 'en' | 'ua' { + // Safari private mode throws on localStorage access — fall back to 'en'. + try { + const persisted = localStorage.getItem(LANG_STORAGE_KEY) + return persisted === 'ua' || persisted === 'en' ? persisted : 'en' + } catch { + return 'en' + } +} + i18n.use(initReactI18next).init({ resources: { en: { translation: en }, ua: { translation: ua } }, - lng: 'en', + lng: readPersistedLanguage(), fallbackLng: 'en', interpolation: { escapeValue: false }, }) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 3820525..ab67a5a 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1 +1 @@ -export { default } from './i18n' +export { default, LANG_STORAGE_KEY } from './i18n' diff --git a/src/i18n/ua.json b/src/i18n/ua.json index a587f27..abc3429 100644 --- a/src/i18n/ua.json +++ b/src/i18n/ua.json @@ -189,7 +189,39 @@ "tenant": "Конфігурація", "directories": "Директорії", "aircrafts": "Літальні апарати", - "save": "Зберегти" + "save": "Зберегти", + "militaryUnit": "Військова частина", + "unitName": "Назва", + "camWidth": "Ширина кадру", + "camFoV": "Кут огляду", + "required": "ОБ.", + "imagesDir": "Директорія зображень", + "labelsDir": "Директорія міток", + "thumbnailsDir": "Директорія мініатюр", + "mounted": "ПІД'ЄДНАНО", + "cache": "КЕШ", + "browse": "Огляд", + "storageFree": "Вільно", + "aircraftsRegistered": "ЗАРЕЄСТРОВАНО", + "addAircraft": "Додати апарат", + "colModel": "Модель", + "colType": "Тип", + "colDefault": "За замовч.", + "language": "Мова", + "languageHint": "Впливає на весь UI", + "languageNote": "Назви класів детекцій теж беруться з локалізованого поля seed-даних.", + "languageBundle": "i18n БАНДЛ", + "locale": "Локаль", + "session": "Сесія", + "sessionActive": "АКТИВНА", + "lastLogin": "Останній вхід", + "signOutEverywhere": "Вийти всюди", + "cancel": "Скасувати", + "saveChanges": "Зберегти зміни", + "saveError": "Не вдалося зберегти. Спробуйте ще раз.", + "unsavedChanges": "Незбережені зміни в", + "unitTenant": "КОНФІГУРАЦІЇ", + "unitDirectories": "ДИРЕКТОРІЯХ" }, "common": { "confirm": "Підтвердити", diff --git a/src/index.css b/src/index.css index 8e22dc1..5304d7c 100644 --- a/src/index.css +++ b/src/index.css @@ -209,6 +209,15 @@ body { border-color: var(--border-hair); } .btn-ghost:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-raised); } +.btn-danger-ghost { + background: transparent; + color: var(--accent-red); + border-color: rgba(255,71,86,0.5); +} +.btn-danger-ghost:hover:not(:disabled) { + background: rgba(255,71,86,0.08); + border-color: var(--accent-red); +} .btn-danger { background: var(--accent-red); color: #0A0D10; @@ -336,10 +345,79 @@ header .ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent border-radius: 2px; } +/* Settings v2 — settings.html mock specs larger buttons than admin.html mock. + Scope to the Settings page only so Admin keeps its tighter spec. + line-height: 1.5 matches the mock's body inheritance (its .btn doesn't use + the font shorthand, so it inherits body's line-height instead of "normal"). */ +.settings-page .btn { + height: auto; + padding: 7px 14px; + font-weight: 400; + line-height: 1.5; + letter-spacing: 0.10em; + gap: 8px; +} +.settings-page .seg-btn { + height: auto; + padding: 7px 18px; + font-size: 11px; + line-height: 1.5; + letter-spacing: 0.14em; + font-weight: 400; +} +.settings-page .seg-btn.active { font-weight: 600; border-right: 0; } +.settings-page .seg-btn + .seg-btn { border-left: 1px solid var(--border-hair); } +.settings-page .btn-primary:hover:not(:disabled) { filter: brightness(1.05); } + /* Star button */ -.star { color: var(--accent-amber); } +.star { + background: transparent; + border: 0; + cursor: pointer; + color: var(--text-muted); + font-size: 18px; + line-height: 1; + padding: 4px; + transition: color .12s, transform .12s; +} +.star:hover { color: var(--accent-amber); } +.star.active { color: var(--accent-amber); } .star-off { color: var(--text-muted); } +/* Path input with Browse button (Settings v2 directories panel). */ +.path-wrap { position: relative; display: flex; align-items: center; } +.path-wrap .path-icon { + position: absolute; + left: 10px; + color: var(--text-muted); + display: flex; + align-items: center; + pointer-events: none; +} +.path-wrap .browse { + position: absolute; + right: 4px; + top: 4px; + height: 24px; + padding: 0 10px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-secondary); + background: transparent; + border: 1px solid var(--border-hair); + border-radius: 2px; + cursor: pointer; + transition: color .12s, border-color .12s, background .12s; +} +.path-wrap .browse:hover { + color: var(--accent-amber); + border-color: var(--accent-amber); + background: rgba(255,157,61,0.06); +} +.path-wrap > input.inp { padding-left: 30px; padding-right: 70px; } + /* Pulse for live dot */ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } .live { animation: pulse 1.6s ease-in-out infinite; } diff --git a/tests/settings_resilience.test.tsx b/tests/settings_resilience.test.tsx index 3e70d26..5d8e108 100644 --- a/tests/settings_resilience.test.tsx +++ b/tests/settings_resilience.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from './msw/server' import { jsonResponse } from './msw/helpers' -import { renderWithProviders, screen, waitFor, userEvent, within } from './helpers/render' +import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render' import { seedBearer, clearBearer } from './helpers/auth' import { SettingsPage } from '../src/features/settings' import { seedAircraft } from './fixtures/seed_aircraft' @@ -18,16 +18,9 @@ import type { SystemSettings, DirectorySettings } from '../src/types' // AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error // to error visibility ≤ 2 s. // -// Production today (`SettingsPage.saveSystem` / `saveDirs`) does -// setSaving(true); await api.put(...); setSaving(false) -// with no try/finally and no error region in the JSX. Both AC-1 and AC-2 are -// drift today: the button stays disabled forever and no alert appears. The -// AC-3 deadline assertion is also vacuously failing (no DOM element to find). -// We mark the contract assertions `it.fails()` and pin the current drift with -// control tests, so: -// - The drift is documented in the test suite. -// - The contract tests will start passing the moment SettingsPage wires -// try/finally + an error region — no edits to this file required. +// v2 SettingsPage wraps `save()` in try/catch/finally and renders an inline +// role="alert" in the sticky footer when the PUT rejects. The three contract +// tests below assert that wiring directly. const SYSTEM_SEED: SystemSettings = { id: 'sys-1', @@ -84,163 +77,93 @@ function rigSettingsEnv(failure: SettingsFailure): SettingsRig { } /** - * SettingsPage renders two "Save" buttons (one per panel) once both GETs - * resolve. We always exercise the *system* panel — its handler (`saveSystem`) - * has the same try-finally drift as `saveDirs`, and scoping the query to - * "Tenant Configuration" makes the selector unambiguous regardless of which - * GET resolves first. + * SettingsPage (v2) renders a single sticky-footer "Save Changes" button that + * persists whichever panels are dirty in parallel. The footer button is the + * only Save affordance; per-panel Save buttons no longer exist. We must mark + * the Tenant panel as dirty by editing a field before the footer button + * becomes enabled — selecting the Military Unit input by accessible name and + * typing a single character is enough to flip the dirty flag. */ async function findSystemSaveButton(): Promise { - const systemHeading = await screen.findByRole('heading', { name: /Tenant Configuration/i }) - const panel = systemHeading.parentElement as HTMLElement - return within(panel).getByRole('button', { name: /^Save$/i }) + // Wait until the data has loaded (heading is present immediately, but the + // input is rendered only after the GET resolves). + await screen.findByRole('heading', { name: /Tenant Configuration/i }) + return screen.getByRole('button', { name: /^Save Changes$/i }) +} + +async function makeTenantDirty(): Promise { + const militaryUnit = await screen.findByLabelText(/Military Unit/i) + await userEvent.type(militaryUnit, '!') } async function renderAndClickSave(): Promise { renderWithProviders() + await makeTenantDirty() const saveButton = await findSystemSaveButton() await userEvent.click(saveButton) } describe('AZ-477 — Settings save resilience + 2 s error budget', () => { - // Production today has no try/catch around the settings-save api.put(). - // When MSW returns 500 (or HttpResponse.error()), the rejected promise - // becomes an unhandled rejection at the process level and Vitest fails - // the run with exit code 1 — even though every test assertion passes. - // This handler swallows the *expected* rejection pattern only, so any - // unexpected unhandled rejection still surfaces as a hard failure. - // The drift itself is asserted by the it.fails() contract tests above - // ("Save button stays disabled" / "no DOM error region"). - let suppressedRejections: unknown[] = [] - const onUnhandled = (reason: unknown): void => { - const msg = - reason instanceof Error - ? reason.message - : typeof reason === 'string' - ? reason - : '' - if ( - msg.startsWith('500: upstream failure') || - msg.startsWith('Failed to fetch') || - msg === 'Network error' || - msg.includes('network error') - ) { - suppressedRejections.push(reason) - return - } - // Re-throw — surface unexpected rejections to the test runner. - throw reason - } - beforeEach(() => { seedBearer() - suppressedRejections = [] - process.on('unhandledRejection', onUnhandled) }) afterEach(() => { clearBearer() - process.off('unhandledRejection', onUnhandled) - // Sanity: every test in this file expects exactly one swallowed - // rejection (the settings PUT). If a test triggers more — or zero — the - // drift assumption changed and the harness should flag it. - if (suppressedRejections.length > 1) { - throw new Error( - `AZ-477 harness: expected at most 1 suppressed rejection, got ${suppressedRejections.length}`, - ) - } }) describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => { - it.fails( - 'PUT 500 → Save button is no longer disabled within 2 s', - async () => { - // Drift: saveSystem awaits api.put() outside a try/finally; on a - // rejected promise the trailing `setSaving(false)` is never reached - // and the button stays disabled forever. - rigSettingsEnv({ kind: 'http', status: 500 }) - await renderAndClickSave() - const saveButton = await findSystemSaveButton() - await waitFor( - () => expect(saveButton).not.toBeDisabled(), - { timeout: 2000 }, - ) - }, - ) - - it.fails( - 'PUT 500 → an in-DOM error region (role="alert") appears within 2 s', - async () => { - // Drift: SettingsPage renders no error region. Will pass once a - // toast / inline alert is wired into the save handler. - rigSettingsEnv({ kind: 'http', status: 500 }) - await renderAndClickSave() - const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 }) - // Message shape: production task picks the i18n key; the test only - // asserts that *some* user-visible error text is present. - expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0) - }, - ) - - it('control: today the Save button stays disabled after a 500 (current drift)', async () => { - // Pins the silent-failure drift: button remains in `disabled` state - // because setSaving(false) is unreachable. - const rig = rigSettingsEnv({ kind: 'http', status: 500 }) + it('PUT 500 → Save button is no longer disabled within 2 s', async () => { + rigSettingsEnv({ kind: 'http', status: 500 }) await renderAndClickSave() - await waitFor(() => expect(rig.systemPuts).toBe(1)) - // Wait briefly past the response; the button must stay disabled - // (drift: setSaving(false) is unreachable past the rejected await). - await new Promise((r) => setTimeout(r, 100)) const saveButton = await findSystemSaveButton() - expect(saveButton).toBeDisabled() + await waitFor( + () => expect(saveButton).not.toBeDisabled(), + { timeout: 2000 }, + ) + }) + + it('PUT 500 → an in-DOM error region (role="alert") appears within 2 s', async () => { + rigSettingsEnv({ kind: 'http', status: 500 }) + await renderAndClickSave() + const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 }) + // Message shape: production task picks the i18n key; the test only + // asserts that *some* user-visible error text is present. + expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0) }) }) describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => { - it.fails( - 'network error → Save button is no longer disabled within 2 s', - async () => { - rigSettingsEnv({ kind: 'network' }) - await renderAndClickSave() - const saveButton = await findSystemSaveButton() - await waitFor( - () => expect(saveButton).not.toBeDisabled(), - { timeout: 2000 }, - ) - }, - ) + it('network error → Save button is no longer disabled within 2 s', async () => { + rigSettingsEnv({ kind: 'network' }) + await renderAndClickSave() + const saveButton = await findSystemSaveButton() + await waitFor( + () => expect(saveButton).not.toBeDisabled(), + { timeout: 2000 }, + ) + }) - it.fails( - 'network error → an in-DOM error region (role="alert") appears within 2 s', - async () => { - rigSettingsEnv({ kind: 'network' }) - await renderAndClickSave() - const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 }) - expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0) - }, - ) + it('network error → an in-DOM error region (role="alert") appears within 2 s', async () => { + rigSettingsEnv({ kind: 'network' }) + await renderAndClickSave() + const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 }) + expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0) + }) }) describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => { - it.fails( - '500 → DOM error region visible within 2000 ms of the response', - async () => { - // The deadline is measured from the moment the 500 response is - // returned by MSW (rig.responseAt.value) to the moment role="alert" - // is found. Today the alert never appears; the assertion is set so - // it will pass the moment the alert is wired AND comes up under the - // 2-second budget. - const rig = rigSettingsEnv({ kind: 'http', status: 500 }) - await renderAndClickSave() - const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 }) - const alertVisibleAt = performance.now() - expect(rig.responseAt.value).not.toBeNull() - const elapsed = alertVisibleAt - (rig.responseAt.value as number) - // Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms. - expect(elapsed).toBeGreaterThanOrEqual(0) - expect(elapsed).toBeLessThanOrEqual(2000) - expect(alertEl).toBeInTheDocument() - }, - ) + it('500 → DOM error region visible within 2000 ms of the response', async () => { + const rig = rigSettingsEnv({ kind: 'http', status: 500 }) + await renderAndClickSave() + const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 }) + const alertVisibleAt = performance.now() + expect(rig.responseAt.value).not.toBeNull() + const elapsed = alertVisibleAt - (rig.responseAt.value as number) + // Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms. + expect(elapsed).toBeGreaterThanOrEqual(0) + expect(elapsed).toBeLessThanOrEqual(2000) + expect(alertEl).toBeInTheDocument() + }) }) })