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).
193 lines
9.0 KiB
Markdown
193 lines
9.0 KiB
Markdown
# Slice 2 Review — Dashboard mobile layout (mobile-responsive-parity)
|
|
|
|
**Scope:** unstaged diff on `frontend/src/pages/Dashboard.tsx` (+134/-7) and
|
|
`frontend/src/pages/__tests__/Dashboard.test.tsx` (+176/-7). Slice 1
|
|
(primitives: `useIsMobile`, `mobile-touch-target` CSS) is already committed.
|
|
|
|
## Verdict: **commit**
|
|
|
|
No blockers. One non-blocking deviation from the task wording (JS-gated
|
|
`md:hidden` instead of the Tailwind class), which is functionally equivalent
|
|
and tested. All seven requested verification points pass.
|
|
|
|
---
|
|
|
|
## 1. Desktop non-regression (R7.4 / R10.1) — ✅ CONFIRMED, most important check
|
|
|
|
`Dashboard.tsx:541-547` — the desktop branch is literally the original code:
|
|
|
|
```tsx
|
|
{isMobile && mobileSections.length > 0 ? (
|
|
<MobileWidgetSections sections={mobileSections} />
|
|
) : (
|
|
visibleWidgets.map((widget) => (
|
|
<WidgetInstanceCard key={widget.id} widget={widget} />
|
|
))
|
|
)}
|
|
```
|
|
|
|
When `isMobile === false`, the renderer emits the exact same
|
|
`visibleWidgets.map(...)` → `WidgetInstanceCard` sequence, with the same
|
|
`visibleWidgets` memo (`filter(enabled).sort(sort_order asc)`, unchanged at
|
|
`Dashboard.tsx:456-461`). No wrapper element is introduced on desktop, sort
|
|
order is identical, and no new query runs on the desktop path beyond the
|
|
cache-shared `useServiceInstances()` (see §6). The only desktop-visible
|
|
addition is the `useIsMobile()` hook and the `mobileSections` memo, both of
|
|
which are pure and render nothing extra when `isMobile` is false.
|
|
|
|
Test evidence: `Dashboard.test.tsx` "does NOT render the anchor bar at desktop
|
|
width" asserts the widget still renders (`getByText("Grafana Link")`) AND no
|
|
section heading/pill appears (`queryByText("Observability")` is null).
|
|
|
|
## 2. Section grouping logic (`widgetSection` / `groupWidgetsBySection`) — ✅ CORRECT
|
|
|
|
`Dashboard.tsx:62-78`:
|
|
|
|
```ts
|
|
function widgetSection(widget, services): SectionId {
|
|
if (!widget.service_id) {
|
|
return widget.widget_kind === "backups" ? "backups" : "custom";
|
|
}
|
|
const service = services.find((s) => s.id === widget.service_id);
|
|
const serviceType = service?.service_type ?? "";
|
|
if (OBSERVABILITY_TYPES.has(serviceType)) return "observability"; // alertmanager/prometheus/grafana
|
|
if (serviceType === "jellyfin") return "media";
|
|
return "custom";
|
|
}
|
|
```
|
|
|
|
Mapping verified against the closed service registry
|
|
(`backend/.../integrations/registry.py`: alertmanager, grafana, jellyfin,
|
|
jellyseerr, nextcloud, prometheus, ssh_tasks) and builtin widget kinds
|
|
(`widgets/builtin.py`: static, backups):
|
|
|
|
| Widget | Result |
|
|
|-----------------------------------------------------|-----------------|
|
|
| builtin `backups` (no service_id) | backups ✓ |
|
|
| builtin `static` (no service_id) | custom ✓ |
|
|
| grafana `link`, prometheus `metric`, alertmanager `alerts` | observability ✓ |
|
|
| jellyfin `activity` | media ✓ |
|
|
| ssh_tasks `task_output` | custom ✓ |
|
|
| nextcloud / jellyseerr / unknown service_type | custom ✓ |
|
|
| orphan widget (service_id points at deleted service → `service` undefined, serviceType `""`) | custom (safe fallback) ✓ |
|
|
|
|
No widget kind falls through wrong. The closed-over `SECTION_ORDER`
|
|
(`observability, media, backups, custom`) guarantees deterministic section
|
|
render order independent of widget arrival order.
|
|
|
|
## 3. Anchor bar — ✅ CORRECT (one wording deviation, non-blocking)
|
|
|
|
- Horizontal scroll: `-mx-1 flex gap-2 overflow-x-auto px-1 pb-1` ✓
|
|
- `scrollIntoView({ behavior: "smooth", block: "start" })` on click ✓
|
|
(`Dashboard.tsx:107-113`)
|
|
- `scroll-mt-16` on each `<section>` (`Dashboard.tsx:124`) so the sticky
|
|
TopBar (64px ≈ `mt-16`) does not cover the heading ✓
|
|
- `md:hidden`: **implemented via JS gating** (`isMobile &&
|
|
mobileSections.length > 0`), NOT via a Tailwind `md:hidden` class. Task 2.2
|
|
literally says "Anchor bar `md:hidden`". Functionally equivalent — at md+
|
|
`useIsMobile()` returns false so `MobileWidgetSections` is never mounted,
|
|
which is cleaner than rendering hidden DOM. Tested at both breakpoints.
|
|
**Non-blocking note only.**
|
|
|
|
## 4. Empty sections — ✅ CONFIRMED
|
|
|
|
`groupWidgetsBySection` filters with `s.widgets.length > 0`
|
|
(`Dashboard.tsx:94`). The same filtered `sections` array feeds BOTH the anchor
|
|
bar pill list and the section list inside `MobileWidgetSections`, so an empty
|
|
section appears in neither. Test evidence: with observability/media/backups
|
|
widgets present and no custom widget, `queryByText("Custom")` is null
|
|
(`Dashboard.test.tsx` "renders widgets in a single column…").
|
|
|
|
## 5. Test quality — ✅ GOOD
|
|
|
|
Three new tests, all asserting behavior (not snapshots):
|
|
|
|
1. "renders widgets in a single column with an anchor bar below md" — checks
|
|
each populated section label is present, the empty `Custom` section is
|
|
absent, and every widget title renders.
|
|
2. "does NOT render the anchor bar at desktop width" — asserts widget renders
|
|
AND no section heading appears (anchor-bar-absent + widgets-present). ✓
|
|
3. "anchor bar pills jump to their section via scrollIntoView" — spies on
|
|
`Element.prototype.scrollIntoView`, clicks the Media pill via
|
|
`getByRole("button", { name: "Media" })`, asserts the spy fired. ✓
|
|
|
|
`matchMedia` mock (`Dashboard.test.tsx:79-92`) is correct and complete: it
|
|
returns `{ matches, media, onchange, addEventListener, removeEventListener,
|
|
addListener, removeListener, dispatchEvent }`. `matches` is keyed on the exact
|
|
query string `"(max-width: 768px)"` that `useIsMobile` uses, so the boolean
|
|
flips correctly. `useIsMobile` only needs `addEventListener`/`removeEventListener`
|
|
- the initial `matches` read, all of which are stubbed. The mock is reset in
|
|
`beforeEach` via `setMatchMedia(false)`.
|
|
|
|
Minor note: the widget-stub was upgraded to render `widget.title`
|
|
(`Dashboard.test.tsx:6-9`) so tests can distinguish widgets — good improvement,
|
|
doesn't affect the existing shortcut-CRUD tests.
|
|
|
|
## 6. `useServiceInstances()` addition — ✅ CACHE-SHARED, no duplicate request
|
|
|
|
`useServiceInstances(serviceType?)` builds queryKey
|
|
`["services", "instances", serviceType ?? "all"]` (`useServices.ts:21`). The
|
|
Dashboard calls it with no arg → key `["services", "instances", "all"]`.
|
|
|
|
Critically, **`WidgetInstanceCard` already calls `useServiceInstances()` with
|
|
no arg** (`WidgetInstance.tsx:12`) for every rendered widget, as does
|
|
`WidgetConfigDialog` (`WidgetConfigDialog.tsx:167`). So the Dashboard's new
|
|
call hits the exact same TanStack cache entry that is already being subscribed
|
|
to by the widget cards it renders. TanStack Query deduplicates by key → **zero
|
|
additional network requests** introduced by this change on either desktop or
|
|
mobile. The 60s `refetchInterval` is shared.
|
|
|
|
## 7. Sort order within sections (R7.3) — ✅ PRESERVED
|
|
|
|
`visibleWidgets` is sorted by `sort_order` ascending (`Dashboard.tsx:456-461`,
|
|
unchanged). `groupWidgetsBySection` iterates `visibleWidgets` in order and
|
|
`.push()`es into per-section arrays, preserving insertion order. Therefore
|
|
within each section the user's configured sort order is intact, and sections
|
|
themselves render in fixed `SECTION_ORDER`. R7.3 satisfied.
|
|
|
|
---
|
|
|
|
## Build / lint / test evidence
|
|
|
|
| Command | Result |
|
|
|---------|--------|
|
|
| `npm run lint` | ✅ 0 errors (2 pre-existing warnings in `UsersPage.impl.tsx`, unrelated) |
|
|
| `npm run build` (`tsc -b && vite build`) | ✅ built, typecheck clean |
|
|
| `npm run test` (vitest run) | ✅ 25 files / 89 tests passed |
|
|
| `vitest run Dashboard.test.tsx` | ✅ 6 tests passed (3 original + 3 new) |
|
|
|
|
## Other observations (non-blocking)
|
|
|
|
- The mobile single-column container is `grid grid-cols-1 gap-4`
|
|
(`Dashboard.tsx:120`). The pre-change desktop widgets were already a flat
|
|
vertical stack (no grid wrapper), so mobile parity is effectively the same
|
|
column plus grouping — consistent with R7.1.
|
|
- `mobileSections` is recomputed via `useMemo` keyed on `[visibleWidgets,
|
|
services]`; correct deps, no stale-closure risk.
|
|
- `OBSERVABILITY_TYPES`, `SECTION_ORDER`, `SECTION_META` are module-level
|
|
constants — no per-render allocation. Good.
|
|
- Diff is +303/-7 across 2 files, well under the 400-line review budget and
|
|
exactly the two files Slice 2 scoped.
|
|
|
|
## Blockers
|
|
|
|
None.
|
|
|
|
## Suggestions (non-blocking, do not gate commit)
|
|
|
|
1. If strict adherence to the task wording "Anchor bar `md:hidden`" is
|
|
preferred, add the `md:hidden` Tailwind class to the anchor bar div and
|
|
render `MobileWidgetSections` unconditionally inside the mobile branch.
|
|
The current JS-gated approach is equally correct and avoids mounting hidden
|
|
nodes; leaving as-is is fine.
|
|
2. Consider an explicit test that an orphan widget (service_id set but not in
|
|
`services`) lands in `custom` — the code handles it but no test pins the
|
|
fallback. Low value; optional.
|
|
|
|
## Acceptance
|
|
|
|
All seven requested verification points pass. Desktop non-regression is
|
|
verified by code identity on the `isMobile === false` branch and by the
|
|
desktop test. No scope widening (only the two Slice-2 files changed, no
|
|
backend, no other pages). No staged files. Ready to commit.
|