feat(frontend): mobile friendly user dashboard - #1758
Draft
shivoomiess wants to merge 56 commits into
Draft
Conversation
The same breakpoints were written as raw pixel media queries in four components, so "mobile" meant a different width depending on which component you asked, and the drawer width was declared twice. This gives the upcoming mobile work one place to retune. hooks/common/useResponsive.ts collects the two viewport thresholds (1224px for the drawer overlay, 500px for dense UI), the orientation query, the two AppBar heights and the drawer width, and exposes useIsTabletOrMobile, useIsMobile and useIsPortrait. AppToolbar, SimpleTabs and Questionary now read from it. AppToolbar also drops its own `drawerWidth = 250`, which duplicated the value PageLayout used to size the drawer it sits next to. The values are unchanged, so this is not a behavioural change.
…ports
PageLayout offset the main content area by a hardcoded 64px. The AppBar
is 64px tall only from `sm` up; below that it is 56px, so on phones the
content started 8px below the bar. The offset and the matching viewport
height calculation are now breakpoint-aware.
PageLayout also fixed the main area at `calc(100% - 250px)`, which
assumed the drawer was always expanded, so the collapsed drawer left
dead space on the right. `flexGrow: 1` already claims the remaining row
space; `minWidth: 0` lets the flex item shrink rather than overflow when
its content is wider than the viewport.
FapGradeGuide passed `sm={25}`, which is out of range. The legacy Grid
ignored it and emitted a class that does not exist, but Grid v2 computes
the width arithmetically, so after the v9 upgrade it became a real 208%
overflow. The editor is meant to span the full row at every breakpoint.
…l widths
The hook preserved two pixel thresholds inherited from the old inline
media queries, so the app had two competing vocabularies for viewport
size: 500/1224 in the hook, and MUI's sm/md/lg in the eight files that
use `theme.breakpoints` directly. Standardising on the theme leaves one
set of numbers to reason about, and they are now configurable in the
theme rather than in this module.
`useIsBelow(breakpoint)` is the general form; useIsTabletOrMobile and
useIsMobile are named cases of it, so a component needing a mobile
variant no longer has to reach for useMediaQuery and useTheme itself.
This does move both thresholds, since neither pixel value sat on a
breakpoint:
- drawer overlay 1224px -> `down('lg')`, so 1200px. 24px narrower; the
window widths affected are between the two values.
- dense UI 500px -> `down('sm')`, so 600px. 100px wider, which is the
larger change. Between 500 and 600 the tab bar now collapses and
Questionary drops its 500px minWidth where it previously did not.
Dropping that minWidth below 600px is the safer direction, because it
is what stops the stepper forcing horizontal overflow.
`down('sm')` also matches the pickers' `desktopModeMediaQuery`, so
"mobile" now means one width everywhere in the app.
The dashboard's two sections, upcoming experiments and the user's own
proposals, are both wide material-tables. On a phone they scroll
horizontally and the dense-column ellipsis truncates most of what is
worth reading.
Rows become cards below `sm` through a Row component slot rather than a
separate mobile table. The slot receives the same columns, actions and
getFieldValue the desktop row does, so the column render functions and
every action, with its hidden/tooltip rules and the Navigate that opens
a proposal, are reused unchanged. No routing is involved and neither
table component is split.
The card stays inside a TableRow/TableCell. That keeps the DOM valid and
leaves the toolbar, title, loading overlay and pagination working, and
it keeps the `closest('tr')` the e2e helpers rely on.
A BottomNavigation switches between the two sections below `sm`, since
they do not both fit on one phone screen. It is local state in
OverviewPage, so no routes were added. Both panels stay mounted and are
toggled with `hidden`, so switching does not refetch. It appears only
for UserRole.USER with the scheduler feature on, which is the only case
with two sections to switch between.
UserUpcomingExperimentsTable gains `hideIfEmpty`. It hid itself entirely
when empty, which is right for the stacked desktop layout but leaves a
blank panel behind a tab, so the tabbed layout opts out and shows the
table's own empty state.
Cypress runs at 1920x1080, so every existing spec stays on the desktop
path.
The doc blocks on useResponsive, MaterialTableCardRow and hideIfEmpty repeated what the names already say. The two that remain each stop a specific rewrite: material-table silently ignoring a falsy `components`, and the card row needing a stable reference.
Every table wanting card rows on a phone had to pass two props that must agree:
`components={rowComponents(isMobile)}` and `options.header = !isMobile`. Cards
with a stray header row, or desktop rows with no header, were one forgotten line
away, and nothing tied the two together.
ResponsiveMaterialTable owns both. The card and default Row constants move into
it, so the stable references material-table needs are no longer something a
caller can get wrong - the store merges `components` and never reverts, so a
falsy value leaves the previous Row in place.
useCardRows names the question the tables were asking through useIsMobile. It
returns the same answer today; when a tablet wants cards without the rest of the
compact layout, that becomes one line rather than an audit of every table.
DenseMaterialTable now builds on the wrapper and skips denseTableColumns when
rows are cards. Those renderers clip a value to one line and move the rest into
a hover title, which is a fix for a narrow desktop cell - a card has room to
wrap, and a phone has no hover.
UserUpcomingExperimentsTable and ProposalTable each drop an import, a hook call
and the two props. Neither knows about viewports now.
… branched JSX
OverviewPage wrote each panel twice: once stacked for desktop, once inside a
toggled Box for the phone layout, with different props on the same tables. A
third section meant editing both branches, and the five other roles repeated the
same Paper wrapper a further five times.
Sections are now data. Each role returns a list of {id, label, icon, render},
and DashboardSections picks StackedSections or TabbedSections from the viewport
and the number of sections. Tabs appear when there is more than one section,
which is what the SCHEDULER check was standing in for - it stays correct if a
third section is ever added.
hideIfEmpty reaches UserUpcomingExperimentsTable as canHideWhenEmpty from the
layout rather than from OverviewPage. Hiding an empty table is right in a stack
and wrong behind a tab, so the component that knows which one it is decides.
Desktop behaviour is unchanged.
Both panels still stay mounted and toggle with `hidden`, so switching sections
does not refetch. label and icon are optional because they are only read when
the sections are tabbed, which needs more than one of them.
On a phone the admin-authored homepage content filled the first screen, so the experiment cards and their outstanding tasks were always below the fold. The content now becomes a third bottom-nav section, Call info, and the dashboard opens on Experiments. The content is not a member of `sections`. It is passed separately and placed by DashboardSections: a trailing tab when tabbed, a leading panel when stacked. That keeps the desktop order as it was, keeps Experiments as the default tab, and means the info content cannot decide whether the section nav appears at all. With the scheduler off a user still has one real section, so the layout stays stacked as before. The admin HTML is restyled from the outside, scoped to the compact breakpoint so the desktop welcome panel is untouched. Its H1 was previously the loudest element on the dashboard. The section nav gains count badges. UserUpcomingExperimentsTable reports its outstanding task count upward rather than the count being refetched. Privacy Statement and FAQ are suppressed on the mobile dashboard: they sat directly above the sticky section nav and were easy to hit by accident. Where they should live on mobile is still open, and marked with a TODO.
The nav was sticky, so on a short page it came to rest under the content
instead of at the bottom of the screen. It is fixed now, with matching bottom
padding on the panels so the last card is never behind it.
The "{n} tasks due" chip and the nav count badges are removed. The checklist
already shows what is outstanding on the card itself, and the count restated it
twice over. This also removes the count callback that reached from
UserUpcomingExperimentsTable back up to OverviewPage.
DashboardInfoSection restyles admin-authored HTML, which is a job for a media
query rather than a hook, but it reached for theme.breakpoints directly. That
now goes through belowCompactUi() in useResponsive, alongside the existing
toolbarHeight() and drawerWidth() helpers, so the breakpoint has one home
whether it is read from JS or from sx.
Each proposal becomes a card: status chip and reference, then the title in full, then call and age. The row of small icon buttons is replaced by one full-width primary action - Continue for an editable draft, View when read-only - and an overflow sheet holding clone, download, data access users and delete. ProposalTable's five row actions were closures written inline in the actions array, reading rowData and recomputing predicates in place. They are now named handlers and predicates above the return, and both the desktop icons and the card are built from them, so the two presentations cannot drift. ProposalCard takes a structural prop type rather than PartialProposalsDataType, which would close an import cycle back through ProposalTable. The invite notification was a space-between row, which squeezed its message to a few words at 390px. It stacks below the compact breakpoint, and the button goes full width. Join proposal does the same rather than sitting in a right-aligned row of one.
The section headings were subtitle1 with a weight override, barely distinct from the card titles beneath them. They are h5 now, and card titles h6, so a section reads as a page title, a card title as a heading, and the details as body text. The proposal card was a header row above a details block, which left the status and reference squeezed beside the title alone. The body is two columns instead: title and details fill the left, status and reference sit against the right edge for the height of the card. The left column carries minWidth 0, without which a long title or call code would push the stack off the card rather than wrap. Both card actions are outlined and use the secondary set. Contained primary made a solid block of the instance accent colour the loudest thing in the list, and the status chip already marks a draft as needing attention. The overflow control takes the theme corner radius so it stops reading as a circle next to a rectangle, and both controls size off one spacing constant rather than a 44px literal. Proposal details are stacked icon lines built from the experiment card's CardDetailLine, so the two card types read the same way, and titles get half a line of air above and below.
- Rename the running experiment label from "Running now" to "Active". - Rename "My proposals" to "Proposals" in the nav and the heading. - Show experiment details as aligned key and value rows, with instrument and proposal on separate lines. - Use the desktop instrument icon on the experiment card. - Take task row wording from the desktop tooltips, and the helper line from the status description behind them. - Show the desktop action icon and its status badge on each task row. - Give the shipment and feedback actions a status description, which they did not have. This also adds one to their desktop tooltip when inactive. - Add a shadow to proposal cards. - Add an empty state for experiments and for proposals. - Drop the paper the table draws around itself on mobile, so a section is one panel and not a box inside a box. The empty state is a sibling of the table, not a localization entry: material-table deep-merges localization and deepmerge does not terminate on React elements under React 19. The proposals table stays mounted and hidden while empty, because joining a proposal by code refreshes through its ref.
… table - Show the proposals title and the join button in one row above the table, instead of the title in the table toolbar and the button below it. - Put the button top right on desktop, and next to the heading on mobile. - Turn off the table toolbar, which held only the title. - Make the section headings larger and heavier, so a heading is not read as a button. - Pad the header to match the inset of the rows below it. - Read the minimum touch target from one place in useResponsive, rather than repeating 44 in four files. The search prop of ProposalTable no longer does anything, because the search field lives in the toolbar. Its only caller passes false.
- Give the card a light outline and rounded corners, and drop the divider above its buttons. - Style the open button like the overflow button next to it, rather than in the secondary colour. - Show the proposal reference as a detail row instead of a corner label. - Widen the gap between a field name and its value, without widening the gap between the icon and the field name. - Add a neutral grey to the theme palette, so the submitted badge can ask for a colour instead of overriding its text and border.
…ards - Navigate from the click handler instead of rendering Navigate from state, in the proposals, reader and FAP tables. Navigate redirects from an effect, which StrictMode runs twice, so one click left two history entries and the first press of Back appeared to do nothing. - Build the task rows from ListItemIcon and ListItemText, and use the disabled prop for a locked step, rather than setting role, tabindex, the cursor, hover and greying by hand. - Wrap card bodies in CardContent and card buttons in CardActions. - Use Stack for the stacked and row layouts. - Size and colour icons with their own props, rather than reaching for the svg class from the parent. - Take the bottom navigation height from the component's own default, and read the matching clearance from useResponsive. - Use action.hover rather than a grey from the palette by index. Note MUI 9 has removed the system props from Stack as well as Typography, so alignment goes in sx.
- Add a quiet button variant: outlined, in the neutral action colour rather
than a palette one, for sitting beside a plain icon button.
- Use it for the open button on the proposal card, which was carrying the
same treatment as a local override.
The status chip keeps its local styling. Chip keys its colour styles on the
pair `{ variant: 'outlined', color }`, so a custom variant matches neither and
every status loses both its border and its colour. StatusChip is itself the
one place that styling is written.
Should now fill the screen below 900px: - **Dashboard → "Join proposal"** — the best single test. It goes through `ButtonWithDialog` → `StyledDialog`, so it exercises the shared path all 34 call sites use. - **Experiment card → "Define who is coming"** — one of the three raw dialogs I edited by hand. - **Experiment card → "Define your visit"** — `StyledDialog` again, and the one that'll matter most for piece D. - **Proposal card → ⋯ → Clone** — the third raw dialog. **Should be unchanged:** - **Proposal card → ⋯ → Delete** — the confirmation must stay a small centred box. If this goes full-screen, the `withConfirm` opt-out isn't working and that's the regression to catch. - **Anything at 900px and above** — drag the window across the boundary; at 901px every dialog should look exactly as it did before.
Interval and NumberInput pinned their fields to hardcoded Grid columns, so at 360px a Min/Max field was about 90px and a value field about 60px. The file upload row split 1/6/5 with the figure and caption nested at 6/6 inside the 5, leaving them about 75px each. Embellishment injected admin-authored HTML with no containment at all and now carries the same overflow guards HelpPage got. The three radio variants forced a row layout below three options, which squashed two long labels side by side, and the instrument picker laid its per-instrument time fields out in a row. Desktop is unchanged: every rule is gated below the md breakpoint.
Section 2.5 of the design handoff. Below the md breakpoint each topic becomes a ProposalCard-style card with a two-column label/value grid instead of the accordion and answers table, which needed a horizontal scroll on a phone. The Edit button resolves its target wizard step by topicId rather than by index, because the review step is appended and the sample and generic template flows use their own factories, so the two step lists need not line up. It is omitted when the step is readonly, and when the component renders outside a QuestionaryContext, which is the case on the officer review surfaces. GO_TO_STEP_CLICKED is dispatched without a confirm: the review step has no form of its own to lose, so there is nothing to guard against.
Part of section 2.6 of the design handoff. A sample declaration or generic template opened from inside a proposal is now inset 84px on mobile, so the proposal's app bar stays visible above it, with rounded top corners, its own shadow and a lighter scrim. A strip under the title names the collection being added to and says the proposal stays open. The named exits are not included. The handoff puts 'Save and back to proposal' and 'Discard sample' on the child's review step, but the sample flow is built with StepsWizardWithoutReviewStepFactory and has no review step, so there is nowhere for them to live without changing the wizard factory. The close button is therefore kept for now: removing it before the replacements exist would leave no way out of the child on a phone.
Implements option 1c of the review design handoff. The card is gone: a topic is now a heading row and its answers, with an 18px gap and a single hairline between groups, rendered by the parent so the last group carries no trailing rule. Dropping the border, radius, padding and header rule returns roughly 90px of vertical space per screen and stops six outlined boxes competing with the wizard chrome. The hierarchy is inverted so the answer outweighs the question the user read three steps ago: the value is 15px medium on text.primary, the label stays 13px on text.secondary, and the pair tightens to 3px. An unanswered optional question now shows an em dash rather than a bare label. That needed the three answer renderers which printed the string 'Left blank' to return null instead, since a rendered element cannot be inspected for emptiness. Those renderers are invoked as plain functions rather than components, so gating the old string on useIsMobile was not available; the string is therefore gone on desktop too. Collapsed groups are remembered for the session, keyed per questionary and topic, so re-checking one topic of six does not mean collapsing the other five on every mount.
…mobile
The proposal invitations dialog was the one full-screen surface left
using a raw Dialog with fullScreen={isMobile}. At phone width it owned
the viewport while keeping the desktop header, so it had no close
control until the user scrolled to the Close button at the bottom.
StyledDialog already goes full screen below the compact breakpoint and
renders a MobileAppBar there, so use it and drop the manual flag.
Each invite was a space-between row, which leaves the proposal title a
few words wide at 375px. Stack it below the breakpoint, as the
notification banner above it already does, and give Accept the minimum
touch target.
Dropping the hand-rolled DialogTitle also removes a Typography h6
nested inside the h2 that DialogTitle already renders. The invite list
returned a bare fragment with the key on the inner Box, so React saw
keyless array children; return the Box directly instead.
- mobileDashboardInfo: the Info tab in the bottom navigation, the notice the user officer sets, and the section choice surviving a reload. - mobileInvites: the seeded co-proposer invites, accepting one from the full-screen dialog, and dismissing it without accepting. Nothing in the code offers a reject. - mobileProposals: ability to open draft proposals for editing and submitted ones for viewing, and the clone dialog can be opened and exited. - mobileExperiments: the five action rows across ten lifecycle states, checking only whether each row is offered and whether it is enabled.
DenseMaterialTable derived its columns through a `useMemo` that read
`props.columns` but did not list it, under an
`eslint-disable-next-line react-hooks/exhaustive-deps`. React Compiler is
enabled here, and `react-hooks/preserve-manual-memoization` reports that
it cannot preserve a memo whose dependencies do not match what it reads,
so it skips optimising the component. The suppression did not resolve
that; it only stopped the rule from saying so.
The memo existed to keep one columns identity, because every one of the
seventeen callers builds `const columns = [...]` in its own body and so
passes a new array each render. That is not necessary:
@material-table/core compares the prop by value, not by identity, in
material-table.js:
let propsChanged =
!deepEql(this.cleanColumns(prevProps.columns),
this.cleanColumns(this.props.columns)) || ...
so a fresh array with equal content does not make it re-initialise. The
memo was guarding against something the library already handles, while
costing the component its compiler optimisation.
Computing the value directly removes the false dependency array and the
suppression, and lets the compiler memoise the component properly.
`denseTableColumns` maps the column list and adds a render function, so
recomputing it is cheap.
shivoomiess
force-pushed
the
mobile-responsive
branch
from
August 28, 2026 15:25
7f2b0b2 to
5c27d10
Compare
…icator `finishedLoading` waits for every `[role="progressbar"]` to disappear. The mobile questionary renders a permanent determinate LinearProgress showing how far through the steps the user is, and Material UI gives that element `role="progressbar"`. It is never going to disappear, so the helper could not succeed on any mobile questionary page and every caller timed out after thirty seconds. The bar carries `data-cy="questionary-progress"`, so both of the helper's progressbar selectors now exclude it. Nothing else is affected: that element does not exist anywhere outside the mobile questionary. This surfaced now because these specs reach a mobile questionary for the first time. The standard end-to-end pass, which is the only place the mobile specs run in full, has never been executed for the shard that holds them.
Both failures are in the spec rather than in the application.
`visitProposalsSection` clicked `[data-cy="dashboard-section-proposals"]`
unconditionally. DashboardSections only renders the section bar when there
is more than one section:
return isMobile && sections.length > 1 ? <TabbedSections .../>
: <StackedSections .../>
Without the scheduler feature there is a single section, the stacked
layout renders, and that element cannot exist. mobileInvites already
guards the same call on the same feature, with a comment saying why; this
spec was never given the same guard. Under the STFC configuration, where
the scheduler is off, five of its six tests failed on it.
The read-only test expected a questionary wizard, asserting on the mobile
app bar and the progress bar. Neither seeded proposal opens one: both have
`final_status = 1` with the management decision submitted, so they open on
their decision tabs instead. Choosing the other seeded proposal would not
have helped, because it is in the same state. The proposal is still
readable through the second tab, so the test now opens it the way
invites.cy.ts does and asserts on `questionary-details-view`.
All twenty-eight mobile tests across the five specs pass locally under the
standard configuration.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves the mobile work from #1727 onto the reordered stack, on top of #1747.
What this is
The 53 commits from
feat/frontend-mobile-responsive, replayed ontoupgrade-mui-9-material-table-8. #1727 stays open as the backup and isunchanged.
What changed during the move
Two commits did not come across, because they were not mobile work. They
repair the upgrades and have already landed on #1747:
restore picker theming after the x-date-pickers v9 upgradecorrect app shell and grade guide sizing on small viewports. The app shell half is mobile work and is included here.Five commits needed conflict resolution. The one worth review is
UserUpcomingExperimentsTable: develop added a shipping feature flag whilethis branch extracted the action list so the cards and the table share it.
The two are combined so the flag hides the action in both views rather than
only in the table.
ProposalTablewas expected to be the hard one. It was not, because thisbranch had already absorbed the "View data access users" action and the
changed delete action through an earlier develop merge, so its extracted
helpers already encode the same conditions.
The last commit reformats six files. Each was prettier-clean on the old
branch, so the drift comes from merged hunks rather than a deliberate change.
Checks
tsc --noEmit,eslint --quiet,prettier --checkandvite buildallpass. Every commit was scanned for conflict markers. The end-to-end suite has
not been run against this branch yet.