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 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 | Leave as-is. |
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, andSlideInrows in the table above are superseded — migrate them in-place alongside the Material work:
ix-table→tn-table. Use*tnColumnDef+<ng-template tnHeaderCellDef>/<ng-template tnCellDef let-row>. ReuseTableActionsCellComponent/TableToggleCellComponentfromapp/modules/tn-table-cells/.[expandable]="true"+*tnDetailRowDeffor detail rows. No column-selector equivalent. Reference:cron-list,map-user-group-ids-dialog.ix-*form controls →tn-formprimitives.ix-fieldset→<tn-form-section [heading]>(there is notn-fieldset). Wrap each control in<tn-form-field [label] [tooltip] [required]>containingtn-input/tn-checkbox/tn-select.tn-input [multiline]="true"replacesix-textarea;[inputType]="InputType.Number"for numbers.tn-selecttakes a synchronousTnSelectOption[](unwrap observables with| async) and does not translate labels — pre-translate withtranslateOptions(this.translate, …).tn-selecthas no[required]— put the indicator on the wrappingtn-form-field. Controls with notn-*equivalent (ix-explorer,ix-permissions,ix-chips,ix-ip-input-with-netmask,ix-user/group-combobox) stayix-*. Reference:service-ftp,global-config-form.- SlideIn forms →
tn-side-panelvia theSidePanelFormbase class (app/modules/slide-ins/side-panel-form.directive.ts). The formextends SidePanelForm: the base injectsslideInRef{ optional: true }, exposes theclosedoutput, publicsubmit()/hasUnsavedChanges(), andtrackCanSubmit(isLoading)→canSubmit. The subclass providesform+onSubmit()and callsthis.close(saved). Gateix-modal-headerand the in-form Save with@if (slideInRef)(the panel host renders its own footer Save). Forms must self-load their data (the panel host has noSlideInRef); for row-edit add a typedinput()and resolve from either host. Host:<tn-side-panel [title] [(open)] [closeGuard]>+viewChild(FormComponent)+ atnSidePanelActionSave. BuildcloseGuardfromUnsavedChangesService.showConfirmDialog(). Cross-featureslideIn.open(OtherForm)navigations stay on SlideIn. Reference:ntp-servers-card+ntp-servers-form,global-config-form,container-filesystem-device-form.ix-empty→tn-empty(Recipe 3):[title](required),[description],icon+iconLibrary,iconSize(the input now exists in 0.3.7 — no::ng-deepworkaround needed). Inline the*EmptyConfigand drop theEmptyComponentimport.Specs: a
tn-side-panel-hosting parent that mocks a child carrying a signalviewChild()query hits ng-mocks #8634 (bindQueryToSignalreadsundefined). Seed it inbeforeEach:MockInstance(ChildComponent, 'configForm', signal(undefined));.
jest-axeis not installed in webui (absent frompackage.json, 0 specs import it) — the per-spectoHaveNoViolations()mandate does not apply; assert accessible names via harnesses instead. Track at the epic level.Still genuinely off-limits:
DialogService/ dialog hosting (NAS-141022) andSnackbarService(NAS-141027) — keep calling those services; only migrate Material inside a dialog body. Verify all component APIs against the installed.d.ts— the library version moves and this table lags.
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-testselectors — 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; TnDividerDirective for inline list separation. |
<mat-grid-list> / <mat-grid-tile> |
(no equivalent) | Use CSS grid directly. |
<mat-expansion-panel> / <mat-accordion> |
<tn-expansion-panel> |
TnExpansionPanelComponent/TnExpansionPanelHarness. Verify input names against d.ts. |
<mat-sidenav-container> / <mat-sidenav> / <mat-sidenav-content> |
<tn-drawer-container> / <tn-drawer> / <tn-drawer-content> |
⚠ For a page-level side panel hosting a form, use <tn-side-panel> (Recipe 5) instead — drawer is for persistent UI chrome. |
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 accepts [routerLink]/[href] but renders an internal <button>, not <a>. Verify middle-click "open in new tab," right-click context menu, and focus parity. The test-id prefix also shifts (link-* → button-*); 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> / <mat-button-toggle> |
<tn-button-toggle-group> / <tn-button-toggle> |
See Recipe 7. ⚠ No [label] input — must provide [ariaLabel] or [ariaLabelledby]. ⚠ Per-option test IDs are not auto-synthesized; set [testId] per <tn-button-toggle>. |
[matRipple] |
(remove) | Ripple is built into tn-* components where appropriate. Drop the directive. |
[matBadge] / [matBadgeHidden] |
(no equivalent — hold) | No badge component. Use <tn-chip> for static labels, or hold migration on the surface if notification-count semantics are needed. |
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 menu-item-*, not button-* (tn-menu-item declares the menu-item prefix). To preserve a legacy button-foo, pass testId="button-foo" — a per-item testId is written verbatim. 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> (non-form) or <ix-radio> (forms) |
|
<mat-slide-toggle> |
<tn-slide-toggle> (non-form) or <ix-slide-toggle> (forms) |
|
<mat-slider> |
<tn-slider> + [tnSliderThumb] |
Also TnSliderWithLabelDirective. |
<mat-chip-grid> / <mat-chip-row> (input pattern) |
(use ix-chips — NAS-141028 owns) |
|
<mat-chip> (display only) |
<tn-chip> |
TnChipComponent for static display chips. |
<mat-datepicker> / <input matDatepicker> / <mat-datepicker-toggle> |
<tn-date-input> |
TnDateInputComponent plus TnDateRangeInputComponent, TnCalendarComponent, TnCalendarHeaderComponent, TnMonthViewComponent, TnMultiYearViewComponent. Verify against d.ts. |
<mat-calendar> |
<tn-calendar> |
Standalone; pair with <tn-calendar-header> if needed. |
| (none — new surface) | <tn-time-input> |
TnTimeInputComponent — no Material equivalent in webui; available if needed. |
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> (non-form) or <ix-table> (NAS-141029 owns) |
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> |
<tn-selection-list> / <tn-list-option> |
TnSelectionListComponent/TnListOptionComponent. |
<mat-tree> / <mat-tree-node> / <mat-nested-tree-node> |
<tn-tree> / <tn-tree-node> / <tn-nested-tree-node> |
Plus TnTreeFlatDataSource, TnTreeFlattener, TnTreeNodeOutletDirective. |
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 viaDialogService).
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,CdkVirtualScrollViewportcdkDrag,cdkDropListPortal,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 |
|
MatDialogHarness |
TnDialogHarness + TnDialogTesting |
|
OverlayContainerHarness (snackbar) |
TnToastMock + TnToastTesting.providers(...) |
See NAS-141027 spec for the pattern. |
Keep EmptyService (used by data providers) — only EmptyComponent is replaced.
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/MatToolbarRowimports →TnCardComponent,TnCardHeaderDirective.- Delete
mat-card { height: 100% }SCSS. - Do not redefine
.tn-card__titlestyling 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 fromsrc/assets/styles/mixins/cards.scsson atn-cardhost — it targets.mat-mdc-card-title/mat-card-header/mat-card-contentinternals 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
undefinedwhen the role is absent — this replaces*ixRequiresRoleson the old<button>. - Import the input types:
TnCardAction,TnCardHeaderStatus,TnMenuItem.
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 (ix-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-tablehandles row roles internally. - Column widths. Use
[width]ontnColumnDef, 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// TEMPmarker and a library follow-up reference (same convention as thetn-emptyicon-size workaround in Recipe 3). A bare::ng-deepintotn-tableinternals 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 querytn-tableinternals 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 — otherwisearia-labelledbyresolves to the wrong DOM node. Audit's pattern (a module-scope counter incremented in a class field initializer; seeaudit.component.ts) is one way;crypto.randomUUID()is another. - Options are children, not an
[options]array. Use@forwith<tn-button-toggle>children. The previousIxButtonGroupComponentauto-synthesized per-option test IDs from[name]+option.value; withtn-button-toggleyou set[testId]on each toggle yourself (a single token or an array base for the option).tn-button-toggledeclares thebutton-toggleprefix, so pass the bare semantic base — the library composes the rest. Do NOT add[ixTest]or touchtest.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-card title link → link,
tn-menu-item → menu-item, tn-select → select (options option), tn-checkbox →
checkbox, tn-radio → radio, 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.
Two properties make this safe:
- Kebab-parity with the legacy directive (mirrors lodash
kebabCase), so a migrated base resolves byte-identically (sshPort→ssh-port). - Idempotent prefix — a base that already starts with its prefix is not doubled
(
button-savestaysbutton-save). The migration is therefore order-independent, but pass the bare semantic base ('save', not'button-save') — let the component supply the prefix.
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.
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](+tnTestIdTypefor a prefix). - Do not edit
test.directive.tselement-type mappings — that legacy workaround is obsolete now that the library owns prefixes. If a prefix must differ, set the componenttestId/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 tolink-foo.<tn-button [testId]="'foo'">resolves tobutton-foo(tn-button declaresbutton). Intentional anchor → button change, but RE-visible: if a legacylink-*selector is referenced anywhere, pin the full legacy value verbatim on a typeless host or coordinate the rename.<button mat-menu-item [ixTest]="'foo'">resolved tobutton-foo.<tn-menu-item [testId]="'foo'">resolves tomenu-item-foobecausetn-menu-itemdeclares themenu-itemprefix. To preserve the legacybutton-foo, passtestId="button-foo"on the menu item — a per-itemtestIdis written verbatim. Do NOT edittest.directive.ts.
Spec / test updates
- Swap harnesses:
MatButtonHarness→TnButtonHarness(.with({ text })→.with({ label })); newTnBannerHarnessfor banners (.with({ textContains: /re/ }),await banner.getText()). Prefer harnesses overspectator.query('.css-class'). - When a component is deleted (e.g.
ServiceStateButtonComponent,ServiceExtraActionsComponent), remove it fromMockComponents(...)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. - Run
yarn test src/app/.../file.spec.tsper 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]oraria-label. An icon-onlytn-button/tn-icon-buttonMUST 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 aTnCardAction/TnMenuItem, the aria value moves into the action object — never disappears. Diff againstgit show $(git merge-base master HEAD):<path>if unsure. - Status mapping is not color-only.
TnCardHeaderStatuscarrieslabeltext in addition totype(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 droppedaria-liveassumes 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 onix-input/ix-checkbox/ etc. (which produces an accessible description) and use[tnTooltip]only for hover-only context — the disabled-state hint pattern innvme-of-configurationis 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:
- No
mat-*/Mat*left in the template orimportsarray (unless owned by another ticket). - No
@angular/materialimports left in the.ts(unless owned by another ticket). - Every old
[ixTest]/data-testvalue still resolves — re-homed onto a tn-componenttestIdinput or the[tnTestId]directive. None silently dropped or re-prefixed. - No
[ixTest]left on a migratedtn-*/plain element — test IDs use the library mechanism ([testId]/[tnTestId]), and the bare semantic base is passed (no hand-craftedbutton-/link-prefix; the component/tnTestIdTypeowns it). - All visible strings still go through the
translatepipe /TranslateService. -
ChangeDetectionStrategy.OnPushretained; new state is signals/computed, not fields. - Dual-host forms:
slideInRefis{ optional: true },?.used on it,closedoutput present,submit()/canSubmitpublic. - Component spec updated to tn-harnesses and passing.
- a11y: every interactive
tn-*has an accessible name; element-level aria attrs preserved on element→input conversions; new live regions verified (or a library bug filed); side-panel focus return verified. -
yarn lintclean on the file.
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-141074is the canonical worked example — diff it for any case this playbook doesn't spell out.