| name | tn-migration |
| description | Playbook for migrating webui feature-area page templates from Angular Material to @truenas/ui-components (tn-* components). Use when working on any child ticket of Epic NAS-141021 — any "Migrate <area> to tn-*" task — or when replacing any mat-* element (card, button, button-toggle, menu, select, toolbar, tooltip, list, tabs, stepper, expansion, slide-toggle, slider, checkbox, radio, datepicker, dialog, snackbar, table, tree, divider, autocomplete, sidenav, progress-bar/spinner, etc.), ix-empty, info-message notices, or SlideIn-hosted forms with their tn-* equivalents. Contains a comprehensive Material → tn-* component map plus the card, side-panel dual-host, declarative-signal, banner, empty-state, table, button-toggle, test-id, and spec-harness recipes established by the shares-dashboard pilot (NAS-141074) and the audit-page migration (NAS-141063). |
Angular Material → @truenas/ui-components migration playbook
This is the shared reference for the webui component-library migration (Epic
NAS-141021, "ER-66"). It encodes the pattern proven by the shares-dashboard pilot
(NAS-141074) so that every dev and every Claude session migrates a component the same way.
Scope — what this playbook covers
This playbook is for feature-area tickets: replacing direct Angular Material usage in a
page area's own templates and components (NAS-141039–141065, plus the pilot NAS-141074).
It is not for the shared-infrastructure tickets. Do not migrate these here — they have
their own tickets and migrating them piecemeal will cause conflicts:
| Concern | Owning ticket | What to do in a feature-area migration |
|---|
ix-forms internals (ix-input, ix-select, ix-fieldset, ix-chips, ix-checkbox) | NAS-141028 | Leave as-is. Keep using ix-* form controls. |
ix-table and its sub-components | NAS-141029 | Gone. app/modules/ix-table was deleted; use tn-table + app/modules/tn-table. |
DialogService / dialog components | NAS-141022 | Keep calling DialogService. |
SnackbarService | NAS-141027 | Keep calling SnackbarService. |
SlideIn system / modal-header | NAS-141030 | Use the dual-host recipe below — do not delete SlideIn. |
Also leave alone: ix-card-alert-badge, RequiresRolesDirective where still used outside
declarative actions, and tn-icon (already migrated — always tn-icon, never ix-icon).
⚠ SCOPE UPDATE (2026-06) — forms, tables, and SlideIn are now IN scope
As of mid-2026 the epic has progressed past the "leave as-is" deferrals above. For
active feature-area tickets, the ix-forms, ix-table, and SlideIn rows in the
table above are superseded — migrate them in-place alongside the Material work:
ix-table → tn-table. DONE (NAS-141029): app/modules/ix-table no longer exists.
Tables use [tnColumnDef] + <ng-template tnHeaderCellDef> / <ng-template tnCellDef let-row>, with the cell components in app/modules/tn-table-cells/
(TableTextCellComponent, TableActionsCellComponent, TableToggleCellComponent,
TableRelativeDateCellComponent, TaskStateCellComponent). [expandable]="true" +
<ng-template tnDetailRowDef> for detail rows, with <ix-table-details-row> printing the
columns the picker has hidden. Everything around the table — data providers, createTable,
column()/actionsColumn(), toDisplayedColumns, mapTnSortToTableSort,
tnTableListHost, <ix-table-column-picker>, <ix-table-pager-show-more> — now lives in
app/modules/tn-table/. The pager itself, <tn-table-pager>, comes from
@truenas/ui-components.
Reference: cron-list, portal-list, jobs-list.
ix-* form controls → tn-form primitives. ix-fieldset → <tn-form-section [heading]> (there is no tn-fieldset). Wrap each control in <tn-form-field [label] [tooltip] [required]> containing tn-input / tn-checkbox / tn-select. tn-input [multiline]="true" replaces ix-textarea; for numbers.
takes a (unwrap observables with )
and does translate labels — pre-translate with . has no — put the indicator on the wrapping .
Its own copy (placeholder, empty-dropdown message, "select all") comes from the app-root
provider (, ), so do bind
/ per call site — only where a field needs its own wording.
Controls with no equivalent (, , ,
, ) . Reference:
, .
Core principles
- Manual, file-by-file. No codemods. Read each component in full context and make
verified changes. The transforms below are judgment calls, not mechanical substitutions.
- One ticket owns a disjoint set of files. Don't reach outside your ticket's files.
- Preserve test IDs. Automated tests match on
data-test selectors — a dropped or
renamed ID is a silent regression. See the Test IDs section.
- Run the component's spec after every file.
yarn test src/app/path/to/file.spec.ts.
Component & directive mapping
This is the first lookup for "what does mat-X become?" The map describes the library
state at @truenas/ui-components@0.1.60. For any non-obvious API (input names,
projection slots, default values), always verify against the installed types:
node_modules/@truenas/ui-components/types/truenas-ui-components.d.ts. The map can lag
behind library releases; the .d.ts cannot.
Rows in the Notes column flagged ⚠ are non-obvious gotchas — read them before
swapping. Rows where the tn-* column is (no equivalent yet — hold) mean the library
hasn't shipped a replacement; do not silently leave the Material element in place —
either keep the legacy surface and surface to NAS-141021 lead, or skip that surface in
the ticket and document it in the PR.
Cards & layout
| Angular Material | @truenas/ui-components | Notes |
|---|
<mat-card> | <tn-card> | See Recipe 1. mat-card { height: 100% } SCSS is dropped. |
<mat-card-content> | (remove) | Content goes directly inside tn-card. [padContent]="true" (default) controls inner padding. |
<mat-card-header> | [tnCardHeader] projection directive | Projected <ng-content select="[tnCardHeader]"> slot. ⚠ If you project, the library suppresses its own <h3 class="tn-card__title"> — see Recipe 1's "four header patterns." |
<mat-card-title> | [title] input or tnCardHeader projection | ⚠ Mutually exclusive — picking projection means you own the title styling (use class tn-card__title on your <h3> to match library defaults). |
<mat-card-subtitle> | (no equivalent) | No subtitle slot on tn-card. Render inside tnCardHeader projection if needed. |
<mat-card-actions> | [primaryAction] / [secondaryAction] / [footerLink] typed inputs | Typed slot objects (TnCardAction/TnCardFooterLink), not projection. |
<mat-toolbar> | (no equivalent — hold) | Pages don't get a generic toolbar. If this is in-card, fold into tnCardHeader. If page-level, surface to lead. |
<mat-toolbar-row> | (remove) | Header content moves to tnCardHeader; actions become typed slot inputs. |
<mat-divider> | <tn-divider> or [tnDivider] directive | TnDividerComponent for standalone; for inline list separation. |
Buttons & toggles
| Angular Material | @truenas/ui-components | Notes |
|---|
<button mat-button> | <tn-button> | [label] input + (onClick) output. NOT content projection + (click). |
<button mat-raised-button> | <tn-button variant="filled"> | |
<button mat-stroked-button> | <tn-button variant="outline"> | |
<button mat-flat-button> | <tn-button variant="filled" color="default"> | |
<a mat-button [routerLink]> | <tn-button [routerLink]> | tn-button renders a real <a routerLink> in anchor mode ([routerLink]/[href]), so middle-click "open in new tab" and right-click "copy link" are preserved — verified in 0.3.26. ⚠ The test-id prefix still shifts (link-* → button-*): the anchor arm hard-codes tnTestIdType="button", and composeTestId only skips a prefix the base already starts with, so a legacy link-* value cannot be pinned through the testId input. Drop [testId] and put tnTestIdType="link" [tnTestId]="…" on the <tn-button> host instead — the inner anchor then emits no id and the host carries the legacy value verbatim (pools-dashboard, disk-health-card, pool-usage-card do this). See "Test IDs." |
<button mat-icon-button> | <tn-icon-button> | ⚠ Bare icon-only buttons MUST have [ariaLabel] — no accessible name otherwise. |
<button mat-fab> / <button mat-mini-fab> | (no equivalent — hold) | No FAB component. Rework to a primary action button or surface to lead. |
<mat-button-toggle-group> / |
Menus & tooltips
| Angular Material | @truenas/ui-components | Notes |
|---|
<mat-menu> | <tn-menu> | [items] input takes TnMenuItem[]. See ServiceActionsMenuService for the composition pattern (Recipe 2). |
<button mat-menu-item> | <tn-menu-item> | Test IDs resolve to button-*, matching the legacy value — tn-menu-panel renders each item as a <button tnTestIdType="button">, so a legacy button-foo survives from testId="foo". Pass the bare semantic base ([testId]="['expand', name]" → button-expand-<name>). Do NOT edit test.directive.ts. See "Test IDs." |
[matMenuTriggerFor] | [tnMenuTriggerFor] | TnMenuTriggerDirective; same usage shape. |
[matTooltip] | [tnTooltip] | TnTooltipDirective. ⚠ Tooltips are not accessible descriptions on their own — for form controls prefer the [tooltip] input on ix-input/ix-checkbox/etc., reserve [tnTooltip] for hover-only context (disabled-state hints, etc.). |
Form controls
Forms are owned by NAS-141028 — most form-field surfaces stay on the ix-* wrappers
(ix-input, ix-select, ix-checkbox, ix-chips, ix-fieldset). The tn-* form
primitives below are for non-form display surfaces (toolbar filters, selection cards,
read-only views) unless a feature-ticket explicitly carries ix-* work.
| Angular Material | @truenas/ui-components | Notes |
|---|
<mat-form-field> / <input matInput> | (use ix-input — NAS-141028 owns) | Library does export TnFormFieldComponent/TnInputComponent/TnInputDirective, but feature tickets keep ix-*. |
<mat-hint> / <mat-error> | (use ix-input hint/error inputs) | Same — owned by ix-forms. |
<mat-select> / <mat-option> | <tn-select> (non-form) or <ix-select> (forms) | ⚠ tn-select has no [required] input — required indicator is silently dropped. ⚠ No [ariaLabelledby]; use [ariaLabel] for accessible name. For form contexts, keep ix-select. |
<mat-select-trigger> | (use tn-select's [displayWith] if available, else hold) | Verify against d.ts; the trigger-template pattern may not be supported. |
<mat-autocomplete> / [matAutocomplete] | <tn-autocomplete> | TnAutocompleteComponent/TnAutocompleteHarness. Verify input shape against d.ts. |
<mat-checkbox> | <tn-checkbox> (non-form) or <ix-checkbox> (forms) | TnCheckboxComponent/TnCheckboxLabelDirective. Forms keep ix-checkbox. |
<mat-radio-group> / <mat-radio-button> | <tn-radio-group> + <tn-radio> (non-form) or <ix-radio> (forms) | TnRadioGroupComponent/ own the value for the whole set — never bind one form control to several standalone s, which renders stale. Pass ( — webui's fits) or project children; replaces ; for object values. Inside a the group is a normal control (name, required indicator, error text). ⚠ — gives no per-option test-id override, and the legacy ids can only be preserved per . See "Radio groups" under Test IDs. |
Navigation
| Angular Material | @truenas/ui-components | Notes |
|---|
<mat-tab-group> / <mat-tab> | <tn-tabs> / <tn-tab> + <tn-tab-panel> | TnTabsComponent/TnTabComponent/TnTabPanelComponent. Verify input shapes against d.ts before swap — the tabs API often differs in subtle ways. |
<mat-tab-nav-panel> / <mat-tab-link> | (no direct equivalent) | Tab-nav (link-based) is not 1:1 mapped; use <tn-tabs> if appropriate or hold. |
<mat-stepper> / <mat-step> | <tn-stepper> / <tn-step> | TnStepperComponent/TnStepComponent. Verify against d.ts; horizontal/vertical mode may be expressed differently. |
<mat-vertical-stepper> / <mat-horizontal-stepper> | <tn-stepper> with orientation input | Verify input name. |
Tables, lists, trees
| Angular Material | @truenas/ui-components | Notes |
|---|
<mat-table> etc. | <tn-table> | See Recipe 6. tn-table is intentionally smaller surface than ix-table — verify every input/output against d.ts. ⚠ .tn-table__* classes are NOT public; any ::ng-deep into them requires a // TEMP marker + library follow-up. |
[matSort] / [mat-sort-header] | [sortable] on *tnColumnDef + (sortChange) | Built into tn-table's column-def directive. |
[matColumnDef] | *tnColumnDef | Structural directive on <ng-container> with <ng-template tnHeader> / <ng-template tnCell>. |
<mat-paginator> | <tn-table-pager> | TnTablePagerComponent/TnTablePagerHarness. Use TN_TABLE_PAGER_LABELS provider for i18n (replacement for MatPaginatorIntl); default labels in TN_TABLE_PAGER_DEFAULT_LABELS. |
<mat-list> / <mat-list-item> | <tn-list> / <tn-list-item> | Plus TnListItemTitleDirective/TnListItemPrimaryDirective/TnListItemSecondaryDirective/TnListItemLineDirective/TnListItemTrailingDirective/TnListAvatarDirective/TnListIconDirective/TnListSubheaderComponent for slots. |
<mat-nav-list> | <tn-list> with [routerLink] on items | No dedicated nav-list — use list + per-item routerLink. |
<mat-selection-list> / <mat-list-option> | / |
Feedback & overlays
| Angular Material | @truenas/ui-components | Notes |
|---|
<mat-progress-bar> | <tn-progress-bar> or <tn-particle-progress-bar> | TnProgressBarComponent for standard; TnParticleProgressBarComponent for the animated variant. |
<mat-progress-spinner> / <mat-spinner> | <tn-spinner> or <tn-branded-spinner> | TnSpinnerComponent / TnBrandedSpinnerComponent. |
MatSnackBar (service) | TnToastService (via SnackbarService — NAS-141027 owns) | ⚠ Don't call TnToastService directly from feature pages — go through SnackbarService. Known a11y gap: no politeness input — error() no longer announces assertive. |
MatDialog (service) | (call DialogService — NAS-141022 owns) | Library exports TnDialog/TnDialogShellComponent/TnConfirmDialogComponent, but feature tickets call DialogService, not the library directly. |
<mat-dialog-content> / <mat-dialog-actions> / [matDialogClose] | (via DialogService — NAS-141022 owns) | |
<mat-bottom-sheet> | (no equivalent — hold) | Surface to lead; bottom-sheet pattern not present in tn-*. |
info-message notice <div> | <tn-banner> | See Recipe 4. Plus TnBannerActionDirective for action buttons inside a banner. |
Indicators
| Angular Material | @truenas/ui-components | Notes |
|---|
<mat-icon> | <tn-icon> | webui already migrated; always tn-icon, never ix-icon. |
<ix-empty [conf]> | <tn-empty> | See Recipe 3. Inline icon/iconLibrary/[title]; drop the *EmptyConfig constant. ⚠ No iconSize input — use the sanctioned ::ng-deep workaround. |
Library-only (no Material counterpart)
These tn-* components have no Material equivalent in webui but may be appropriate for new
work or pattern replacements. Listed so the conformance agent recognizes them as valid
surface area:
TnSidePanelComponent + TnSidePanelActionDirective + TnSidePanelHeaderActionDirective — see Recipe 5.
TnFilePickerComponent / TnFilePickerPopupComponent — file picker.
TnKeyboardShortcutComponent + TnKeyboardShortcutService — shortcut display & registration.
TnConfirmDialogComponent — confirmation dialog body (consumed via DialogService).
CDK / shared infra — keep
The @angular/cdk/* packages are not migrated (they're framework-level primitives, not
Material UI). Leave these alone:
cdkTrapFocus, cdkAriaLive, Overlay, OverlayRef (a11y/overlay primitives — the library uses them internally too)
cdkScrollable, CdkVirtualScrollViewport
cdkDrag, cdkDropList
Portal, CdkPortalOutlet
Specs / harnesses
| Angular Material harness | @truenas/ui-components harness | Notes |
|---|
MatButtonHarness | TnButtonHarness | .with({ text }) → .with({ label }). |
MatIconHarness | TnIconHarness | |
MatCheckboxHarness | TnCheckboxHarness | |
MatRadioHarness | TnRadioHarness | |
MatSlideToggleHarness | TnSlideToggleHarness | |
MatSelectHarness | TnSelectHarness | |
MatAutocompleteHarness | TnAutocompleteHarness | |
MatMenuHarness | TnMenuHarness | |
MatTableHarness / MatHeaderCellHarness etc. | TnTableHarness | Smaller surface — see Recipe 6 for the full method list. |
MatPaginatorHarness | TnTablePagerHarness | |
MatTabGroupHarness | TnTabsHarness / TnTabHarness / TnTabPanelHarness | |
MatExpansionPanelHarness | TnExpansionPanelHarness | |
MatFormFieldHarness | TnFormFieldHarness | (rare — forms keep IxFormHarness) |
MatInputHarness | TnInputHarness | (rare — forms keep IxInputHarness) |
MatDatepickerInputHarness | TnDateInputHarness / TnDateRangeInputHarness |
Keep EmptyService (used by data providers) — only EmptyComponent is replaced.
Known upstream defects (worked around in webui)
Defects observed in the pinned @truenas/ui-components (package.json currently ~0.3.26).
Every workaround in webui should carry a TEMP (NAS-141021) marker and a "drop once …" condition
so this table stays the single index for the cleanup pass. Check this list before inventing a new
workaround — and add to it when you find a new one, rather than leaving the knowledge in one
component's comment.
| Defect | Symptom | Workaround | Drop when |
|---|
tn-checkbox does not stopPropagation() the inner <input>'s native change | Ivy invokes a (change) binding for both the component output and the bubbled DOM event, so handlers fire twice — the second time with an Event, not the boolean | Guard with isTnCheckboxChange(event) (modules/forms/ix-forms/utils/tn-checkbox-change.utils.ts; call site: pool-warnings.component.ts). Handlers that ignore the payload entirely (e.g. services.component.html's enableToggle(row)) fire twice with no signal at all — check these | Fixed in 0.4.x; drop once the dependency range moves past 0.4.0. tn-radio already stops it in 0.3.26 |
tn-select's writeValue unwraps an array to its first element in single-select mode | An array-valued option can never be written back into the control | Model the value as an object, not a tuple (see size-and-type.interface.ts) | Library preserves array values in single-select |
tn-card declares no host display, so it defaults to inline | An inline box wrapping block content fragments, each painting the host background — stray rectangles above/below the card | tn-card-block-host mixin in mixins/cards.scss | Library sets a block host display |
.tn-card__title is style-scoped to tn-card | A consumer projecting an adornment next to the title can't lay the row out without ::ng-deep | tn-card-inline-title / tn-detail-card mixins in mixins/cards.scss | Library exposes a styled-title projection slot |
tn-card header/footer dividers use the unmapped --tn-lines token | Divider falls back to a light colour that reads wrong in the dark theme | tn-detail-card repoints them at webui's --lines |
Recipe 1 — Card (mat-card → tn-card)
tn-card is declarative: the toolbar row disappears and its contents become inputs.
Four header patterns — pick exactly one
The library's <tn-card> template renders its header from this slot:
<div class="tn-card__header">
<div class="tn-card__header-left">
<ng-content select="[tnCardHeader]" />
@if (!projectedHeader() && title()) {
<h3 class="tn-card__title">{{ title() }}</h3> <!-- LIBRARY-OWNED -->
}
</div>
@if (hasHeaderRight()) {
<div class="tn-card__header-right">
@if (headerStatus()) { … }
@if (headerControl()) { <tn-slide-toggle … /> }
@if (headerMenu()) { <tn-icon-button [tnMenuTriggerFor]=… /> }
</div>
}
</div>
⚠ Projecting [tnCardHeader] suppresses the library's <h3 class="tn-card__title">.
You cannot combine [title] + [tnCardHeader] and get both — the library only renders
its own <h3> when no projection is present. This is the audit-page Event Data card
trap: the migration projected a custom <h3 class="card-title"> with no styles,
inherited browser-default h3 margins, and the divider drifted.
The four valid patterns:
A. Text-only title + typed right-side slots. Simplest. Use when the header is just a
title plus an optional status badge, slide toggle, or kebab menu — nothing else.
<tn-card
padding="small"
[title]="'Metadata' | translate"
[headerStatus]="serviceStatus()"
[headerMenu]="serviceMenu()"
>
<!-- body -->
</tn-card>
B. Custom projection with library title styling. Use when you need a trailing element
the typed slots don't cover (copy button, custom action, link icon next to title). Apply
class tn-card__title to your <h3> so it matches the library default; do not redo the
flex layout — .tn-card__header is already display: flex; justify-content: space-between.
<tn-card padding="small">
<h3 tnCardHeader class="tn-card__title">{{ 'Event Data' | translate }}</h3>
<ix-copy-button tnCardHeader [text]="yaml()"></ix-copy-button>
<!-- body -->
</tn-card>
Both projected nodes match [tnCardHeader] and land in .tn-card__header-left; the
library's outer flex separates them.
C. Title-link projection (shares-dashboard service-card pattern). Use when the title
is a navigation link with a trailing icon, paired with typed right-side slots.
<tn-card
padding="small"
[bordered]="true"
[headerStatus]="serviceStatus()"
[headerMenu]="serviceMenu()"
[headerMenuTriggerTestId]="headerMenuTriggerTestId()"
[primaryAction]="addAction()"
[secondaryAction]="openAction()"
>
<a tnCardHeader class="card-title-link" [routerLink]="…" [tnTestId]="[…]" tnTestIdType="link">
<h3 class="tn-card__title">{{ 'Title' | translate }}<tn-icon … /></h3>
</a>
<!-- body -->
</tn-card>
SCSS for this pattern: .card-title-link { color: inherit; display: inline-flex; text-decoration: none; }. Use class tn-card__title (library) over a local .card-title
where possible — drift between local and library title styling is the recurring source
of divider/height inconsistencies.
D. No header at all. Don't set [title], don't project tnCardHeader, don't set
the right-side slots. hasHeader() returns false and the entire .tn-card__header (and
its divider) are not rendered.
Footer slots
The footer mirrors the header — typed inputs only, no projection slot. Use [primaryAction]
(filled button), [secondaryAction] (outline button), [footerLink] (text-button link).
The library renders .tn-card__footer with its own top divider only when at least one
footer input is set. Don't hand-roll a footer <div> inside the card body.
Imports & SCSS
MatCard/MatToolbarRow imports → TnCardComponent, TnCardHeaderDirective.
- Delete
mat-card { height: 100% } SCSS.
- Do not redefine
.tn-card__title styling locally — use the library class. The only
sanctioned local card classes are .card-title-link (for pattern C) and any custom
body styles.
- Do not call the legacy
details-card() mixin from src/assets/styles/mixins/cards.scss
on a tn-card host — it targets .mat-mdc-card-title / mat-card-header / mat-card-content
internals and silently no-ops against <tn-card>.
Recipe 2 — Imperative → declarative signals
Toolbar buttons and status badges driven by | async become computed() signals typed to
the tn-card input contract. Convert the source observable with toSignal():
service$ = this.store$.select(selectService(ServiceName.Cifs));
private service = toSignal(this.service$);
private hasAddRole = toSignal(this.authService.hasRole(this.requiredRoles), { initialValue: false });
protected serviceStatus = computed<TnCardHeaderStatus | undefined>(() => {
const svc = this.service();
if (!svc) { return undefined; }
// map ServiceStatus → { label, type: 'success' | 'neutral' | 'warning', testId }
});
protected addAction = computed<TnCardAction | undefined>(() => {
if (!this.hasAddRole()) { return undefined; } // role gating replaces *ixRequiresRoles
return { label: this.translate.instant('Add'), testId: 'button-...-add', handler: () => this.openForm() };
});
- A role-gated action returns
undefined when the role is absent — this replaces
*ixRequiresRoles on the old <button>.
- Import the input types:
TnCardAction, TnCardHeaderStatus, TnMenuItem.
Translated strings inside a signal
Moving a string from the template into a computed() loses the translate pipe's language
tracking: instant() reads the translations current at first evaluation and the signal never
re-runs. Prefer keeping the pipe — [description]="messageKey | translate" — whenever the
string goes straight into a tn-* input. When it genuinely has to be composed in TypeScript
(several keys folded into one string, a labels object handed to a library token, an instant()
used as an interpolation parameter), build it with translated()
(app/helpers/translated.helper.ts) rather than re-deriving the
toSignal(translate.onLangChange) idiom per component.
Shared service-menu builders
Service cards build their headerMenu from ServiceActionsMenuService
(shares-dashboard/service-extra-actions/service-actions-menu.service.ts). Compose
TnMenuItem[] from its granular builders (buildToggleItem, buildSessionsItem,
buildLogsItem, …) rather than re-implementing menu logic. When a card needs a custom item
(e.g. opening config in a local side panel), substitute just that one item.
The serviceStatus mapper (ServiceStatus → TnCardHeaderStatus) must use the same
mapping across all service cards: Running → success, Stopped → neutral, anything
else → warning. Prefer a shared builder over copy-pasting the switch — divergence on
the default branch is an easy, silent inconsistency.
Recipe 3 — Empty state (ix-empty → tn-empty)
<!-- before --> <ix-empty [conf]="emptyConfig"></ix-empty>
<!-- after --> <tn-empty icon="smb-share" iconLibrary="custom" [title]="'...' | translate"></tn-empty>
Inline the icon/title from the old *EmptyConfig constant, then delete the constant import
and the component field. EmptyComponent import → TnEmptyComponent.
Known gap — empty-state icon size. tn-empty has no iconSize input yet, so the icon
renders at the inline ~24px scale — too small for a card empty state. The pilot works
around this with one shared block in shares-dashboard.component.scss:
// TEMP: until @truenas/ui-components ships the tn-empty `iconSize` input.
:host ::ng-deep tn-empty tn-icon { width: 56px; height: 56px; font-size: 56px; }
If your area needs sized empty-state icons, reuse that exact selector — do not invent a
different one. Keep the // TEMP marker; it is removed in favour of [iconSize] once the
library ships the input. This is the only sanctioned ::ng-deep into a tn-* internal.
Recipe 4 — Banner (info-message notice → tn-banner)
<tn-banner
class="clickable"
role="button" tabindex="0"
[heading]="'WebShares unavailable' | translate"
[message]="'WebShare service requires TrueNAS Connect...' | translate"
(click)="openDialog()"
(keydown.enter)="openDialog()"
(keydown.space)="openDialog(); $event.preventDefault()"
></tn-banner>
Keep the role/tabindex/keyboard handlers. tn-banner adds a [heading] — write
concise heading copy; the old single-line message becomes [message]. The inner
tn-icon/<span> are dropped — tn-banner renders its own icon and message. aria-live
is dropped on the assumption that tn-banner emits its own live-region announcement —
verify this on first use with a screen reader. If it does not, file a library bug and
add a wrapping aria-live="polite" element back until fixed; a silent banner is a real
regression for screen-reader users.
Recipe 5 — SlideIn form → tn-side-panel (dual-host)
This is the subtle one. A form previously opened only via SlideIn must work both
hosted in a tn-side-panel and via the legacy SlideIn (other call sites still use it
until NAS-141067/NAS-141030 land). Make the form host-agnostic:
Form component (service-*.component.ts):
// Optional: present via legacy SlideIn host, absent inside <tn-side-panel>.
slideInRef = inject(SlideInRef<undefined, boolean>, { optional: true });
readonly closed = output<boolean>(); // emitted to a tn-side-panel host
readonly isFormLoading = signal(false); // public — host may read it
private formStatus = toSignal(
this.form.statusChanges.pipe(startWith(this.form.status)),
{ initialValue: this.form.status },
);
readonly canSubmit = computed(() => this.formStatus() === 'VALID' && !this.isFormLoading());
constructor() {
this.slideInRef?.requireConfirmationWhen(() => of(this.form.dirty)); // note ?.
}
submit(): void { this.onSubmit(); } // public entry point for the host
private close(saved: boolean): void {
if (this.slideInRef) { this.slideInRef.close({ response: saved }); }
else { this.closed.emit(saved); }
}
Inject SlideInRef with the inject(SlideInRef<…>, { optional: true }) call form shown
above — not inject<SlideInRef<…>>(SlideInRef, …). Both compile; standardize on the
first so the codebase stays consistent. Replace every slideInRef.close({ response }) with
this.close(...). Members the host reads through its viewChild reference (submit,
canSubmit, closed) must be public.
Form template (service-*.component.html): the <mat-card><mat-card-content> wrapper
is removed so <form> is top-level; <ix-modal-header> is kept but gated for the legacy
host only:
@if (slideInRef) {
<ix-modal-header [requiredRoles]="requiredRoles" [title]="'SMB' | translate" [loading]="isFormLoading()" />
}
<form class="ix-form-container" [formGroup]="form" (submit)="onSubmit()">
<!-- fieldsets ... -->
<ix-form-actions>
@if (slideInRef) {
<tn-button
*ixRequiresRoles="requiredRoles" color="primary"
[testId]="'save'" [label]="'Save' | translate"
[disabled]="form.invalid || isFormLoading()" (onClick)="onSubmit()"
></tn-button>
}
<!-- non-Save actions (e.g. an Advanced Settings toggle) stay UNgated -->
</ix-form-actions>
</form>
The in-form Save tn-button is gated to the legacy host — the tn-side-panel host renders
its own Save in the panel footer (next snippet), so an ungated in-form Save would render
twice. Any other form actions stay ungated.
Host card (the tn-side-panel): placed after </tn-card>. The form is referenced via
viewChild; the Save button lives in the panel footer slot:
<tn-side-panel [title]="'SMB' | translate" [(open)]="configOpen">
@if (configOpen()) {
<ix-service-smb (closed)="onConfigClosed()"></ix-service-smb>
}
@if (configForm(); as form) {
<tn-button
tnSidePanelAction color="primary"
[testId]="'save'" [label]="'Save' | translate"
[disabled]="!form.canSubmit()" (onClick)="form.submit()"
></tn-button>
}
</tn-side-panel>
protected configOpen = signal(false);
protected configForm = viewChild(ServiceSmbComponent);
protected onConfigClosed(): void { this.configOpen.set(false); }
The menu's "Config Service" item action becomes () => this.configOpen.set(true) instead
of slideIn.open(...).
A11y — focus management. When the panel opens, focus must move into it; when it
closes, focus must return to the trigger element. Escape must close the panel. These are
tn-side-panel's responsibility — the legacy SlideIn host had them built in, do not
silently regress. Verify each migrated panel on first use; if any of the three is missing,
file a library bug rather than papering over it with imperative focus calls.
Recipe 6 — Table (mat-table → tn-table)
tn-table is intentionally a smaller surface than ix-table — verify every input/output
you use against node_modules/@truenas/ui-components/types/truenas-ui-components.d.ts.
What's available in 0.1.60:
- Inputs:
dataSource, displayedColumns, trackBy, emptyMessage, emptyIcon,
selectable, expandable, bordered, activeRow, activeBg, activeIndicator,
loading, loadingMessage, clickable.
- Outputs:
sortChange, selectionChange, rowClick.
- Column defs:
<ng-container *tnColumnDef="name" [width] [sortable]> with
<ng-template tnHeader> / <ng-template tnCell> for header and cell content.
<tn-table
[dataSource]="dataProvider().rows"
[displayedColumns]="displayedColumns"
[loading]="dataProvider().isLoading"
[emptyMessage]="emptyMessage()"
[emptyIcon]="emptyIcon()"
[activeRow]="selectedRowIndex()"
[clickable]="true"
(rowClick)="onRowClick($event)"
(sortChange)="onSortChange($event)"
>
<ng-container *tnColumnDef="'username'" [width]="'30%'">
<ng-template tnHeader>{{ 'User' | translate }}</ng-template>
<ng-template tnCell let-row>{{ row.username }}</ng-template>
</ng-container>
<!-- ... -->
</tn-table>
- Row interaction. Prefer
(rowClick) for navigation/details; use
[selectable] + (selectionChange) for multi-select. Do not wrap rows in a <button> —
tn-table handles row roles internally.
- Column widths. Use
[width] on tnColumnDef, not CSS. If you need fixed
table-layout or cell-wrap behaviour the library doesn't expose, a ::ng-deep tn-table { ... } block is permitted but must carry a // TEMP marker and a library
follow-up reference (same convention as the tn-empty icon-size workaround in
Recipe 3). A bare ::ng-deep into tn-table internals is a finding.
- Specs. Use
TnTableHarness — getRowCount, getHeaderTexts, getRowTexts,
getCellText, clickSortHeader, getSortDirection, toggleSelectAll,
toggleRowSelection, isRowSelected, clickRow, pressKeyOnRow, isRowFocusable,
isLoading, isRowActive, getActiveRowIndex, toggleRowExpansion, isRowExpanded,
getDetailRowContent. Do not query tn-table internals with raw CSS — .tn-table__*
classes are not part of the public contract.
Recipe 7 — Button toggle group (ix-button-group → tn-button-toggle-group)
tn-button-toggle-group is content-projection-based and has a smaller input surface than
ix-button-group. Two things to get right:
<span [id]="controllerToggleLabelId" class="visually-hidden-label">
{{ 'Controller Type' | translate }}
</span>
<tn-button-toggle-group
[value]="selectedController()"
[ariaLabelledby]="controllerToggleLabelId"
(valueChange)="selectController($event)"
>
@for (option of controllerOptions; track option.value) {
<tn-button-toggle
[value]="option.value"
[testId]="['controller', option.value]"
>{{ option.label | translate }}</tn-button-toggle>
}
</tn-button-toggle-group>
- Accessible name. No
[label] input. Either [ariaLabel]="'Controller Type' | translate" (self-contained name), or a visible label <span> paired with
[ariaLabelledby] (used when the label is visually present on screen). Don't ship
without one.
- Per-instance label IDs. If you use
[ariaLabelledby] and the same component can
instantiate more than once, generate a unique id per instance — otherwise
aria-labelledby resolves to the wrong DOM node. Audit's pattern (a module-scope
counter incremented in a class field initializer; see audit.component.ts) is one
way; crypto.randomUUID() is another.
- Options are children, not an
[options] array. Use @for with <tn-button-toggle>
children. The previous IxButtonGroupComponent auto-synthesized per-option test IDs
from [name] + option.value; with tn-button-toggle you set [testId] on each toggle
yourself (a single token or an array base for the option). tn-button-toggle declares the
button-toggle prefix, so pass the bare semantic base — the library composes the rest. Do
NOT add [ixTest] or touch test.directive.ts.
Test IDs — use the library directive, do not drop values
webui automated tests select on data-test. The component library now owns test-id
composition via the [tnTestId] directive — this supersedes webui's homegrown [ixTest]
directive, which the Epic is phasing out. As you migrate a component, move its test IDs onto
the library mechanism; don't leave [ixTest] behind.
How the library composes an ID. Each tn-component hosts TnTestIdDirective internally and
declares its own element-type prefix (tnTestIdType). You pass ONLY the semantic base through
the component's testId input, and the library assembles ${type}-${base}, kebab-cased:
<!-- emits data-test="button-save": tn-button declares the "button" prefix -->
<tn-button [testId]="'save'">Save</tn-button>
<!-- plain element: declare the prefix yourself via tnTestIdType -->
<li [tnTestId]="['username', option.value]" tnTestIdType="option"></li>
Verified prefixes: tn-button/tn-icon-button → button, tn-menu-item → button (the
owning tn-menu-panel renders each item as a <button tnTestIdType="button">), tn-card
title link / [footerLink] → link, tn-select → select (options option),
tn-checkbox → checkbox, tn-radio → radio, tn-radio-group → radio-group,
tn-slide-toggle → toggle, tn-input → input (textarea textarea),
tn-button-toggle → button-toggle. Exception: tn-table, tn-tree, tn-selection-list,
tn-calendar are not yet typed — they write testId verbatim, so pass the full value
(prefix included) on those.
Three properties make this safe:
- Kebab-parity with the legacy directive for a static base, so it resolves
byte-identically (
sshPort → ssh-port). It is not parity for values carrying
digits: the library does not split a letter→digit boundary the way lodash does, so
eth0 stays eth0 where [ixTest] produced eth-0. Pre-normalize any dynamic
value with normalizeTestIdString / normalizeTestIdParts
(app/modules/test-id/normalize-test-id.utils) before handing it to a testId input.
- Idempotent prefix — a base that already starts with its prefix is not doubled
(
button-save stays button-save). The migration is therefore order-independent, but
pass the bare semantic base ('save', not 'button-save') — let the component supply
the prefix.
- Form controls fall back to the bound control name.
tn-input, tn-select,
tn-checkbox, tn-radio, tn-slide-toggle, tn-autocomplete and tn-file-picker route
testId through the library's controlTestId(), which reads formControlName (or a named
[formControl]/ngModel) when no explicit testId is given. So <tn-input formControlName="sshPort"> emits input-ssh-port with no testId at all — matching what
ix-input's [ixTest]="controlDirective.name" produced. Passing an explicit testId on a
bound control is redundant, not required; it is only needed to override the control name.
- Options key off the value, not the label.
tn-select/tn-autocomplete derive an option's
id from its value, where [ixTest] used the label — so any select whose two differ
silently renames every option id. Pin it back with the shared callbacks in
modules/forms/ix-forms/constants/tn-select-option-test-id.constant.ts
(, or when the legacy id
had collapsed case/spaces the label still carries). Only write a bespoke callback when the id
comes from neither.
Attribute name (required once, at the app root). { provide: TN_TEST_ATTR, useValue: 'data-test' } makes the library write data-test instead of its default data-testid.
Without this provider every tn-component testId lands on the wrong attribute and every e2e
selector misses. This provider is part of the webui-side rollout and may not be wired yet —
grep -rn 'TN_TEST_ATTR' src and add it if absent before relying on tn-component test IDs.
App-root label providers (0.7.x). The library resolves the copy it renders itself through DI
tokens, so webui wires each one once in main.ts (and mirrors it in setup-jest.ts so specs read
what the app renders) instead of binding the same string on every call site:
| Token | webui provider | Covers |
|---|
TN_SELECT_LABELS | provideTnSelectLabels() | tn-select placeholder, empty-dropdown message, "select all" |
TN_FALLBACK_LABELS | provideTnFallbackLabels() | accessible names for an unnamed tn-spinner, tn-progress-bar, tn-dialog-shell, tn-side-panel, tn-drawer |
TN_TABLE_PAGER_LABELS | provideTnTablePagerLabels() | pager copy |
TN_FORM_FIELD_ERRORS | provideTnFormFieldErrors() | validation messages |
TN_CALENDAR_INTL | provideTnCalendarIntl() | calendar copy |
An input on a particular instance still wins, which is how a field or surface with its own wording
opts out — and for TN_FALLBACK_LABELS an [ariaLabel] that says what is loading is still the
better answer. Without the fallback provider the library warns in dev mode on every unnamed
instance, and jest-fail-on-console turns that warning into a spec failure.
Phasing out [ixTest]. On a migrated element, [ixTest] is the wrong mechanism:
- On a
<tn-*> component, use its [testId] input — not [ixTest].
- On a surviving plain element, use
[tnTestId] (+ tnTestIdType for a prefix).
- Do not edit
test.directive.ts element-type mappings — that legacy workaround is
obsolete now that the library owns prefixes. If a prefix must differ, set the component
testId/tnTestIdType.
The trap: when an element disappears (a toolbar <button> becomes a TnCardAction, a
<button mat-menu-item> becomes a TnMenuItem), it no longer carries a test-id directive.
Carry the value forward via the component input — testId on TnCardAction /
TnMenuItem, headerMenuTriggerTestId on tn-card. The resolved data-test must equal what
[ixTest] produced before. ServiceActionsMenuService.menuItemTestId() is the reference for
menu-item IDs — reuse it, don't hand-roll the string.
Element-prefix mutations to watch for. Because the prefix is owned by the element/component
type, changing the type changes the resolved value even when the base is identical:
-
<a mat-button [ixTest]="'foo'"> resolved to link-foo. <tn-button [testId]="'foo'">
resolves to button-foo — tn-button declares button on all three arms, including the two
that render an <a>. There is no escape hatch through the component: passing
testId="link-foo" yields button-link-foo, because composeTestId's idempotent guard only
skips a prefix the base already starts with. So if a legacy link-* selector is referenced
anywhere, pin it on the <tn-button> host — drop [testId] and add
tnTestIdType="link" [tnTestId]="'foo'" to the host element:
<tn-button tnTestIdType="link" [tnTestId]="'disks'" [routerLink]="['/storage', 'disks']" …>
TnTestIdDirective matches any element, so the host resolves to link-disks, and with no
testId input the inner <a> composes an empty id and writeTestId removes the attribute —
exactly one data-test in the DOM, identical to what <a mat-button ixTest="disks"> produced.
This keeps the button's appearance and the library's anchor semantics; do not hand-roll an
<a> styled like a button (the .storybook-button classes are encapsulated inside
TnButtonComponent and can't be reused from a consumer). Established in pools-dashboard,
disk-health-card, pool-usage-card. Prefer this over coordinating a rename with whoever owns
the e2e selectors.
-
<button mat-menu-item [ixTest]="'foo'"> resolved to button-foo, and <tn-menu-item [testId]="'foo'"> also resolves to button-foo — the menu-item prefix this playbook
previously documented is not what the library emits. tn-menu-panel renders every item
(projected or from [items]) as <button tnTestIdType="button" [tnTestId]="…">, so menu
items are the one element→component conversion that needs no prefix compensation. Pass
the bare base; do NOT pass (it survives only because of the idempotent
guard) and do NOT edit .
Radio groups need no prefix compensation. <mat-radio-button> mapped to the prefix
radio-button, and since 0.7.0 tn-radio declares the same one — an option identifies a radio
BUTTON inside a radio-group, matching the button-toggle-group/button-toggle pair. The group
itself declares radio-group. Both resolve through controlTestId(), so a bound group needs
nothing at all:
<!-- radio-group-encryption-type on the group, radio-button-encryption-type-<label> per option -->
<tn-radio-group formControlName="encryptionType" [options]="encryptionTypeOptions()"></tn-radio-group>
[options] scopes each option's id by the option label — the same discriminator [ixTest]
used — so it reproduces legacy ids exactly and there is no reason to project <tn-radio> children
just to reach them. Project only when an option needs markup of its own, and give each projected
child its own testId (the group does not reach into content it did not render).
Do not push radio-button into a testId you pass — the library already owns that prefix, and
earlier revisions of this playbook told you to. Legacy call sites carrying
[testId]="['radio-button', …]" still resolve correctly (the idempotent guard absorbs it) but the
segment is dead weight; drop it when you touch them.
Spec / test updates
- Swap harnesses:
MatButtonHarness → TnButtonHarness (.with({ text }) →
.with({ label })); new TnBannerHarness for banners (.with({ textContains: /re/ }),
await banner.getText()). Prefer harnesses over spectator.query('.css-class').
- When a component is deleted (e.g.
ServiceStateButtonComponent,
ServiceExtraActionsComponent), remove it from MockComponents(...) and delete the
import.
- Signal-based
viewChild (e.g. viewChild(ServiceSmbComponent)) needs the real child
rendered for panel tests — don't mock it away if the test exercises the side panel.
MockComponents(...) deep-mocks a standalone child's whole import graph, so mocking a
migrated child also mocks the tn-* primitives the parent itself renders — the parent's own
fields come back blank, and signal queries trip
ng-mocks#8634. Fix with
ngMocks.globalKeep(TnFormFieldComponent) etc. at module scope, above the describe:
ng-mocks reads its global config when the TestBed is configured, which for Spectator happens
inside the beforeEach that createComponentFactory registers, and Jest's per-file module
registry keeps the mutation from reaching another spec. Established in
automated-disk-selection, widget-group-slot-form, widget-group-form,
dashboard.component.spec.ts.
- Run
yarn test src/app/.../file.spec.ts per file; yarn lint <file> before commit.
Accessibility — verify per migration
The migration trades baked-in Material a11y for declarative tn-* slot inputs whose a11y
is the library's responsibility. That delegation is silent: a missing aria-label on a
TnCardAction, a tn-banner that doesn't announce, a tn-side-panel that doesn't return
focus — none surface in a compile error or a visual review. Verify per recipe:
- Accessible names. Every interactive
tn-* carries a meaningful [label] or
aria-label. An icon-only tn-button / tn-icon-button MUST have [attr.aria-label]
(or the equivalent component input) — a bare icon button is unusable on a screen reader.
- No element-level a11y silently dropped. When a
<button aria-label="…"> becomes a
TnCardAction / TnMenuItem, the aria value moves into the action object — never
disappears. Diff against git show $(git merge-base master HEAD):<path> if unsure.
- Status mapping is not color-only.
TnCardHeaderStatus carries label text in
addition to type (success / neutral / warning); the label must be meaningful
text — Running / Stopped / etc. — not empty, so status is conveyed without colour.
- Live-region announcements.
tn-banner's dropped aria-live assumes the component
announces. Verify on first use; if not, file a library bug and add a wrapping live
region until fixed.
- Focus management on
tn-side-panel. Opening moves focus into the panel; closing
returns focus to the trigger; Escape closes. Verify per migrated panel.
- Tooltips are not the only description.
[tnTooltip] on a hover surface is not an
accessible description on its own. For form controls, prefer the [tooltip] input on
ix-input / ix-checkbox / etc. (which produces an accessible description) and use
[tnTooltip] only for hover-only context — the disabled-state hint pattern in
nvme-of-configuration is the canonical use.
- Keyboard reachability. Tab through the migrated page: every interactive element is
focusable in source order, focus is visible at every step, Enter/Space activate as
expected.
Complex editors inside a focus-trapped dialog/panel. A built-in focus trap
(tn-side-panel, cdkTrapFocus, or role="dialog") assumes every focusable child
participates in the standard Tab sequence. Editors that capture Tab themselves —
CodeMirror, Monaco, embedded terminals — break that assumption: Tab inside the editor
moves the cursor, and Shift+Tab can escape the trap to background DOM. The audit
migration (NAS-141063) hit this in advanced-search and solved it with a hand-rolled
focus-walker: compareDocumentPosition to find the next/previous focusable element
relative to the editor's host, filtered through CDK InteractivityChecker.isFocusable,
then .focus() directly. See
src/app/modules/forms/search-input/components/advanced-search/advanced-search.component.ts
(moveFocusInDirection) for the canonical implementation. Test the walker behavior with
real DOM focus assertions (document.activeElement) as in
advanced-search/tests/focus-walker.spec.ts. If your migration hosts a CodeMirror-class
editor inside a panel, reuse that pattern rather than reinventing it.
The harness agent (tn-migration-harness) mandates a jest-axe assertion in each
migrated component's spec — that catches a meaningful subset of these automatically. Use
it as the safety net, not the ceiling; keyboard and screen-reader smoke on first use of
each new surface is irreplaceable. (Browser-driven smoke is not currently part of the
review toolchain — Playwright MCP coverage was retired after two consecutive runs were
blocked on dev-VM auth without producing useful findings.)
Per-file verification checklist
Before committing a migrated file, confirm:
Branch & commit conventions
- Branch:
NAS-<ticket> (e.g. NAS-141040).
- Commits:
NAS-<ticket>: <description>, one line, scoped to one component/step.
- The pilot branch
NAS-141074 is the canonical worked example — diff it for any case
this playbook doesn't spell out.