React component library for various ads-eng products, consumed as
@mozilla-services/ads-eng-react-components. There's no registry — consumers install
directly from a public git tag, so no tokens or .npmrc setup are involved:
npm install github:mozilla-services/ads-eng-react-components#v0.3.0📖 Browse the components on Storybook — published from the latest release, so it always documents a version you can install.
Components here originate in
consvc-shepherd/ad-ops-dashboard;
the ESLint, TypeScript and Jest setup is deliberately a copy of that repo's so components
can be relocated with as few edits as possible.
npm install
npm run storybook # http://localhost:6006| Script | What it does |
|---|---|
npm run storybook |
Storybook dev server on port 6006 |
npm run build-storybook |
Static Storybook into storybook-static/ |
npm run build |
Builds dist/ (ESM + CJS + .d.ts) via Vite library mode |
npm run lint |
ESLint, then check:types |
npm run lint:fix |
ESLint --fix, then check:types |
npm run check:types |
tsc --noEmit over src/ and .storybook/ |
npm test |
Jest with coverage |
npm run test:watch |
Jest in watch mode |
Requires Node >= 24 and npm >= 11 (engine-strict=true).
npm blocks install scripts by default here. If
npm installwarns about@swc/coreoresbuild, runnpm approve-scripts --allow-scripts-pending— both need their postinstall to fetch native binaries.
.storybook/ Storybook config; preview.tsx mirrors the test provider stack
src/index.ts The public API — nothing is exported to consumers unless it's listed here
src/components/ One directory per component: Foo.tsx, Foo.test.tsx, Foo.stories.tsx
src/hooks/ usePage, useTheme — the contexts the providers below fill
src/providers/ PageProvider, ThemeProvider
src/theme/ createAdsEngTheme() — the shared theme definition
src/types/ Global helper types and the MUI theme augmentation
src/utils/ Only what components here need — see "What stayed behind"
src/test/ Shared test helpers (render wrapper, storage mocks)
Alert, AppLoader, Badge, Button (Button, AsyncButton, RemoteButton,
ClipboardButton, DialogButton, PopoverButton, ToggleButton, ToggleButtonGroup),
Chip/ActionChip, DashboardCard, Dialog, InputField (TextField, DateField,
EmailField, NumberField, CheckboxField, SelectField), Layout (Box, Grid,
Stack, Table*, Typography, …), SplitLayout, TabLayout/Tab, Link
(BackLink, ExternalLink, ExternalLinkOrNone), List/ListItem,
DescriptionList/DescriptionListItem, Page/EmbeddedPage, Popover, Progress,
ToastContainer, Tooltip; the usePage/useTheme hooks and PageProvider/ThemeProvider.
These are deliberately still in ad-ops-dashboard — each one needs app-owned data the library can't reach. Build them there on top of the primitives this package exports:
| Left behind | Depends on | Build it on |
|---|---|---|
DialogDataFormButton, PopoverDataFormButton |
DataForm → DataGrid → ag-grid |
DialogButton / PopoverButton + the app's DataForm |
RemoteTaskButton |
apiRoutes.taskStatus |
AsyncButton + app-local polling |
RemoteSelectField |
api/fetch (cache + de-dupe layer) |
SelectField + the app's fetchData |
SearchField |
useSearch / SearchProvider |
TextField + the app's search state |
AppLayout, DrawerLayout |
app shell, routes, SearchField |
— (app shell, not a library concern) |
Some supporting code was narrowed rather than copied wholesale:
utils/urls.ts— droppedgetShepherdOrigin()(readsprocess.env.SENTRY_ENV) andformatUrlWithPathParams()(needsDataGrid/DataGridUtils.getNestedValue()). Everything else came over, includingdownloadBlob(),downloadUrl()andgetUrlForEmailAddress()— no component here uses them, but they're generic and app-side callers can now import them from the package.utils/storage.ts— rewritten as a generic, untyped-key primitive. The app's exhaustiveStorageValueTypeMap(columnState,fetchState,invalidateApiRoutes,notificationSettings) would have pulled inconfig/routes.config,useNotificationsand ag-grid. Keys and JSON shapes are unchanged, so athemeSettingsvalue written by either implementation is readable by the other.SelectFieldParamsis{ options }only. The app's version also carriesapiRoute,queryParams,keyField,labelFieldandsuppressAppendingKeyFieldInFormData, all of which exist solely forRemoteSelectField— so they stayed with it.MultiSelectFieldParamsstayed too (it's only read byDataGrid), which is why there's noAPIRouteorIdentifiabletype in this package: nothing here needs them. If you relocateRemoteSelectFieldorDataGridlater, bring those fields and types along then.
ThemeProvider did move, and it now builds its theme from createAdsEngTheme() instead of
inlining the palette — so Storybook, the Jest render wrapper and the real app all share one
theme definition.
<Page title="…" /> sets the document title to baseTitle and title joined with ": ".
The app-wide part comes from the provider, not the page:
<PageProvider baseTitle="Ads Ops"> {/* once, at the app root */}
…
<Page title="Campaigns" /> {/* tab reads "Ads Ops: Campaigns" */}
</PageProvider>Empty parts are dropped, so a missing piece never leaves a stray separator:
baseTitle |
title |
document title |
|---|---|---|
"Ads Ops" |
"Campaigns" |
"Ads Ops: Campaigns" |
| — | "Campaigns" |
"Campaigns" |
"Ads Ops" |
— | "Ads Ops" |
| — | — | unchanged (see below) |
Two behaviors worth knowing:
- A titleless page doesn't reset the tab. When both parts are empty the composed title is
"", and Helmet treats that as nothing to set rather than clearing what's there — so the tab keeps the previous page's title, or whateverindex.htmlshipped. Only reachable in an app with nobaseTitle. baseTitleis a one-time seed. It initializes the provider's state, so changing the prop after mount has no effect. Fine for an app-wide constant; don't try to drive it from state.
baseTitle is deliberately not a Page prop — PageProps omits it, so passing it to
<Page> or <EmbeddedPage> is a compile error pointing you at the provider instead.
Every component module has a .stories.tsx beside it — 143 stories across 20 components,
with an autodocs page generated per component from its props and the JSDoc on its meta.
Published to https://mozilla-services.github.io/ads-eng-react-components/ by the
deploy-storybook job in release.yml, which runs only
after the release verification passes — a broken release never becomes the live docs. It uses
GitHub's Pages artifact deployment, so there's no gh-pages branch: the build is ~11 MB
across 138 files and committing that per release would grow history permanently for content
that's rebuilt every time.
Conventions, if you're adding one:
title: "Components/<Name>",tags: ["autodocs"], andsatisfies Meta<typeof Component>.- Put the why in a JSDoc block above
meta— autodocs renders it as the component's description, so that's where non-obvious behavior belongs. - Cover the states that are hard to reach by clicking around: pending, failure, empty, loading, overflow/ellipsis.
- Components opened imperatively through a ref (
Dialog,Popover) have no visible surface on their own, so those stories render a localTriggerwrapper that owns the ref.
.storybook/preview.tsx wraps every story in the same provider stack as
src/test/utils.tsx — HelmetProvider, the MUI theme from createAdsEngTheme(), and a
MemoryRouter — plus a light/dark toggle in the toolbar. Keep the two in sync.
build-storybook bundles stories but never renders them, so a story that throws would only
surface when someone opened Storybook. src/test/stories.test.tsx renders all of them,
discovering story files from disk (no list to maintain) and applying the preview's decorators
via setProjectAnnotations. It also fails if a component module has no story file at all.
This is why jest.config.mjs allows @storybook/* through transformIgnorePatterns — those
packages ship ESM only.
A few things Storybook can't fully show, and why:
RemoteButtonissues a real request. Nothing serves an API behind Storybook, so its story lands in the failure state deliberately;AsyncButtoncovers the success path.EmbeddedPageframes a remote URL, which most sites refuse viaX-Frame-Options.ToastContainerneedsreact-toastify/dist/ReactToastify.css, which this package does not bundle — that would force a stylesheet on every consumer and breaksideEffects: false. The story imports it; your app must too (ad-ops-dashboard does, inApp.tsx).
src/types/global.ts and src/types/mui.ts are .ts files (not .d.ts) whose types are
re-exported from src/index.ts. Both details matter:
- As
.tsfiles they're compiled intodist/, so they ship. - TypeScript elides side-effect-only imports from declaration output, so a plain
import "./types/mui"inindex.tswould vanish fromdist/index.d.tsand consumers would silently losetheme.palette.flagsand theExtendableComponent*globals. The named re-export is what keeps them reachable.
If you add another module augmentation, follow the same pattern and give it at least one named export.
- Copy the component directory into
src/components/, along with its test. - Fix up relative imports.
modulePaths/moduleResolutionare configured the same way in both repos, so most paths carry over unchanged. - Resolve anything app-specific it drags in. The usual suspects:
- App-owned providers (
GlobalEventTargetProvider,PreferencesProvider,SearchProvider,NotificationsProvider) and app-owned config (config/routes.config,api/fetch). These are app state, not library concerns — take them as props, or a context the app supplies, rather than importing the app's copy. If that isn't practical, leave the component in the app and build it on a primitive from here — see the table above. axiosis a peer dependency on purpose: the app installs auth interceptors on the shared instance, so the library must not bundle its own.
- App-owned providers (
- Check for module-scope browser access. Anything at the top level of a file runs on
any
importof this package once it's re-exported fromsrc/index.ts. A barewindow.matchMedia(...)/document...at module scope is safe in a browser-only app but throwswindow is not definedhere under Node, SSR prerender or a non-jsdom Jest environment. Resolve it lazily inside the component —ThemeProvidershows the pattern. To catch it:node -e "require('./dist/index.cjs')"after a build. - Add a
Foo.stories.tsxnext to it — see Stories for the conventions.src/test/stories.test.tsxfails if a component module has no story file. - Export it from
src/index.ts— including its prop types. Prop interfaces that were file-private in the app need anexportto be usable by consumers. - If it needs a provider at test time, add that provider to both
src/test/utils.tsxand.storybook/preview.tsxso Storybook and Jest agree. npm run lint && npm test && npm run build.
Components that use ag-grid or ag-charts additionally need module registration and the
license key wired into jest.setup.ts — there's a commented block at the bottom of that
file showing what to add — plus ag-grid-enterprise/ag-grid-react added back as peer
dependencies and to the externalPackages list in vite.config.mts.
If a component needs a browser API jsdom lacks, stub it in jest.setup.ts rather than
per-test. ResizeObserver and IntersectionObserver are already stubbed there (the former
because react-resizable-panels constructs one in a layout effect).
Consumers install straight from a public git tag, so the tag is the artifact — nothing is published to a registry.
npm version minor # or patch / major — creates the commit and the vX.Y.Z tag
git push --follow-tags
gh release create v0.3.0 --generate-notesOn release, .github/workflows/release.yml verifies the tag
matches version in package.json, runs lint and tests, then installs the tag exactly as a
consumer would and asserts the built entrypoints exist. That last check matters: a git
install builds the package from source via prepare, and if prepare is missing or broken
npm installs a package with an empty dist/ and no error — consumers just get a module with
no exports.
It then publishes that tag's Storybook to GitHub Pages (see Stories).
CI (.github/workflows/ci.yml) runs lint, tests, the package
build, the Storybook build, and npm pack --dry-run on every PR and push to main.
The repo must stay public. A git install of a private repo needs SSH keys or a token in every consumer and CI job, which is the plumbing this model exists to avoid.
dist/ is gitignored, so it doesn't exist in a tag. npm builds it at install time by running
the prepare script — the only lifecycle hook it runs for git dependencies.
prepublishOnly does not run for git installs, which is why it was replaced. Don't remove
prepare or rename it without changing the consumption model.
Peer dependency ranges are pinned to what ad-ops-dashboard currently uses (React 18, MUI 7). Bumping a peer's major is a major version bump here, since consumers have to move in lockstep.
Git dependencies don't resolve semver ranges: a consumer pins one tag and moves deliberately.
npm install records the tag's exact commit SHA in the lockfile, so npm ci stays
reproducible even if the tag is later moved.
No registry, no .npmrc, no token:
npm install github:mozilla-services/ads-eng-react-components#v0.3.0Bumping later means changing the tag in package.json and re-running npm install.
import { Badge, createAdsEngTheme } from "@mozilla-services/ads-eng-react-components"theme.palette.env and theme.palette.flags are typed automatically — delete the local
src/mui.d.ts and src/global.d.ts from the consumer once you're importing from here, or
the duplicate declarations will conflict.
The package ships ESM and CJS. The ESM entry assumes a bundler (Vite, webpack) because
react-helmet-async is CJS-only and exposes no named ESM exports that Node's loader can
detect — import { ToastContainer } from "@mozilla-services/ads-eng-react-components" in a
plain Node ESM script fails on that, while the same import through Vite is fine. Node
require() of the CJS entry works unconditionally, which is what the consumer's Jest uses.
Two things the registry didn't require:
- A git client.
npm installshells out togitto clone the tag. Fullnode:*images have it;node:*-slimandalpinedo not and will fail on install (ad-ops-dashboard's dev Dockerfile installs it explicitly). - Install scripts enabled.
prepareis what buildsdist/. Underignore-scripts=truethe install silently yields a package with nodist/, so don't harden that flag on in a consumer without vendoring the build another way.
Installs are also slower than a registry fetch — npm clones the repo, installs the library's devDependencies and runs a build (~15–20s) rather than unpacking a prebuilt tarball.
ad-ops-dashboard's transformIgnorePatterns excludes all of node_modules from
transformation. This package ships CJS alongside ESM specifically so that keeps working. If
you do hit a parse error, allow-list the package:
transformIgnorePatterns: [
"[/\\\\]node_modules[/\\\\](?!@mozilla-services[/\\\\]ads-eng-react-components).+\\.(js|jsx|mjs|cjs|ts|tsx)$",
],