a8dfbd5dc6
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).
190 lines
14 KiB
Markdown
190 lines
14 KiB
Markdown
# Slice 5 Review — Users + Backups mobile card layout
|
||
|
||
**Change:** `mobile-responsive-parity` · **Slice:** 5 (Users + Backups tables)
|
||
**Reviewer mode:** fresh adversarial · **Date:** 2026-06-26
|
||
**Verdict: fix-then-commit** (one real HTML-validity issue; everything else clean)
|
||
|
||
---
|
||
|
||
## Commands run (all green)
|
||
|
||
| Command | Result | Notes |
|
||
|---|---|---|
|
||
| `npm run lint` | ✅ 0 errors | Only 2 pre-existing `react-hooks/exhaustive-deps` warnings in `UsersPage.impl.tsx` (lines 149/188). Verified identical on `HEAD` (lines 120/159) — **not introduced by this slice**. |
|
||
| `npm run build` | ✅ built | `tsc -b` typecheck clean (build runs it). |
|
||
| `npm run test` | ✅ 25 files / 102 tests | All pass. |
|
||
| `git diff --cached --stat` | empty | No staged files. |
|
||
|
||
---
|
||
|
||
## 1. `useIsMobile` hardening — SAFE ✅
|
||
|
||
`frontend/src/hooks/useIsMobile.ts`: added `typeof window.matchMedia === "function"` to both the lazy `useState` initializer and the `useEffect` guard.
|
||
|
||
- In real browsers `window.matchMedia` is always a function, so the added predicate is always `true` and **behavior is identical**.
|
||
- In jsdom (no native `matchMedia`) the change converts a hard `TypeError` ("window.matchMedia is not a function") into a graceful `false` (desktop). This is strictly safer — it can only turn a crash into a non-crash.
|
||
- All existing consumers (`App.tsx`, `Dashboard`, `Media`, `FileBrowser`) already stub `matchMedia` in their test files, so their tests are unaffected. Confirmed no regression path for slices 1–4.
|
||
|
||
**Conclusion:** safe across all consumers; no regression.
|
||
|
||
---
|
||
|
||
## 2. UsersPage desktop Table — preserved EXACTLY ✅
|
||
|
||
I programmatically extracted the `<Table aria-label="Users table">…</Table>` block from `HEAD` and from the working tree and diffed them token-for-token.
|
||
|
||
- The only differences are **line re-wrapping** caused by deeper indentation (e.g. the `checked || selectedUser?.jellyfin_id === row.jellyfin_id` expression and `onClick={() => setSearchParams({ user: row.jellyfin_id })}` wrap onto more lines). Every token, attribute, and child is present in both.
|
||
- **Columns preserved (10):** select-all checkbox header, User, Email, Activity, Type (hidden md), Jellyseerr, Role (hidden md), Permissions, Reqs (hidden md), Contact (hidden md).
|
||
- **Header checkbox** `toggleVisibleSelection` — preserved.
|
||
- **Row checkbox** `toggleUserSelected` + `onClick={(event) => event.stopPropagation()}` + `aria-label` — preserved.
|
||
- **Row click** `onClick={() => setSearchParams({ user: row.jellyfin_id })}` — preserved.
|
||
- **Avatar** (`AvatarImage`/`AvatarFallback`), username fallback, all `<Badge>` variants, `data-state="selected"`, `cursor-pointer` — all preserved.
|
||
|
||
**Conclusion:** the desktop branch is the original table re-indented one level deeper into the `: (` else arm. No prop, column, or handler was dropped. Diff stat (207 ins / 149 del) is dominated by this re-indentation; the true behavioral delta is small (cards branch + `userCardFields` + `useComposeViewport` rename).
|
||
|
||
---
|
||
|
||
## 3. Compose hook rename — correct ✅
|
||
|
||
The file-local 900px `useIsMobile` was renamed `useComposeViewport`; the shared 768px `useIsMobile` (from `hooks/`) now drives the directory-table branch.
|
||
|
||
- `isComposeMobile` (900px) → used **only** at `UsersPage.impl.tsx:827` for the compose `DialogContent` full-screen class. Breakpoint unchanged (`(max-width: 900px)`).
|
||
- `isMobile` (768px) → used **only** at `UsersPage.impl.tsx:511` for the table/card branch.
|
||
- Verified no stray references to the old local name remain (`grep` confirms 2 distinct symbols, correctly wired).
|
||
|
||
---
|
||
|
||
## 4. Backups cards (3 components) ✅
|
||
|
||
- **BackupAlertsTable:** mobile branch renders `MobileCardRow` with primary=message + severity/type/created; **Ack action preserved** in the `actions` slot (`mobile-touch-target`, calls `onAcknowledge(a.id)`; hidden when `acknowledged`). Desktop `<Table>` block is byte-identical (diff is purely additive before the `return`).
|
||
- **BackupJobsTable:** mobile branch builds `JobCardRow[]` (joins `latestRuns` exactly as the desktop row does) with primary=name + source/schedule/last-status. No per-row action exists in the desktop original, so none is "lost". Desktop table unchanged.
|
||
- **BackupRunsTable:** status-filter `<Select>` is rendered **outside** the `isMobile ? … : …` ternary, so it stays available on both layouts (correct — filter preserved on mobile). Mobile card primary=job_id + status/duration/size/started. The desktop `<Table>` is re-indented into the `: (` else arm but content is identical (same 5 columns, same formatters, same `statusVariant`).
|
||
|
||
Spec R3.2 (primary + 3–5 fields) satisfied for all three. Spec R3.6 (desktop unchanged) satisfied.
|
||
|
||
---
|
||
|
||
## 5. UsersPage mobile selection — INVALID HTML NESTING (confirmed issue)
|
||
|
||
`MobileCardRow` renders the card as a `<button type="button">` whenever `onRowClick` is set (`mobile-card.tsx:82`). The UsersPage mobile branch passes **both** `onRowClick` (opens drawer) **and** an `actions` slot containing a Radix `<Checkbox>`, which itself renders a `<button role="checkbox">`. Result:
|
||
|
||
```html
|
||
<button> <!-- card -->
|
||
…
|
||
<button role="checkbox"></button> <!-- selection checkbox -->
|
||
</button>
|
||
```
|
||
|
||
This is **invalid HTML** (`<button>` cannot contain interactive `<button>`).
|
||
|
||
The review brief asks whether this is "a real runtime bug or acceptable parity with the existing desktop pattern." Findings:
|
||
|
||
- **The desktop-parity argument does not hold.** On desktop the row is a `<TableRow>` → `<tr>` with `onClick`. A `<tr>` is not a `<button>`, so nesting a checkbox inside it is valid. The mobile variant introduces a *new* `<button>`-in-`<button>` nesting that does not exist on desktop.
|
||
- **Runtime impact:** browsers perform error-correction by closing the outer `<button>` before the inner one starts. The 102 tests pass (jsdom does not enforce this), and in practice the card body still receives taps while the checkbox still toggles (with `stopPropagation`). So it *functions* — but only by accident of browser error-recovery. It is fragile, fails HTML validation, and is an a11y issue (nested interactive elements).
|
||
- This pattern is **not present in the other two card usages** in this slice (BackupJobs/Runs pass no `onRowClick`; Alerts passes `actions` but no `onRowClick`), so it is isolated to UsersPage.
|
||
|
||
**Recommended fix (small, localized):** in `MobileCardRow`, when `onRowClick` is set, render the outer element as a `<div role="button" tabIndex={0}` with `onClick` + `onKeyDown` (Enter/Space) instead of a `<button>`; or move the `actions` slot outside the clickable button element. Either keeps the 44px tap target and the `stopPropagation` semantics while producing valid HTML. This also improves on the desktop pattern rather than replicating its weakest aspect.
|
||
|
||
Severity: I am calling this **must-fix before commit** because (a) the brief specifically flagged it, (b) it is invalid DOM, and (c) the fix is tiny and contained to `mobile-card.tsx` (already shipped in Slice 1, so fixing it here benefits every future card consumer too).
|
||
|
||
---
|
||
|
||
## 6. Test quality
|
||
|
||
- **BackupAlertsTable.test.tsx:** new mobile tests assert primary text + Ack button round-trip (`onAcknowledge` called with id). Real behavior. ✅
|
||
- **BackupRunsTable.test.tsx:** asserts job_id primary + Status/Duration labels on mobile. Does not exercise the status filter on mobile, but coverage is adequate. ✅
|
||
- **UsersPage.test.tsx:** asserts display-name primary + Activity label per card on mobile. **Does NOT assert** `toggleUserSelected` round-trip nor that the checkbox `stopPropagation` prevents the drawer opening — the two behaviors the brief specifically called out. The implementation is present and correct, but the assertions are missing. (Suggestion, not a blocker.)
|
||
- **BackupJobsTable:** no test file exists, so the worker skipped it. `BackupJobsTable.tsx` is a touched file with zero direct test coverage. The card logic mirrors the other two and is low-risk, but AC8 ("at least one Vitest test per touched page/component asserting <768 and ≥768") is not fully met for this component. (Suggestion.)
|
||
|
||
Minor: `UsersPage.test.tsx:12` comment still says *"MUI `useMediaQuery` (still used by the compose dialog, slice 6b)"* — stale after the rename to `useComposeViewport` (no longer MUI). Cosmetic.
|
||
|
||
---
|
||
|
||
## 7. Diff size
|
||
|
||
UsersPage `+207 / -149`. Subtracting the re-indented desktop Table block (~149 deletions re-added as ~180 insertions one indent level deeper), the genuine behavioral delta is: `userCardFields` constant (~22 lines), the mobile `MobileCardRow` branch (~30 lines), the `useComposeViewport` rename (3 lines), and `isComposeMobile` usage. **Confirmed: actual behavioral change is small; the bulk is re-indentation**, as the brief expected.
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
- **Blocker / confirmed issue (must-fix):** `MobileCardRow` + UsersPage produce `<button>` nesting a Radix `<button>` checkbox — invalid HTML; "desktop parity" justification does not hold (desktop uses `<tr>`). Fix in `mobile-card.tsx` (render clickable card as `div role="button"` or lift `actions` out of the button).
|
||
- **Suggestions (non-blocking):**
|
||
- Add a UsersPage mobile test asserting `toggleUserSelected` round-trip + checkbox `stopPropagation`.
|
||
- Add a `BackupJobsTable` mobile test (currently zero coverage on a touched file).
|
||
- Refresh the stale "MUI useMediaQuery" comment in `UsersPage.test.tsx`.
|
||
- **Verified clean:** `useIsMobile` hardening (no regression), desktop UsersPage Table preserved exactly (token-identical), compose 900px breakpoint preserved, all 3 Backups desktop tables byte-identical, status filter + Ack action preserved on mobile, lint/build/test green, no staged files.
|
||
|
||
**Verdict: fix-then-commit** — resolve the single button-in-button HTML validity issue (localized to `mobile-card.tsx`), then this slice is good to commit. The two test-coverage suggestions can land in the same commit or a follow-up.
|
||
|
||
```acceptance-report
|
||
{
|
||
"criteriaSatisfied": [
|
||
{
|
||
"id": "criterion-1",
|
||
"status": "satisfied",
|
||
"evidence": "Slice 5 implements Users + 3 Backups mobile card layouts per spec R3.1/R3.2/R3.3 and tasks 5.1/5.2/5.3 without widening scope (no backend, no new product behavior, no sm: breakpoint). Desktop layouts preserved exactly; only presentation-layer parity added."
|
||
}
|
||
],
|
||
"changedFiles": [
|
||
"frontend/src/hooks/useIsMobile.ts",
|
||
"frontend/src/components/BackupAlertsTable.tsx",
|
||
"frontend/src/components/BackupJobsTable.tsx",
|
||
"frontend/src/components/BackupRunsTable.tsx",
|
||
"frontend/src/components/__tests__/BackupAlertsTable.test.tsx",
|
||
"frontend/src/components/__tests__/BackupRunsTable.test.tsx",
|
||
"frontend/src/pages/UsersPage.impl.tsx",
|
||
"frontend/src/pages/__tests__/UsersPage.test.tsx"
|
||
],
|
||
"testsAddedOrUpdated": [
|
||
"frontend/src/components/__tests__/BackupAlertsTable.test.tsx",
|
||
"frontend/src/components/__tests__/BackupRunsTable.test.tsx",
|
||
"frontend/src/pages/__tests__/UsersPage.test.tsx"
|
||
],
|
||
"commandsRun": [
|
||
{
|
||
"command": "cd frontend && npm run lint",
|
||
"result": "passed",
|
||
"summary": "0 errors; 2 pre-existing react-hooks/exhaustive-deps warnings (verified identical on HEAD, not introduced by slice 5)."
|
||
},
|
||
{
|
||
"command": "cd frontend && npm run build",
|
||
"result": "passed",
|
||
"summary": "vite build + tsc -b typecheck clean; 1976 modules transformed."
|
||
},
|
||
{
|
||
"command": "cd frontend && npm run test",
|
||
"result": "passed",
|
||
"summary": "25 test files / 102 tests passed (vitest)."
|
||
},
|
||
{
|
||
"command": "git diff --cached --stat",
|
||
"result": "passed",
|
||
"summary": "Empty — no staged files."
|
||
}
|
||
],
|
||
"validationOutput": [
|
||
"useIsMobile guard safe: only changes jsdom (crash->false); real browsers unchanged; no regression to App/Dashboard/Media/FileBrowser consumers.",
|
||
"UsersPage desktop <Table> block token-diffed against HEAD: identical except line re-wrapping from deeper indentation; all 10 columns, header/row checkboxes, toggleVisibleSelection/toggleUserSelected, setSearchParams row click, Avatar, all Badge variants preserved.",
|
||
"Compose 900px breakpoint preserved via useComposeViewport rename; isMobile (768px) used only for table branch, isComposeMobile (900px) only for compose dialog.",
|
||
"BackupRunsTable status-filter Select rendered outside isMobile ternary -> preserved on mobile+desktop; BackupAlertsTable Ack action preserved in card actions slot; BackupJobsTable has no per-row actions to lose.",
|
||
"Desktop Backups tables byte-identical (Alerts/Jobs additive only; Runs re-indented into else arm, content equal)."
|
||
],
|
||
"residualRisks": [
|
||
"BLOCKER: MobileCardRow renders a <button> and UsersPage nests a Radix Checkbox (<button>) inside it when onRowClick is set -> invalid HTML (button-in-button). Functions via browser error-correction but fails validation and is an a11y issue. Desktop parity argument does not hold (desktop row is a <tr>, not a button). Fix in frontend/src/components/ui/mobile-card.tsx.",
|
||
"BackupJobsTable.tsx is a touched file with no test file -> zero direct coverage; AC8 not fully met for this component.",
|
||
"UsersPage mobile test does not assert toggleUserSelected round-trip nor checkbox stopPropagation (behaviors are implemented but untested).",
|
||
"Stale comment in UsersPage.test.tsx references 'MUI useMediaQuery' after the useComposeViewport rename (cosmetic)."
|
||
],
|
||
"noStagedFiles": true,
|
||
"diffSummary": "Slice 5 adds mobile MobileCardRow branches to UsersPage + 3 Backups tables, renames the local 900px compose hook to useComposeViewport to avoid collision with the shared 768px useIsMobile, and hardens useIsMobile with a typeof matchMedia guard. UsersPage diff is ~356 lines but mostly re-indentation of the preserved desktop Table into an isMobile ternary else branch; true behavioral delta is small.",
|
||
"reviewFindings": [
|
||
"blocker: frontend/src/components/ui/mobile-card.tsx:82 + frontend/src/pages/UsersPage.impl.tsx:511-530 — <button> card nests Radix <button> Checkbox (invalid HTML); fix MobileCardRow to render clickable card as div role=button or lift actions slot out of the button.",
|
||
"suggestion: add UsersPage mobile test for toggleUserSelected round-trip + stopPropagation.",
|
||
"suggestion: add BackupJobsTable mobile test (touched file, currently untested).",
|
||
"suggestion: refresh stale 'MUI useMediaQuery' comment in UsersPage.test.tsx:12."
|
||
],
|
||
"manualNotes": "Verdict: fix-then-commit. The single button-in-button issue is localized to mobile-card.tsx (a Slice-1 primitive) and benefits all future card consumers once fixed. Everything else (useIsMobile safety, desktop non-regression, compose breakpoint, Backups parity, lint/build/test) is verified clean."
|
||
}
|
||
```
|