A small React MUI component library for Entur's hathor frontend. Components are built on MUI and typed directly from the live sobek GraphQL schema. Documented and previewed with Storybook.
Status: seed. The library ships generated data-aware form components (
VehicleForm,VehicleTypeForm) typed from the live sobek GraphQL schema. Per-entity bindings (vehicleTypeFields,vehicleFields) are generated from the live schema. Not published to a package registry yet; the Storybook is deployed to GitHub Pages.
These generated components load an entity from sobek, render a schema-driven form, and save edits back. They are the public form API.
import { VehicleForm, type VehicleLayout } from '@entur/mui-comps-nmr';
const layout: VehicleLayout = {
Edit: ['name', 'registrationNumber', 'operationalNumber'],
Dates: ['buildDate', 'registrationDate'],
};
<VehicleForm
endpoint="https://api.entur.io/sobek/graphql"
headers={{ 'Client-Name': 'hathor' }}
getHeaders={() => ({ Authorization: `Bearer ${token}` })}
netexId="VEH:Vehicle:701"
mode="edit"
layout={layout}
onSaved={(id) => router.push(`/vehicles/${id}`)}
onError={(msgs) => toast.error(msgs.join(', '))}
/>| Prop | Type | Notes |
|---|---|---|
endpoint |
string |
sobek GraphQL endpoint URL. |
headers? |
Record<string, string> |
Static headers sent with every request. |
getHeaders? |
() => Record<string, string> | Promise<...> |
Dynamic headers (e.g. OIDC tokens). Called once per request, so a refreshed token is always picked up. Safe to pass as an inline literal — identity churn never re-triggers a load. |
netexId? |
string |
Entity to load. Omit for create mode. Changing it from set → undefined keeps the current value (in-progress edits survive) and discards any in-flight load — it does not blank the form. |
mode? |
'view' | 'edit' |
Default 'edit'. |
layout? |
Layout<EntityField> |
Whitelist of sections (see below). Omitted → flat, all fields. |
variant? |
'tabs' | 'stacked' |
≥2 sections. Default 'tabs'. |
slotProps? |
ControlSlotProps |
Per-kind MUI overrides (TextField, Switch, DataGrid, Tabs). |
onSaved? |
(netexId: string) => void |
Called after successful save + refetch. |
onError? |
(generalErrors: string[]) => void |
Called with non-field GraphQL / network errors. |
layout is a whitelist of sections: each key becomes a section label; its
array is the ordered list of fields to render in that section. A layout item is
either a bare field key or an object with extra per-field config:
const layout: VehicleTypeLayout = {
Identity: ['name', 'shortName', { field: 'dataOwnerRef', label: 'Owner' }],
Dimensions: ['length', 'height', 'width', 'weight'],
};Key points:
-
Loss-free omission — a field omitted from
layoutis not rendered, but its value survives a save. Omitting a field never drops data. -
Omit
layoutentirely — renders all fields in a flat single panel. -
Single section renders flat — no tab bar or panel header; the section key is ignored visually.
-
variant— when there are ≥2 sections,'tabs'(default) shows a tab bar with one panel visible at a time;'stacked'renders all panels top-to-bottom. -
serverManagedfields — fields flaggedserverManaged(backend-owned:version,created,changed,changedBy) render locked even ineditmode. They are not round-tripped — the backend owns them. Their displayed values go stale after a successful save; the component refetches to refresh them. -
referencefields (e.g.Vehicle.transportType,VehicleType.deckPlan) edit a single relation by its identity leaf. Passoptionsto render an Autocomplete rather than a plain id field:const layout: VehicleLayout = { Edit: [{ field: 'transportType', label: 'Vehicle type', options: () => [ { value: 'VEH:VehicleType:1', label: 'Class 70 EMU' }, { value: 'VEH:VehicleType:2', label: 'Class 80 DMU' }, ], }], };
valuein each option is the referenced entity'snetexId;labelis display-only. -
Grid fields — array-of-identity relations (e.g.
VehicleType.vehicles) render as a read-onlyObjectGrid. Useentriesto fix columns:const layout: VehicleTypeLayout = { Vehicles: [{ field: 'vehicles', entries: [ { field: 'netexId', label: 'NeTEx ID' }, { field: 'name', label: 'Name' }, ] }], };
-
Labels default to a humanized field key (
seatingCapacity→ "Seating Capacity"). Override vialabel. There is no i18n dependency — localization is the client's responsibility.
The library never hand-maintains entity shapes. It generates TypeScript from the live sobek schema using The Guild's GraphQL Code Generator, then distils that into per-entity modules.
- Canonical schema URL:
https://entur.github.io/sobek/schema.graphqls npm run codegendownloads that schema (scripts/fetchSchema.ts) and emitssrc/generated/sobekTypes.tscontaining all entity types and enums (PropulsionType,FuelType,HybridCategory,TransportMode,FareClass, …). Enums are emitted as runtime TypeScript enums so the form both type-checks against them and lists their members in dropdowns.- The generated file and the downloaded schema are git-ignored — they are build artifacts, not source.
npm run distill (which chains npm run codegen) parses
src/generated/sobekTypes.ts and writes committed per-entity modules to
src/entities/*. Each module contains:
-
Entitytype — the full read entity type, verbatim (nested structure preserved for value round-tripping). -
FIELDSregistry — a flat, addressable map of every renderable field. Value-object leaves (e.g.passengerCapacity.seatingCapacity) are hoisted into individually-addressable entries with their access path (path: ['passengerCapacity', 'seatingCapacity']). Each entry carries:kind— the control family (text,number,name,switch,enum,enumMulti,grid,reference,date,datetime).path— access path into the entity value.options— enum member list (forenum/enumMulti).serverManaged— derived, not hand-set: a field present on the readEntitybut absent from itsInputis backend-owned, so distill flags it. No client tagging.
-
Array-of-object relations (e.g. a vehicle type's
vehicleslist — an array of identity-bearing objects) distill to a singlegridfield. It renders as a read-only table (ObjectGrid, built on MUI X Data Grid) whose columns are derived at runtime from the row data — scalar andMultilingualStringleaves shown, nested objects/arrays skipped. Such relations are absent from theInputtype, so they distill asserverManagedautomatically and are never edited here. Place the field in a layout section (Vehicles: ['vehicles']) to render it. To fix the column order and labels, give the layout entry nestedentries(each a{ field, label }wherefieldis a row-object key):const layout: VehicleTypeLayout = { Vehicles: [{ field: 'vehicles', entries: [ { field: 'netexId', label: 'NeTEx ID' }, { field: 'name', label: 'Name' }, ] }], };
Omit
entriesto auto-derive every column.entriesis ignored for non-grid fields. -
Single relations and id-less object arrays (e.g. a one-to-one linked entity, or a
keyValueslist with no identity) get noFIELDSentry — there is no control family for them and editing them is out of scope for a flat details form. They stay on theEntitytype, so they ride along onvalueuntouched: every edit writes a single leaf by itspath, never the whole object, so editing a scalar can't drop a relation. This is what lets the editedvalueround-trip back as a complete entity. -
The
FIELDSdata and enum runtime values are bundled into the published JS;Entitytypes are type-only and erased at runtime.
The public API re-exports each module under its entity name:
// src/entities/index.ts (auto-generated)
export type { Entity as VehicleType, EntityLayout as VehicleTypeLayout } from './vehicleType';
export { FIELDS as vehicleTypeFields } from './vehicleType';
export type { Entity as Vehicle, EntityLayout as VehicleLayout } from './vehicle';
export { FIELDS as vehicleFields } from './vehicle';schema/sobek.patch.graphqls is a committed SDL overlay applied before codegen
runs, carrying fields the domain needs but the live sobek schema has not shipped
yet:
extend type VehicleType {
manufacturer: String
range: Float
fullCharge: Float
carLoading: Boolean
}
extend input VehicleTypeInput {
manufacturer: String
range: Float
fullCharge: Float
carLoading: Boolean
}Extending both halves is deliberate. The read type satisfies the distill
script's "Input ⊆ Entity" check (every field in VehicleTypeInput must exist on
VehicleType). The input keeps the field out of the serverManaged set,
because distill derives that flag from "present on Entity, absent from
Input" — extend only the read type and the field renders permanently locked.
The generated types are therefore deliberately ahead of the live read
schema. This is safe because the library executes no GraphQL operations — it
only generates types and renders a form. When sobek ships a field for real,
delete the matching extend lines by hand (as was done for dataOwnerRef,
which is now a genuine read-schema field).
npm run build uses Vite's library mode.
Some terms, expanded for anyone new to library packaging:
- ES module (
dist/index.js) — the modern JavaScript module format, loaded withimport. This is what bundlers (Vite, webpack, etc.) prefer; it tree-shakes well (unused exports get dropped from the consumer's bundle). - CommonJS (
dist/index.cjs) — the older Node.js module format, loaded withrequire. Shipped alongside the ES module so the package works in older/Node-style toolchains too. - Type declarations (
dist/index.d.ts) —.d.tsfiles describe the types of the compiled JavaScript. They give consumers autocomplete and type-checking without shipping the TypeScript source. Generated here byvite-plugin-dts. - Externalised peer dependencies — React, MUI, and Emotion are not bundled
into
dist. They are declared as peer dependencies: the consuming app is expected to already have them, and supplies its single shared copy. Bundling our own copies would bloat the output and, with React especially, cause subtle "two Reacts" bugs (hooks throwing, context not matching). The host app's copy is the only one in play.
The package.json exports map points each consumption style at the right
file: import → the ES module, require → the CommonJS file, types → the
declarations.
npm run storybook— run Storybook locally on port 6006.npm run build-storybook— build the static Storybook tostorybook-static/.- CI (
.github/workflows/storybook.yml) runsdistill→build-storybook→ deploy to GitHub Pages on every push tomain. Enable Pages for the repo with the "GitHub Actions" source.
| Script | Does |
|---|---|
npm run codegen |
Download schema → generate src/generated/sobekTypes.ts. |
npm run distill |
Download schema → codegen → write src/entities/*. |
npm run build |
Library build to dist/ (runs distill first). |
npm run storybook |
Local Storybook dev server (runs distill first). |
npm run build-storybook |
Static Storybook build (runs distill first). |
npm run test |
Vitest unit tests (runs distill first). |
npm run typecheck |
tsc --noEmit (runs distill first). |
React 19, MUI 7, and Emotion 11 are pinned to match hathor (the primary consumer). They are peer dependencies — the host app owns the actual versions.