Upward Mobility data dashboard
A public dashboard of mobility-from-poverty metrics for about 3,100 US counties and 480 cities. Drupal serves metadata through a cached JSON API. Editors control the dashboard's wording and behavior through forms, and a React front end, shipped as web components, reads both.
My part: I built the Drupal side in a custom module, umf_dashboard. That covers the content model, the JSON
API and its caching, the Layout Builder blocks and sort forms editors use, and the tooling that moves content between environments. The
dashboard team built the React application. The write-up covers the contract between the two: what the API returns, what editors can
change, and how the front end applies it.
Architecture at a glance
Editors
- Node forms for pillars, predictors and metrics
- Sort pages that set display order
- Layout Builder block forms for text, links and the guided tour
Drupal: umf_dashboard
- Content model of pillar → predictor → metric, built with Paragraphs and taxonomy
- JSON endpoints under
/umf_dashboard/…with a permanent cache that content saves invalidate by tag - Block plugins that render
<explore-widget …>and similar elements, with settings as attributes
Browser
- React app registered as six custom elements
- Reads its settings from the element's attributes and its content from the API
- Joins in the numbers from static CSVs and renders a Mapbox map, d3 charts and tables
Drupal never serves the numbers themselves. The metric values are precomputed CSVs from the research team's own data pipeline, pinned by version with Composer. Drupal describes the data (labels, units, chart rules, source text) and the browser joins the two.
1. The API layer
Six read-only JSON routes, handled by one controller. Content is addressed by a stable string ID (field_umf_id), not by node ID,
so URLs survive re-imports between environments.
| Route | Returns |
|---|---|
/umf_dashboard/pillars/all |
All pillars in editor-defined order, each with colors, icon, links and a slim list of its predictors and metrics. The front end builds its navigation and color theme from this. |
/umf_dashboard/predictor/{id} |
One predictor with full metric definitions: variables, units, subgroup trees, chart settings, dynamic-text templates and conditional source rules. |
/umf_dashboard/subgroup_labels |
A map from the data's machine names to display labels (for example black-non-hispanic → “Black, non-Hispanic”). |
/umf_dashboard/pillar/{id}, /predictors/all, /metric/{id} |
Single-item and list views, used for debugging and linked from the edit forms. |
Shape of a metric
{
"id": "JuvenileArrests",
"label": "Juvenile arrests per 100,000 juveniles",
"unit_type": "number_decimal",
"variables": [{
"id": "rate_juv_arrest",
"subgroups": {
"race-ethnicity": { "_parent_label": "Race/ethnicity", "hispanic": "Hispanic", "…": "…" },
"all": { "_parent_label": "None", "all": "All" }
}
}],
"dynamicText": "In {selectedYear}, there were {rate_juv_arrest} juvenile arrests for every 100,000 juveniles in {locationId}.",
"source": "… (Time period: {selectedYear-4}-{selectedYear})",
"chartSettings": { "ignore_years": { "CT": 2022 } },
"conditionalSources": [{ "conditions": { "isAllAvailableYears": true }, "action": "replace", "text": "…" }]
}
Typed settings from plain form fields
Chart behavior is stored as simple key/value Paragraphs, so editors could add a setting without a schema change. On the way out, the API
converts each value to its real type. Numeric strings become numbers, true, false and null become their
literals, and anything in braces is decoded as JSON. An editor typing {"CT":2022} produces a real object in
chartSettings.ignore_years, and the front end can hide Connecticut's 2022 data without a deploy.
Caching
Responses are cached in Drupal's cache backend with no expiry, invalidated by the cache tags for the metric, predictor and pillar content types. Saving any of those nodes clears every API response, so editors see their change on the next request. There is no time-to-live to tune.
Responses include an edit link only when the current user can edit that content. That makes the payload role-dependent, so the cache key includes the user's roles.
2. What editors control
There is no single settings page. Editors control the dashboard in three places, each matched to how often it changes.
Block forms: the wording and the tour
The dashboard pages are Layout Builder pages that hold five custom blocks: results, explore, search, standalone search and tour button. Each block form exposes the text its component shows, from 13 to about 55 fields per block:
- tab labels and tooltips
- warning dialogs (such as comparing cities with counties)
- empty-state messages
- link targets
- which page the search sends visitors to
- a guided tour of up to 20 steps, each with its text, the element it points at, and its placement
When the page renders, the block turns its saved configuration into HTML attributes on one custom element. The front end treats each attribute as an override of a built-in default, so a field left blank never breaks the page. Block settings are stored in the page's layout, so changes to the wording are revisioned like any other content edit. Inside the Layout Builder editor, the explore and search blocks show a readable summary of their settings instead of booting the full app.
<explore-widget
select-widget-title="Explore a predictor"
map-tab-label="Map"
distribution-tab-label="Distribution"
geography-toggle-tooltip="The map is only available for counties"
…
></explore-widget>
Content forms: the data dictionary
Each piece of content controls one part of the dashboard:
- Pillar: sets its name, icon and base, light and dark colors. The front end turns the colors into CSS variables that theme every card, border and label for that pillar.
- Predictor: holds its descriptions, calls to action and list of metrics.
- Metric: holds the rest:
- unit format and variables
- subgroup taxonomy
- a dynamic-text template (
{selectedYear},{locationId}, any data column, and arithmetic such as{selectedYear-4}) - chart settings: color ramp direction, axis range, hiding the map or distribution view with a message, per-state year suppression
- conditional source rules that append or replace citation text when, for example, a city is selected or all years are shown
The metric, predictor and pillar edit forms show where the item sits in the hierarchy, with links to edit its parents, open its API output, and reorder its siblings.
Sort forms: order
Three admin pages set the display order: pillars, predictors within a pillar, and metrics within a predictor. They write a sort weight on each node, and the save invalidates the cached API responses, so the new order appears on the next page load.
3. The front end those forms drive
The dashboard team built the React and TypeScript application. It connects to Drupal in three ways:
-
Web components as the boundary. The app registers six custom elements (such as
<search-widget>,<explore-widget>and<data-results-page>). To Drupal they are ordinary markup that a block can place, and the same components run unchanged in the team's component workbench. - Settings merged over defaults. Attributes become component props that override typed defaults. Pillar colors from the API become CSS variables, and each metric's chart settings steer its visualization.
- Metadata from the API, numbers from files. Query caching deduplicates API calls, and per-location and per-variable CSVs are fetched only for what is on screen.
What visitors can do:
- search up to six counties or cities
- explore any predictor on a county choropleth map or a national distribution chart
- compare places with dot plots and trend lines, filtered by year and subgroup, with confidence intervals
- switch any chart to a table
- share the exact view (every control is stored in the URL)
- export PNGs and filtered CSVs
- follow the guided tour that editors configured
One setting, end to end
This is how an editor's change to one metric reaches the map, with no deploy involved:
- An editor adds the chart setting
colorDirection=darkToLightto a metric and saves. - The save invalidates the metric cache tag, which clears every cached API response.
- On the next request, the API rebuilds the predictor payload and converts the setting into
chartSettings.colorDirection. - The explore widget fetches the predictor and reverses its color palette.
- It maps precomputed natural-breaks bins onto the reversed palette, and Mapbox recolors every county.
A wording change takes a shorter path: block form → page layout → attribute on the element → component prop → the label on screen.
What I'd do differently
- Type the contract. Attribute names were written by hand on both sides, and a few drifted apart. The tour's arrow placement, for example, never reached the UI. Generating the block form fields and the component prop list from one schema would have caught that.
- Tag every source. Subgroup labels are cached under the metric, predictor and pillar tags but not their own, so editing them waits on an unrelated save.
-
Finish the caching story. The API cache is solid, but the blocks kept a development-time
max-age: 0, and asset cache-busting relied on bumping a library version by hand.