From b126f9f610ad7dc44ef4b2a36cb68357d59db836 Mon Sep 17 00:00:00 2001 From: countercheck Date: Sun, 31 May 2026 00:24:56 +0000 Subject: [PATCH] Collapse admin navbar on mobile + scroll wide tables (GH #48) Below `lg` (1024px) the admin top-bar can't fit Surveys/ETL/GDPR/Users/ DB Credentials/My DB access alongside the user identity + Log out, so they collapse behind a hamburger that opens a stacked drawer. Same change pass also wraps every admin data table (SurveyListView, UsersView, EtlView, GdprView, PiiReviewView, MyDbAccessView, DbCredentialsView) in an `overflow-x-auto` container with a sensible `min-w-*` on the table so they scroll horizontally inside their Card on narrow screens instead of blowing out the viewport. Header/main padding also drops from `px-6` to `px-4 sm:px-6` for the same reason, and RespondentLayout gets the same treatment so the survey-runner chrome reads identically on mobile. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/RespondentLayout.tsx | 4 +- frontend/src/admin/AdminLayout.test.tsx | 44 ++++++ frontend/src/admin/AdminLayout.tsx | 185 +++++++++++++++------- frontend/src/admin/DbCredentialsView.tsx | 138 +++++++++-------- frontend/src/admin/EtlView.tsx | 120 ++++++++------- frontend/src/admin/GdprView.tsx | 36 +++-- frontend/src/admin/MyDbAccessView.tsx | 96 ++++++------ frontend/src/admin/PiiReviewView.tsx | 130 ++++++++-------- frontend/src/admin/SurveyListView.tsx | 68 ++++---- frontend/src/admin/UsersView.tsx | 188 ++++++++++++----------- 10 files changed, 572 insertions(+), 437 deletions(-) diff --git a/frontend/src/RespondentLayout.tsx b/frontend/src/RespondentLayout.tsx index c8e4114..12135b6 100644 --- a/frontend/src/RespondentLayout.tsx +++ b/frontend/src/RespondentLayout.tsx @@ -8,10 +8,10 @@ import type { ReactNode } from 'react'; export function RespondentLayout({ children }: { children: ReactNode }) { return (
-
+
Stele
-
{children}
+
{children}
); } diff --git a/frontend/src/admin/AdminLayout.test.tsx b/frontend/src/admin/AdminLayout.test.tsx index e19cf39..fe55619 100644 --- a/frontend/src/admin/AdminLayout.test.tsx +++ b/frontend/src/admin/AdminLayout.test.tsx @@ -114,4 +114,48 @@ describe('AdminLayout', () => { expect(screen.queryByRole('link', { name: 'DB Credentials' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'PII Review' })).not.toBeInTheDocument(); }); + + // The desktop nav doesn't fit on small viewports alongside the user/logout + // block, so we collapse it behind a hamburger button (issue #48). jsdom + // doesn't apply Tailwind's responsive CSS, so we can't assert visibility, but + // we can assert the collapsed-menu DOM contract: button toggles the drawer + // and the drawer mirrors the nav + logout. + it('exposes a hamburger that opens a mirror nav drawer', async () => { + asRole('admin'); + renderLayout(); + await screen.findByTestId('current-user'); + + const toggle = screen.getByRole('button', { name: 'Open menu' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByTestId('current-user-mobile')).not.toBeInTheDocument(); + + await userEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(toggle).toHaveAccessibleName('Close menu'); + // Drawer renders its own user block and a second copy of every nav link. + expect(screen.getByTestId('current-user-mobile')).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'Surveys' })).toHaveLength(2); + expect(screen.getAllByRole('button', { name: 'Log out' })).toHaveLength(2); + }); + + it('closes the drawer when a nav link is clicked', async () => { + asRole('admin'); + renderLayout(); + await screen.findByTestId('current-user'); + + await userEvent.click(screen.getByRole('button', { name: 'Open menu' })); + expect(screen.getByTestId('current-user-mobile')).toBeInTheDocument(); + + // Click the drawer copy of the Surveys link. Both copies route to the same + // place; either close path is fine, but tapping a link is the common one. + const surveysLinks = screen.getAllByRole('link', { name: 'Surveys' }); + await userEvent.click(surveysLinks[surveysLinks.length - 1]!); + + expect(screen.queryByTestId('current-user-mobile')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open menu' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); }); diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index 29b8181..8703790 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -1,86 +1,161 @@ +import { useState } from 'react'; import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/AuthContext'; import { Button } from '../ui'; /** Shell for the authenticated admin area: a branded header with role-aware - * navigation, the current user, and a logout control, plus the routed view. */ + * navigation, the current user, and a logout control, plus the routed view. + * + * Below `lg` (1024px) the full nav can't fit alongside the user/logout block + * for an admin, so we collapse it behind a hamburger that opens a stacked + * drawer underneath the header. Above `lg` the drawer never renders. */ export function AdminLayout() { const { user, logout } = useAuth(); const navigate = useNavigate(); + const [menuOpen, setMenuOpen] = useState(false); const handleLogout = (): void => { void logout().then(() => navigate('/admin/login', { replace: true })); }; + const closeMenu = (): void => setMenuOpen(false); + const navLinkClass = ({ isActive }: { isActive: boolean }): string => [ 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors', isActive ? 'bg-brand-light text-brand-dark' : 'text-muted hover:bg-canvas hover:text-ink', ].join(' '); + // Same link set is rendered in the desktop top-bar nav and the mobile drawer; + // the container controls direction/visibility, the links themselves don't care. + const navLinks = user ? ( + <> + {/* Authors see the survey workspace; admins the GDPR console; reviewers + the PII screening queue. Role drives which links appear (design §3.10). */} + {user.roles.includes('researcher') || user.roles.includes('admin') ? ( + + Surveys + + ) : null} + {user.roles.includes('admin') ? ( + + ETL + + ) : null} + {user.roles.includes('admin') ? ( + + GDPR + + ) : null} + {user.roles.includes('admin') ? ( + + Users + + ) : null} + {user.roles.includes('admin') ? ( + + DB Credentials + + ) : null} + {user.roles.includes('reviewer') ? ( + + PII Review + + ) : null} + {/* Anyone may hold a DB credential to reveal/regenerate (§3.10). */} + + My DB access + + + ) : null; + return (
-
-
- - Stele - - +
+
+
+ + Stele + + +
+ {user ? ( + <> +
+ + {user.email} ({user.roles.join(', ')}) + + +
+ + + ) : null}
- {user ? ( -
- - {user.email} ({user.roles.join(', ')}) - - + {user && menuOpen ? ( +
+ +
+ + {user.email} ({user.roles.join(', ')}) + + +
) : null}
-
+
); } + +function MenuIcon({ open }: { open: boolean }) { + return ( + + ); +} diff --git a/frontend/src/admin/DbCredentialsView.tsx b/frontend/src/admin/DbCredentialsView.tsx index 32e3ac0..3144b1c 100644 --- a/frontend/src/admin/DbCredentialsView.tsx +++ b/frontend/src/admin/DbCredentialsView.tsx @@ -222,33 +222,35 @@ export function DbCredentialsView() { <>

Recent requests

- - - - - - - - - - - {requests.map((r) => ( - - - - - +
+
ActionSubject / roleStatusDetail
- {r.action} - {r.access ? ` (${r.access})` : ''} - - {r.subject_label ?? r.login_role ?? '—'} - - {r.status} - {r.error_detail ?? '—'}
+ + + + + + - ))} - -
ActionSubject / roleStatusDetail
+ + + {requests.map((r) => ( + + + {r.action} + {r.access ? ` (${r.access})` : ''} + + + {r.subject_label ?? r.login_role ?? '—'} + + + {r.status} + + {r.error_detail ?? '—'} + + ))} + + +
) : null} @@ -262,48 +264,50 @@ export function DbCredentialsView() { No DB credentials provisioned. ) : ( - - - - - - - - - - - - - - {grants.map((g) => ( - - - - - - - - +
+
SubjectAccessLogin roleStatusCreatedRevokedActions
{g.subject_label}{g.access}{g.login_role} - {g.status} - {formatDateTime(g.created_at)} - {g.revoked_at ? formatDateTime(g.revoked_at) : '—'} - - {g.status === 'active' ? ( - - ) : null} -
+ + + + + + + + + - ))} - -
SubjectAccessLogin roleStatusCreatedRevokedActions
+ + + {grants.map((g) => ( + + {g.subject_label} + {g.access} + {g.login_role} + + {g.status} + + {formatDateTime(g.created_at)} + + {g.revoked_at ? formatDateTime(g.revoked_at) : '—'} + + + {g.status === 'active' ? ( + + ) : null} + + + ))} + + +
)} diff --git a/frontend/src/admin/EtlView.tsx b/frontend/src/admin/EtlView.tsx index df080f5..3b8897d 100644 --- a/frontend/src/admin/EtlView.tsx +++ b/frontend/src/admin/EtlView.tsx @@ -178,65 +178,67 @@ export function EtlView() { No ETL runs yet. ) : ( - - - - - - - - - - - - {runs.map((run) => { - const sources = total(run.source_row_counts); - const marts = total(run.mart_row_counts); - return ( - - - - - - - - ); - })} - -
StatusStartedElapsedSources → martsVersion
- - {run.interrupted ? 'interrupted' : run.status} - - {run.interrupted ? ( -
- Likely stopped by a restart. - -
- ) : null} - {run.failures.length > 0 ? ( -
    - {run.failures.map((f, i) => ( -
  • - {f.unique_id ?? '?'} - {f.message ? `: ${f.message}` : null} -
  • - ))} -
- ) : null} -
{formatDateTime(run.started_at)}{elapsed(run)} - {sources ?? '—'} → {marts ?? '—'} - - {run.dbt_version ?? '—'} - {run.git_sha ? ` @ ${run.git_sha.slice(0, 7)}` : ''} -
+
+ + + + + + + + + + + + {runs.map((run) => { + const sources = total(run.source_row_counts); + const marts = total(run.mart_row_counts); + return ( + + + + + + + + ); + })} + +
StatusStartedElapsedSources → martsVersion
+ + {run.interrupted ? 'interrupted' : run.status} + + {run.interrupted ? ( +
+ Likely stopped by a restart. + +
+ ) : null} + {run.failures.length > 0 ? ( +
    + {run.failures.map((f, i) => ( +
  • + {f.unique_id ?? '?'} + {f.message ? `: ${f.message}` : null} +
  • + ))} +
+ ) : null} +
{formatDateTime(run.started_at)}{elapsed(run)} + {sources ?? '—'} → {marts ?? '—'} + + {run.dbt_version ?? '—'} + {run.git_sha ? ` @ ${run.git_sha.slice(0, 7)}` : ''} +
+
)} diff --git a/frontend/src/admin/GdprView.tsx b/frontend/src/admin/GdprView.tsx index 8c79959..09dc9e5 100644 --- a/frontend/src/admin/GdprView.tsx +++ b/frontend/src/admin/GdprView.tsx @@ -138,24 +138,26 @@ export function GdprView() { No withdrawals recorded. ) : ( - - - - - - - - - - {audit.map((w) => ( - - - - +
+
RespondentRequestedReason
{w.respondent_id}{formatDateTime(w.requested_at)}{w.reason ?? '—'}
+ + + + + - ))} - -
RespondentRequestedReason
+ + + {audit.map((w) => ( + + {w.respondent_id} + {formatDateTime(w.requested_at)} + {w.reason ?? '—'} + + ))} + + +
)} diff --git a/frontend/src/admin/MyDbAccessView.tsx b/frontend/src/admin/MyDbAccessView.tsx index 6d8ddb3..00b5e30 100644 --- a/frontend/src/admin/MyDbAccessView.tsx +++ b/frontend/src/admin/MyDbAccessView.tsx @@ -140,54 +140,56 @@ export function MyDbAccessView() { ) : ( - - - - - - - - - - - - {creds.map((c) => ( - - - - - - +
+
Login roleAccessStatusGrantedActions
{c.login_role}{c.access} - {c.status} - {formatDateTime(c.created_at)} -
- {c.has_pending_secret ? ( - - ) : null} - {c.status === 'active' ? ( - - ) : null} -
-
+ + + + + + + - ))} - -
Login roleAccessStatusGrantedActions
+ + + {creds.map((c) => ( + + {c.login_role} + {c.access} + + {c.status} + + {formatDateTime(c.created_at)} + +
+ {c.has_pending_secret ? ( + + ) : null} + {c.status === 'active' ? ( + + ) : null} +
+ + + ))} + + +
)} diff --git a/frontend/src/admin/PiiReviewView.tsx b/frontend/src/admin/PiiReviewView.tsx index c853d3a..d41dc05 100644 --- a/frontend/src/admin/PiiReviewView.tsx +++ b/frontend/src/admin/PiiReviewView.tsx @@ -132,72 +132,74 @@ export function PiiReviewView() { No {status} answers. ) : ( - - - - - - - - - - - - {items.map((item) => ( - - - - - - + + ))} + +
RespondentQuestionAnswerSubmitted - {status === 'pending' ? 'Decision' : 'Status'} -
{item.respondent_id}{item.question_name}{item.value_text ?? '—'}{formatDate(item.created_at)} - {status === 'scrubbed' ? ( - scrubbed - ) : ( -
- {status === 'pending' ? ( - <> - - - - ) : ( - {status} - )} - {/* Scrub stays available on pending/promoted/rejected: the +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + - - ))} - -
RespondentQuestionAnswerSubmitted + {status === 'pending' ? 'Decision' : 'Status'} +
{item.respondent_id}{item.question_name}{item.value_text ?? '—'}{formatDate(item.created_at)} + {status === 'scrubbed' ? ( + scrubbed + ) : ( +
+ {status === 'pending' ? ( + <> + + + + ) : ( + {status} + )} + {/* Scrub stays available on pending/promoted/rejected: the PII persists in storage until it is scrubbed, whatever the review decision. */} - -
- )} -
+ +
+ )} +
+
)} diff --git a/frontend/src/admin/SurveyListView.tsx b/frontend/src/admin/SurveyListView.tsx index a7bfc2b..707dc66 100644 --- a/frontend/src/admin/SurveyListView.tsx +++ b/frontend/src/admin/SurveyListView.tsx @@ -236,40 +236,42 @@ function SurveyCard({ versions }: { versions: SurveySummary[] }) { {error} ) : null} - - - - - - - - - - - {versions.map((v) => ( - - - - - - +
+
VersionStatusResponsesPublished -
v{v.version} - {v.status} - {v.response_count} - {v.published_at ? formatDate(v.published_at) : '—'} - - - Open - -
+ + + + + + + - ))} - -
VersionStatusResponsesPublished
+ + + {versions.map((v) => ( + + v{v.version} + + {v.status} + + {v.response_count} + + {v.published_at ? formatDate(v.published_at) : '—'} + + + + Open + + + + ))} + + + ); } diff --git a/frontend/src/admin/UsersView.tsx b/frontend/src/admin/UsersView.tsx index b5e9bf7..7e67086 100644 --- a/frontend/src/admin/UsersView.tsx +++ b/frontend/src/admin/UsersView.tsx @@ -249,102 +249,104 @@ export function UsersView() { No operators yet. ) : ( - - - - - - - - - - - - {users.map((u) => ( - - - - - - + + + + + ))} + +
EmailRolesStatusCreatedActions
{u.email} - {editingId === u.id ? ( - toggle(editRoles, setEditRoles, r)} - idPrefix={`edit-role-${u.id}`} - /> - ) : ( - - {u.roles.map((r) => ( - - {r} - - ))} - - )} - - - {u.disabled ? 'disabled' : 'active'} - - {formatDateTime(u.created_at)} -
+
+ + + + + + + + + + + + {users.map((u) => ( + + + - - ))} - -
EmailRolesStatusCreatedActions
{u.email} {editingId === u.id ? ( - <> - - - + toggle(editRoles, setEditRoles, r)} + idPrefix={`edit-role-${u.id}`} + /> ) : ( - <> - - - - + + {u.roles.map((r) => ( + + {r} + + ))} + )} - -
+
+ + {u.disabled ? 'disabled' : 'active'} + + {formatDateTime(u.created_at)} +
+ {editingId === u.id ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+
)}