| name | scaffold-mj-dashboard |
| description | Scaffold a new MJ Explorer dashboard component following all MJ conventions — chrome trio, BaseResourceComponent/BaseDashboard, design tokens, mjButton, modern Angular syntax, NavigationService routing, query-param round-trip, and CI gate compliance. Use when the user wants to create a new dashboard, resource component, or app inside `packages/Angular/Explorer/dashboards/`. |
Scaffold an MJ Explorer Dashboard
This skill generates a complete dashboard component (.ts + .html + .css + feature module updates) following every MJ convention. Code generated by this skill should pass ./.github/scripts/check-css-hex-tokens.sh and ./.github/scripts/check-mj-btn-override.sh cleanly.
Step 1 — Gather requirements (ask the user)
Before generating anything, get clear answers to:
- Dashboard name (e.g. "Scheduling", "Customer Insights") — used as the page title
- File prefix in kebab-case (e.g.
scheduling, customer-insights)
- Driver class name (e.g.
SchedulingDashboard) — used in @RegisterClass
- Icon — a Font Awesome class (e.g.
fa-solid fa-calendar-check). If unsure, suggest one based on the dashboard's purpose.
- Subtitle — short description shown below the title
- Shape — which of the three structures fits:
- A. Single-page — one content area, no tabs (e.g. simple resource viewer)
- B. Tabbed — multiple top-level sections via
<mj-tab-nav> (e.g. Scheduling, AI Analytics)
- C. Sub-page — loaded INSIDE another shell's left-nav (e.g. Admin settings sub-pages). Uses
<mj-page-header-interior> instead of the chrome trio.
- Target directory — usually
packages/Angular/Explorer/dashboards/src/<Name>/. For sub-pages this may be different.
- For shape B (Tabbed) — what are the tab names? (you'll generate child components separately)
If the user is unsure on shape, ask: "Is this a top-level dashboard the user opens from the app switcher, or does it live INSIDE another dashboard's left-nav?"
Step 2 — Generate the component class (.ts)
Shape A (Single-page) — extends BaseDashboard
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { BaseDashboard } from '@memberjunction/ng-shared';
import { RegisterClass } from '@memberjunction/global';
import { ResourceData } from '@memberjunction/core-entities';
@Component({
standalone: false,
selector: 'mj-__PREFIX__-dashboard',
templateUrl: './__PREFIX__-dashboard.component.html',
styleUrls: ['./__PREFIX__-dashboard.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
@RegisterClass(BaseDashboard, '__DRIVER_CLASS__')
export class __CLASS_NAME__Component extends BaseDashboard {
public IsLoading = false;
constructor(private cdr: ChangeDetectorRef) {
super();
}
async GetResourceDisplayName(_data: ResourceData): Promise<string> {
return '__DASHBOARD_NAME__';
}
initDashboard(): void {
}
async loadData(): Promise<void> {
this.IsLoading = true;
try {
} finally {
this.IsLoading = false;
this.cdr.markForCheck();
}
}
}
Shape B (Tabbed) — extends BaseDashboard, with tab state
import { Component, ChangeDetectionStrategy, ChangeDetectorRef, AfterViewInit, OnDestroy, ViewChild } from '@angular/core';
import { BaseDashboard, SharedService } from '@memberjunction/ng-shared';
import { RegisterClass } from '@memberjunction/global';
import { ResourceData } from '@memberjunction/core-entities';
import { TabConfig } from '@memberjunction/ng-ui-components';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
interface __CLASS_NAME__State {
activeTab: string;
}
@Component({
standalone: false,
selector: 'mj-__PREFIX__-dashboard',
templateUrl: './__PREFIX__-dashboard.component.html',
styleUrls: ['./__PREFIX__-dashboard.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
@RegisterClass(BaseDashboard, '__DRIVER_CLASS__')
export class __CLASS_NAME__Component extends BaseDashboard implements AfterViewInit, OnDestroy {
public IsLoading = false;
public ActiveTab = '__FIRST_TAB_KEY__';
private visitedTabs = new Set<string>();
private stateChangeSubject = new Subject<__CLASS_NAME__State>();
public get Tabs(): TabConfig[] {
return [
];
}
constructor(private cdr: ChangeDetectorRef) {
super();
this.setupStateManagement();
}
async GetResourceDisplayName(_data: ResourceData): Promise<string> {
return '__DASHBOARD_NAME__';
}
ngAfterViewInit(): void {
this.visitedTabs.add(this.ActiveTab);
this.cdr.detectChanges();
}
ngOnDestroy(): void {
super.ngOnDestroy();
this.stateChangeSubject.complete();
}
public OnTabChange(tabId: string): void {
this.ActiveTab = tabId;
this.visitedTabs.add(tabId);
setTimeout(() => SharedService.Instance.InvokeManualResize(), 100);
this.emitStateChange();
this.cdr.markForCheck();
}
public HasVisited(tabId: string): boolean {
return this.visitedTabs.has(tabId);
}
private setupStateManagement(): void {
this.stateChangeSubject.pipe(debounceTime(50)).subscribe(state => {
this.UserStateChanged.emit(state);
});
}
private emitStateChange(): void {
this.stateChangeSubject.next({ activeTab: this.ActiveTab });
}
initDashboard(): void {
}
loadData(): void {
if (this.Config?.userState) {
const state = this.Config.userState as Partial<__CLASS_NAME__State>;
if (state.activeTab) {
this.ActiveTab = state.activeTab;
this.visitedTabs.add(state.activeTab);
}
this.cdr.markForCheck();
}
}
}
Shape C (Sub-page) — extends BaseDashboard, no outer chrome
Same component class as Shape A or B. The difference is purely in the template (see Step 3).
Multi-provider note (applies to all shapes)
If the dashboard touches metadata, RunView, RunQuery, etc., do NOT use new Metadata() / new RunView(). Use this.ProviderToUse:
const result = await RunView.FromMetadataProvider(this.ProviderToUse).RunView({
EntityName: 'Users',
ExtraFilter: `Status='Active'`,
ResultType: 'entity_object'
});
BaseDashboard already extends the multi-provider base, so this.ProviderToUse is available.
Step 3 — Generate the template (.html)
Shape A (Single-page) template
<mj-page-layout>
<mj-page-header
Title="__DASHBOARD_NAME__"
Icon="__ICON_CLASS__"
Subtitle="__SUBTITLE__">
<div meta>
</div>
<div actions>
<mj-refresh-button [Loading]="IsLoading" (Clicked)="loadData()"></mj-refresh-button>
</div>
<div toolbar>
</div>
</mj-page-header>
<mj-page-body>
@if (IsLoading) {
<mj-loading></mj-loading>
} @else {
}
</mj-page-body>
</mj-page-layout>
Shape B (Tabbed) template
Use <mj-tab-nav> in the toolbar slot. Use @switch on ActiveTab to render per-tab actions and per-tab body content.
<mj-page-layout>
<mj-page-header
Title="__DASHBOARD_NAME__"
Icon="__ICON_CLASS__"
Subtitle="__SUBTITLE__">
<div meta>
</div>
<div actions>
@switch (ActiveTab) {
@case ('__FIRST_TAB_KEY__') {
<mj-refresh-button (Clicked)="firstTabCmp?.Refresh()"></mj-refresh-button>
}
}
</div>
<div toolbar>
<mj-tab-nav
[Tabs]="Tabs"
[ActiveKey]="ActiveTab"
(TabChange)="OnTabChange($any($event))">
</mj-tab-nav>
@switch (ActiveTab) {
@case ('__FIRST_TAB_KEY__') {
}
}
</div>
</mj-page-header>
<mj-page-body>
@switch (ActiveTab) {
@case ('__FIRST_TAB_KEY__') {
}
}
</mj-page-body>
</mj-page-layout>
Shape C (Sub-page) template — no chrome trio
<mj-page-header-interior
Title="__DASHBOARD_NAME__"
Subtitle="__SUBTITLE__">
<div meta>
</div>
<div actions>
<mj-refresh-button [Loading]="IsLoading" (Clicked)="loadData()"></mj-refresh-button>
</div>
<div toolbar>
</div>
</mj-page-header-interior>
<div class="__PREFIX__-body">
@if (IsLoading) {
<mj-loading></mj-loading>
} @else {
}
</div>
Step 4 — Generate the stylesheet (.css)
Use only design tokens. No hardcoded hex values — the hex-color CI gate will reject the PR otherwise.
:host {
display: block;
height: 100%;
}
.__PREFIX__-body {
padding: var(--mj-space-4);
background: var(--mj-bg-page);
}
Full token reference is in CLAUDE.md under the "Design Token System" section.
Step 5 — Register the component
The dashboard needs to be declared in a feature module so Angular knows about it. Find the appropriate module file based on the dashboard's package — typically:
packages/Angular/Explorer/dashboards/src/<Name>/<name>.module.ts (if creating a new feature module)
- OR an existing module that aggregates related dashboards
Pattern for a per-dashboard feature module:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UIComponentsModule } from '@memberjunction/ng-ui-components';
import { __CLASS_NAME__Component } from './__PREFIX__-dashboard.component';
@NgModule({
declarations: [__CLASS_NAME__Component],
imports: [
CommonModule,
UIComponentsModule,
],
exports: [__CLASS_NAME__Component]
})
export class __CLASS_NAME__Module { }
export function Load__CLASS_NAME__() {
}
If the dashboard is a top-level resource (loaded via the app metadata's DefaultNavItems), also call Load__CLASS_NAME__() from a LoadFeatures() or public-api.ts entry point in the dashboards package so tree-shaking doesn't drop the registration.
Step 5b — Wire agent context + client tools (REQUIRED for every dashboard)
This is now the baseline for all Explorer dashboards (not just Knowledge Hub): one call lights the surface up for both the async chat agent and the realtime co-agent. A surface that skips it leaves the agent blind to its state and unable to act in it. Add this to ngAfterViewInit() (and re-call on meaningful state changes). NavigationService is already injected for routing; reuse it.
ngAfterViewInit(): void {
this.publishAgentContext();
}
private publishAgentContext(): void {
this.navigationService.SetAgentContext(this, {
ActiveTab: this.ActiveTab,
});
this.navigationService.SetAgentClientTools(this, [
{
Name: 'SwitchTab',
Description: 'Switch to a tab on this dashboard',
ParameterSchema: { type: 'object', properties: { tab: { type: 'string' } }, required: ['tab'] },
Handler: async (params) => {
this.OnTabChange(String(params['tab']));
return { Success: true };
},
},
]);
}
Don't add a parallel mechanism — SetAgentContext/SetAgentClientTools are the single surface API. See packages/Angular/Explorer/dashboards/CLAUDE.md for the full rule.
Step 6 — Verify before committing
After generating files, verify:
Patterns to follow as the dashboard grows
These are out of scope for the initial scaffold but worth flagging to the user when relevant:
- Per-tab state cached in setter pattern — see
SchedulingDashboardComponent for reference
- Filter popover with
<mj-filter-panel> — see SchedulingDashboardComponent for JobsFilterFields / JobsFilterValues getters
- Agent context + client tools — REQUIRED for every dashboard (see Step 5b), not just KH-style ones
- Query param round-trip — if the dashboard pushes state to URL, override
OnQueryParamsChanged() to apply it on later changes (back/forward, deep links, tab re-focus)
For each of those, refer to the per-area CLAUDE.md files (packages/Angular/Explorer/dashboards/CLAUDE.md, packages/Angular/CLAUDE.md, packages/Angular/Explorer/CLAUDE.md).