people-cities-js — People and Cities (MINT)
A frontend for MINT — Measurement INterface Translator, a Backend For Frontend that converts measurements between unit environments and declares the units it used. See The backend: MINT below.
Features
- Environment Selection: Dropdown to select measurement environment (Metrical, Imperial, Custom)
- Person detail panel: Double-clicking a row opens a slide-in panel (
modules/detail-panel.js). Temporal fields (Birth Date) are rendered by the grid's sharedwindow.VanillaGridDateTimeFeature.formatTemporalValue(), resolving the columntypefrom the page's<vn-grid-column>markup so the panel and the grid cell always agree — previously the panel had no temporal branch and displayed the raw wire string. Nested objects (Hometown) render through the sharedformatHometownValue()helper instead of a JSON dump. - Dynamic Grid Headers: Table headers are dynamically generated from MINT's
$metadata - Unit Display: Each property column shows its unit based on the selected environment
- Real-time Updates: Metadata is fetched when environment changes
- Row Double-Click Detail Panel: Double-clicking a row in the people grid opens the detail panel for that row, regardless of selection state
- Export to Excel: Export selected rows to an Excel file via the toolbar button (enabled when at least one row is selected)
- Free-text search: Both grid toolbars carry a
<vn-grid-toolbar-search delay="600">box. Its presentation follows the active theme — the expandable (Carbon-style collapsing) variant under the IBM Carbon light/dark themes, the standard inline box otherwise — toggled at runtime bymodules/theme.js'sswitchTheme(). MINT does not implement OData$search, so both DataManagers run insearchMode: 'filter'— the term is emitted as acontains()disjunction folded into$filter(People:Name/Email/Hometown/Name; Cities:Name/Hometown/Name). Because both searches are field-restricted, hovering or focusing either search box now reveals a tooltip listing the searched fields (<vn-grid-toolbar-search>'s built-in search-fields tooltip — no app code required). Each field renders as its column's header label — since both tabs'getHeaderMainTextresolves throughheaderKeyMap(modules/bff.js'sbuildSharedGridOptions()) to a localized i18n string, theHometown.Namesearch field shows as "Hometown" in the tooltip, not the raw OData field path. The active term is persisted tolocalStorage(viapersistence.searchTerm.enabled: trueinbuildSharedGridOptions()) so it survives a page reload alongside sort and column filters - Search-match highlighting: Both
#peopleGridand#citiesGridsethighlight-search-matches, so a visible cell whose formatted text contains the active term gets that substring wrapped in<mark>. This is the richest exercise of the feature in the repo — both grids runODataDataManagerwith infinite scroll, so newly fetched "load more" pages are highlighted on arrival through the same pooled render path as the initial page, with noODataDataManagercode changes. Because highlighting only re-matches the visible, formatted text (never the server's$filter/contains()matching), a row can appear in the filtered result set with no cell highlighted if it matched a field not shown in these grids — see docs/vanilla-grid/03-data-manager-implementation.md §11.5. - Cities tab — alternate grid view: The Cities tab is a second
<vn-grid id="citiesGrid">view over the same People OData endpoint (/odata-c/users). It shows a combined Hometown first column (custom render: hometown name + area, inline — e.g.Springfield — 142 km²) followed by Name and Birth Date. Infinite scroll is always on (no UI toggle) and the tab carries its own toolbar (status + search / export / auto-fit / reset / view-selection / refresh) plus an Environment selector. It reuses the shared MINT helpers (modules/bff.js), toolbar helpers, and person-detail panel. The combined column's text comes fromformatHometownValue()inmodules/utils.js, shared with the person-detail panel so both render a hometown identically. Implemented inmodules/cities-tab.js.
Resizing the grid
The grid sits in a src/vanilla-resize-box/ (aria-label="Resizable people grid" on the People tab, "Resizable cities grid" on Cities). Drag the corner handle to resize it — resizing is pointer-driven, with no keyboard path.
The handle carries role="separator" and takes its accessible name from the host's aria-label — e.g. "Resizable people grid — resize handle" — but is not focusable, so it adds no tab stop to the page. This app's markup needs nothing beyond the aria-label it already carries.
Structure
people-cities-js/
├── index.html # Main HTML page (both tab panels + toolbars + shared detail panel)
├── styles.css # Layout styling (visuals come from themes/)
├── themes/ # App theme stylesheets (app-<name>.css)
├── app.js # Bootstrap only: locale, event wiring, tab switching
├── modules/
│ ├── index.js # Barrel consumed by app.js only (modules import siblings directly)
│ ├── state.js # Constants (endpoints, preference keys) + locale/threshold state
│ ├── dom-refs.js # data-ref driven DOM reference map (collectRefs/getDomRefs)
│ ├── utils.js # Pure helpers (parsing, metadata resolution, preference restore, formatHometownValue)
│ ├── i18n.js # Locale bundles, t(), number formatters, static-UI localization
│ ├── preferences.js # localStorage save/restore for the controls
│ ├── theme.js # App + grid theme switching (themes every <vn-grid>)
│ ├── bff.js # MINT headers, memoized $metadata, thresholds, DataManager/grid factories
│ ├── toolbar-helpers.js # Shared toolbar routing: status, conversion info, export, row-count
│ ├── detail-panel.js # Shared person-detail panel (render, open/close, view-selection); temporal fields render via VanillaGridDateTimeFeature
│ ├── people-tab.js # People tab: grid init, loading, header units, thermometer, handlers
│ └── cities-tab.js # Cities tab: grid init, loading, combined Hometown column, handlers
└── README.md # This file
Both tabs follow the same architecture: an ODataDataManager (infinite scroll, 1000 rows/page, server-side sort/search) built by bff.js's createPeopleODataManager(), grid options from buildSharedGridOptions(), a toolbar wired inside the tab module (init<Tab>Tab()), and a load<Tab>Rows() / refresh<Tab>() / handle<Tab>EnvironmentChange() public surface. Sort, column-filter, and search changes re-fetch page 0 automatically: each fires a config-change on the manager and the grid (setAutoReloadOnConfigChange(true)) coalesces them into one reload — so onSortChanged is a notification hook (it just logs), not a reload trigger.
How It Works
- Environment selection — the user picks a measurement environment
(Metrical / Imperial / Custom) from the dropdown. The choice is sent as an
X-MU-Environmentheader on every MINT request and persisted tolocalStorage. - Metadata fetch —
bff.jsrequests (and memoizes) the OData$metadatafor the selected environment, resolving each property's unit and decimal places from MINT'srequesteddeclaration. - Headers with units — grid headers show each property's unit for the
active environment (e.g. Height in
mvsft). - Data load — each tab's
ODataDataManagerloads rows automatically (loadPeopleRows()/loadCitiesRows()), with infinite scroll, server-side sort, and server-side free-text search folded into$filter.
The backend: MINT
This app is a frontend for MINT — Measurement INterface Translator (github.com/lucamenazzi/mint), which implements the Backend For Frontend (BFF) pattern: it sits between the browser and an internal OData service and reshapes that service's responses for this specific client.
What MINT translates is measurements. The internal service stores each
quantity in one fixed unit; MINT converts values per request into whatever unit
the caller's environment uses, and declares those units so the client can
label and format them. The frontend therefore performs no unit conversion of
its own — it renders what it is given and reads the units out of $metadata.
The mechanism is a single request header, X-MU-Environment: metrical | imperial | custom. The same row, same query, two environments:
GET /odata-c/users?$top=1 X-MU-Environment: metrical
→ { "Id": "00000343-…", "Height": 156.97, "Weight": 94.28, "BodyTemperature": 36.5 }
GET /odata-c/users?$top=1 X-MU-Environment: imperial
→ { "Id": "00000343-…", "Height": 514.9934, "Weight": 207.8518, "BodyTemperature": 98 }
$metadata reports both sides of that translation per property — what the
backend has (configured) and what this caller asked for (requested),
including the decimal places each unit should be rendered with:
"height": [{
"configured": { "environment": "metrical", "quantity": "Length", "unit": "m", "decimalPlaces": 2 },
"requested": { "environment": "imperial", "unit": "ft", "decimalPlaces": 4 }
}]
That document is what drives two visible grid behaviours: the unit shown beside
each header (column.secondaryLabel) and the numeric precision
(column.formatOptions) — both applied by updatePeopleHeaderUnits() in
modules/people-tab.js. Switching the environment
dropdown re-fetches metadata and rows, and the headers relabel themselves.
Prerequisites
None. MINT is hosted at https://people-cities-app.mthome.org and answers with
Access-Control-Allow-Origin: *, so the browser reaches it directly from
whatever origin serves this app — there is no backend to start locally.
Like the other sample apps that hit a public API, this one needs network access to show rows; offline, the grids stay empty and the toolbar reports the fetch error.
Run
Serve the workspace root with any static server, then open
/samples/people-cities-js/index.html. For example:
python -m http.server 8080 # then open http://localhost:8080/samples/people-cities-js/
# or
npx http-server -p 8080
The Playwright suite auto-starts its own static server on port 4173 (see
playwright.config.js) — no manual server needed to run the tests.
Testing
Two kinds of Playwright spec cover this app, and the split is deliberate:
- 45 hermetic specs boot the app through
bootGrid()intests/playwright/_helpers.js, which aborts every request to the MINT host and injects synthetic rows viagrid.setRows(...). They assert grid behaviour — sorting, filtering, resizing, keyboard nav, theming, export — with zero network, so they are deterministic and run offline. people-cities-js-smoke.spec.jsis the one spec that lets the real MINT instance respond, checking end-to-end that the grids boot, rows arrive,$countresolves, and the$metadata-driven header units land. It is excluded from instrumented coverage runs alongside the other live-network smoke specs (seedocs/testing-and-coverage.md).
BACKEND_URL_PATTERNS in _helpers.js must stay in sync with MINT_BASE_URL
below. If it stops matching, the hermetic specs do not fail — they silently
start making real network calls.
API integration
All requests carry the X-MU-Environment header. Endpoints (base URL and paths
are defined in modules/state.js):
GET https://people-cities-app.mthome.org/odata-c/users/$metadata # per-property configured vs requested units + decimal places
GET https://people-cities-app.mthome.org/odata-c/users # People rows, measurements already translated
# (OData $skip/$top/$orderby/$filter)
GET https://people-cities-app.mthome.org/odata-c/users/$count # total row count
POST https://people-cities-app.mthome.org/odata-c/users/$convert # translate specific values between units
$convert is the one call that is not a grid data source: the body-temperature
thermometer cell has fixed thresholds expressed in °C, and
loadTemperatureThresholds() in modules/bff.js posts them
to MINT to get them back in the active environment's unit rather than
converting them client-side.
MINT annotates its responses with X-MU-ConvertionStrategy (the conversion
strategy applied, e.g. traditional), X-Metadata-Api (where to fetch the unit
declarations for this endpoint), and X-MU-ResponseSizeKB.
displayConversionStrategy() in modules/toolbar-helpers.js
renders those into each toolbar's <vn-grid-toolbar-info> ⓘ tooltip from the
DataManager's onFetchResponse hook. Because the tooltip describes the response
the rows on screen came from, each tab clears it while a full load is in
flight — the first load and every reload a column filter, a sort, the search box
or the reload command triggers — so a stale strategy never appears to describe
rows that are being replaced. This is deliberately app-specific, not a shared
helper: it is wired per tab in modules/people-tab.js
and modules/cities-tab.js as a vn-grid-loading
listener, that event being exactly the set of loads meant (loadRowsAsync() is
its only emitter). An infinite-scroll page — reported through
vn-grid-load-more-succeeded / -failed — leaves the tooltip up: those rows are
appended, not replaced. No restore wiring is needed — the next response re-fills
it, and a failed load correctly leaves it hidden.
The Cities tab is a second grid over the same /odata-c/users endpoint
with a different column projection (see modules/cities-tab.js).
Customization
- MINT base URL / endpoints — edit
MINT_BASE_URLand the endpoint constants inmodules/state.js. This is the single source of truth: the value ships as-is (the build performs no URL substitution), so point it at a local MINT instance to develop against one, and updateBACKEND_URL_PATTERNSintests/playwright/_helpers.jsto match. - Environments — add an
<option>to the environment dropdown inindex.html. - Styling — layout lives in
styles.css; visuals come from the app theme stylesheets inthemes/(seethemes/README.md).
Browser compatibility
Modern browsers (Chrome, Firefox, Edge, Safari) with ES module and Fetch API support.
styles.css sets overscroll-behavior-y: none on html/body alongside the
height: 100dvh app shell — the host-owed half of the grid's mobile touch
contract that stops document-level rubber-band bounce and pull-to-refresh on
touch devices (see docs/vanilla-grid/00-index.md, "Mobile / touch
integration").