Delete the old top-level page files whose content was migrated into service-page tabs in slices 5-9: - pages/Media.tsx, Applications.tsx (-> MediaTab) - pages/FileBrowser.tsx, FileBrowser.impl.tsx (-> FilesTab) - pages/Actions.tsx (-> ActionsTab) - pages/Users.tsx, UsersPage.impl.tsx (replaced by Authentik tabs) - components/BackupsPage.tsx (-> JobsTab) - components/ObservabilityPage.tsx (split into Alerts/Links/Metrics tabs) - hooks/useUsers.ts (orphaned after Users page deletion) - the corresponding page test files (Media, FileBrowser, Applications, Actions, UsersPage) that tested the deleted pages directly. The service-tab components are the live implementations; ServicePage renders them. No live code references the deleted files. Docs: append an Information Architecture section to REQUIREMENTS.md documenting the services-as-hub model (nav shape, service-page tabs, service type registry, Users->Authentik, Observability split, legacy route 404s, empty state). Add a CHANGELOG entry under [Unreleased]. 92 frontend tests pass (was 112; -20 deleted page tests); 271 backend tests pass; lint/build green. Refs openspec/changes/services-as-hub-ia/ (tasks slice 11).
11 KiB
Slice 6 Review — ServicePage mobile form (mobile-responsive-parity)
Reviewer: fresh adversarial review
Scope: unstaged frontend/src/pages/ServicePage.tsx + new frontend/src/pages/__tests__/ServicePage.test.tsx
Commands run: npm run lint (0 errors, 2 pre-existing warnings in UsersPage.impl.tsx), npm run build (green), npm run test (110 passed; ServicePage suite 5/5).
Correct (verified with evidence)
- Desktop non-regression — token-identical. Compared
git show HEAD:ServicePage.tsxagainst the new desktop branch. The heading (<h2>+ binding.description + Badge), theSectionCard title="General"(Name + Enabled + Save/Delete),configFields(=<ServiceConnectionFields isMobile={false}>→ renders<SectionCard title="Connection" description="…">{fields}</SectionCard>with byte-identical field JSX),widgetsCard(identical conditional SectionCard), andconfirmDelete(identical ConfirmDialog) all render the same tree. The refactor only extracted inline JSX intoconfigFields/widgetsCard/confirmDeleteconsts and renamedServiceConnectionCard→ServiceConnectionFields; desktop output is unchanged. ✓ - Mobile SheetForm wiring.
sheetOpeninittrue(open-on-mount, ServicePage.tsx:79); title =name || instance.name(draft-aware, :166);onSave={save}(:167);onCancel={() => setSheetOpen(false)}(:168);isPending={saveService.isPending}disables Save in SheetForm footer. ✓ - Connection fields render without SectionCard on mobile.
ServiceConnectionFieldsisMobilebranch returns<div className="flex flex-col gap-3">{fields}</div>(no card) — the SheetForm is the container. Desktop branch still wraps inSectionCard title="Connection". ✓ - Save semantics preserved.
buildInput()(:111-121) returns{ id, service_type, name, config: draftConfig, secrets: {}, enabled };save()callssaveService.mutateAsync(buildInput()). ✓ - Secrets "leave blank to keep" preserved.
handleUpdateConnection()filtersdraftSecretsto non-blank only (filter(([,v]) => v !== "")); General Save still sendssecrets: {}. Same dual-save model as desktop. ✓ - Delete flow on both branches. Mobile branch renders
{confirmDelete}as a sibling of<SheetForm>(ServicePage.tsx:188), so the ConfirmDialog overlays correctly outside the sheet. Desktop unchanged. ✓ - Rules of Hooks — clean. In
ServicePage:useParams,useServiceInstances,useServiceTypes,useSaveServiceInstance,useDeleteServiceInstance, bothuseMemo, all fiveuseState,useIsMobile, anduseState(sheetOpen)are all called unconditionally before the!binding/!instanceearly returns. InServiceConnectionFields:useSaveServiceInstance()+useState(draftSecrets)at top, unconditionally. No conditional hooks. The earlier "useIsMobile inside a conditional" risk was correctly avoided. ✓ - Test quality — solid. Desktop test #2 asserts
queryByRole("dialog")is null (no SheetForm at ≥768px). Mobile test #2 edits the name, clicks Save, and assertsmutateAsynccalled once withinput.name === "Renamed Grafana"andinput.id === "svc-1". Mobile test #3 asserts thebase_urlconfig field is editable. All 5 pass. ✓
Confirmed issues (must-fix before commit)
Blocker-1 — R4.5 violation: Sheet does not close on successful save
Location: frontend/src/pages/ServicePage.tsx:117-119 (save()) and :165-170 (SheetForm onSave wiring).
save() is:
async function save() {
await saveService.mutateAsync(buildInput());
}
It never calls setSheetOpen(false). Spec R4.5 explicitly requires: "The Sheet closes on successful save and on explicit cancel." Cancel closes (onCancel → setSheetOpen(false)), but after a successful Save on mobile the sheet stays open. useSaveServiceInstance only invalidates queries; it does not close the sheet. This is a direct, testable deviation from the requirement that AC8/verify will flag.
Fix: close the sheet on successful resolve, e.g.
async function save() {
await saveService.mutateAsync(buildInput());
setSheetOpen(false);
}
(Then also address Blocker-2, since closing the sheet surfaces the empty-page problem.)
Notes / risks (non-blocking but important)
Risk-1 — "Cancel leaves empty page" is a REAL UX bug (not acceptable as-is)
The mobile branch (ServicePage.tsx:161-191) renders only <SheetForm> + {confirmDelete}. There is no list, no back button, no useNavigate. When the sheet closes — via Cancel today, or via Save once Blocker-1 is fixed — the user is stranded on a blank <div className="flex flex-col gap-4"> with no way back except browser history. This is a genuine UX defect, not an acceptable artifact of the sheet pattern: this page is reached via /services/:serviceType/:serviceId (deep link / row tap from ServicesPage), so closing the editor must return the user somewhere.
Recommendation: on sheet close (both save-success and cancel), navigate back to the services list — e.g. add const navigate = useNavigate(); and onOpenChange={(o) => { setSheetOpen(o); if (!o) navigate("/services"); }}, or render a fallback "Back to services" affordance when !sheetOpen. This should be resolved in this slice, not deferred, because Blocker-1's fix makes it user-visible.
Risk-2 — R4.5 dirty-state outside-click confirm not implemented
R4.5 also says the sheet "does not close on outside-click while the form is dirty (confirm prompt)." SheetForm passes onOpenChange straight through to Radix Sheet with no dirty guard, and ServicePage wires onOpenChange={setSheetOpen} directly. This is likely a cross-slice concern owned by the Slice-1 SheetForm deliverable, but it is currently unmet for this form. Flag for the verify pass / Slice 1 retro.
Suggestion-1 — Strengthen the mobile Save payload assertion
Mobile test #2 (ServicePage.test.tsx) only asserts input.name and input.id. To lock the save semantics claimed by the slice, also assert input.config (equals draftConfig), input.enabled, and input.secrets === {}. Cheap and prevents regressions.
Suggestion-2 — save() async-onClick typing
SheetForm.onSave is typed () => void but receives an async function; the promise is fire-and-forget. isPending correctly gates the button so this is functionally fine, but worth a comment or a .catch if error toast UX is added later.
Verdict
fix-then-commit.
The desktop non-regression, Rules-of-Hooks, secrets/delete semantics, and test scaffolding are all correct and verified. However, Blocker-1 (sheet does not close on save) is a clear, spec-cited (R4.5) deviation, and Risk-1 (empty page after close) is a real UX bug that becomes user-visible the moment Blocker-1 is fixed. Both should be addressed in this slice before commit. Risk-2 and the two suggestions are non-blocking follow-ups.
Acceptance
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "partially-satisfied",
"evidence": "Slice 6 implements ServicePage mobile SheetForm without widening scope (only ServicePage.tsx + new test). Desktop output verified token-identical to HEAD; Rules-of-Hooks clean; secrets/delete semantics preserved; lint/build/test green. BUT R4.5 'sheet closes on successful save' is not implemented (save() never calls setSheetOpen(false)) and closing the sheet strands the user on an empty page — must-fix before commit."
}
],
"changedFiles": [
"frontend/src/pages/ServicePage.tsx"
],
"testsAddedOrUpdated": [
"frontend/src/pages/__tests__/ServicePage.test.tsx"
],
"commandsRun": [
{ "command": "cd frontend && npm run lint", "result": "passed", "summary": "0 errors; 2 pre-existing warnings in UsersPage.impl.tsx (unrelated)" },
{ "command": "cd frontend && npm run build", "result": "passed", "summary": "vite build green (chunk-size advisory only)" },
{ "command": "cd frontend && npm run test", "result": "passed", "summary": "110/110 tests pass; ServicePage suite 5/5" },
{ "command": "git show HEAD:frontend/src/pages/ServicePage.tsx", "result": "passed", "summary": "Used to verify desktop branch token-identical to pre-change page" }
],
"validationOutput": [
"Desktop non-regression: CONFIRMED token-identical (heading, General, Connection, Widgets, ConfirmDialog).",
"Mobile SheetForm wiring (open-on-mount, title=draft name, onSave=save, onCancel closes, isPending disables Save): CONFIRMED.",
"Connection fields render without SectionCard inside sheet on mobile: CONFIRMED.",
"buildInput() + save()→mutateAsync: CONFIRMED.",
"Secrets leave-blank-to-keep (onlyChanged filter; General secrets:{}): CONFIRMED.",
"ConfirmDialog rendered OUTSIDE SheetForm on mobile (sibling): CONFIRMED.",
"Rules of Hooks (all hooks unconditional, before early returns): CONFIRMED clean.",
"R4.5 'closes on successful save': NOT MET — save() does not call setSheetOpen(false).",
"Empty page after sheet close (cancel/save): real UX bug, no back navigation."
],
"residualRisks": [
"Blocker-1: Sheet does not close on successful save (R4.5 violation) — ServicePage.tsx:117-119.",
"Risk-1: Closing the sheet (cancel, or save once fixed) leaves an empty page with no path back to /services — ServicePage.tsx mobile branch.",
"Risk-2: R4.5 dirty-state outside-click confirm not implemented at ServicePage/SheetForm level (likely Slice-1 cross-cutting concern)."
],
"noStagedFiles": true,
"diffSummary": "Adds a mobile (isMobile) branch to ServicePage that renders the edit form inside a SheetForm (open-on-mount, draft-name title, onSave=save, onCancel=close) with Connection fields unwrapped and ConfirmDialog as a sibling; extracts desktop JSX into configFields/widgetsCard/confirmDelete consts and renames ServiceConnectionCard→ServiceConnectionFields (isMobile prop) so the desktop output stays token-identical. Adds 5 Vitest cases (2 desktop, 3 mobile).",
"reviewFindings": [
"blocker: ServicePage.tsx:117-119 — save() does not close the sheet on success; violates R4.5.",
"blocker: ServicePage.tsx:161-191 — mobile branch has no back navigation; closing the sheet strands the user on an empty page (becomes visible once blocker-1 is fixed).",
"note: R4.5 dirty-state outside-click confirm not implemented (SheetForm passes onOpenChange through).",
"suggestion: ServicePage.test.tsx mobile Save test should also assert config/enabled/secrets payload, not just name+id."
],
"manualNotes": "Verdict: fix-then-commit. Desktop non-regression, hooks, and core save/delete/secrets semantics are correct and verified. The two blockers are tightly coupled (fixing save-close surfaces the empty-page gap) and should be resolved together in this slice: close sheet on save AND navigate back to /services (or render a fallback) on close."
}