Create a complete feature with ViewModel, View, and tests following {{sharedLib}} MVVM patterns. Use when building new UI features, panels, or components that manage their own state.
This skill scaffolds a complete feature following {{sharedLib}} MVVM architecture: ViewModel + View + Tests.
{{projectName}}.components, alpha.components, etc.)Ask user:
{{projectName}}.components, alpha.components)For UI-only state (no entities):
BaseViewModel@observable stateFor entity management (wraps entities):
BaseViewModelcomputeItemVMsFromItems or createVisualPluginFor features (combines both):
BaseViewModelReference: FRAMEWORK_GUIDE.md - ViewModel Types
Location: {lib}.components/src/lib/{feature}/{FeatureName}ViewModel.ts
Template Structure:
import { IFrameworkServices } from '@{{company}}/framework-api';
import { BaseViewModel } from '@{{company}}/framework-shared-plugin';
import { action, computed, makeObservable, observable, runInAction } from 'mobx';
export class FeatureNameViewModel extends BaseViewModel {
public static class: string = 'FeatureNameViewModel';
// ========== UI State ==========
@observable isLoading: boolean = false;
@observable errorMessage: string | null = null;
@observable selectedId: string | null = null;
constructor(services: IFrameworkServices) {
super(services);
makeObservable(this); // CRITICAL!
}
// ========== Entity ViewModels ==========
// @computed required - derives collection from interactor
@computed get entityVMs(): Record<string, EntityViewModel> {
return this.computeItemVMsFromItems(
'entityVMs',
() => this._interactor.getAll(),
item => {
const vm = new EntityViewModel(this._services);
vm.setEntityId(item.id);
return vm;
}
);
}
// ========== Computed Properties ==========
// @computed required - derives from observables
@computed get selectedVM(): EntityViewModel | null {
return this.selectedId ? this.entityVMs[this.selectedId] ?? null : null;
}
// ========== Actions ==========
@action setSelected(id: string | null): void {
this.selectedId = id;
}
@action async loadData(): Promise<void> {
this.isLoading = true;
try {
const data = await this.fetchData();
runInAction(() => {
this.data = data;
});
} catch (error) {
runInAction(() => {
this.errorMessage = error.message;
});
} finally {
runInAction(() => {
this.isLoading = false;
});
}
}
}
Reference: COOKBOOK_PATTERNS_ENHANCED.md - Complete Feature Template
Location: {lib}.components/src/lib/{feature}/{FeatureName}View.tsx
CRITICAL UI Component Rules:
observer()SelectPortal for Select dropdowns<span>, <button>, <input>)Template Structure:
import { observer } from 'mobx-react';
import {
Button,
Label,
TextInput,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectItemText,
SelectPortal,
SelectViewport,
Card
} from '@{{company}}/{{sharedLib}}-components-shared';
import { FeatureNameViewModel } from './FeatureNameViewModel';
export const FeatureNameView = observer(({
viewModel
}: {
viewModel: FeatureNameViewModel
}) => {
return (
<div className="feature-container">
<Card className="feature-card">
<div className="feature-header">
<Label className="feature-title">Feature Name</Label>
</div>
<div className="feature-content">
{/* Example: Select with Portal */}
<Select
value={viewModel.selectedId ?? ''}
onValueChange={(id) => viewModel.setSelected(id)}
>
<SelectTrigger>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectPortal>
<SelectContent position="popper">
<SelectViewport>
{Object.values(viewModel.entityVMs).map(vm => (
<SelectItem key={vm.id} value={vm.id}>
<SelectItemText>{vm.nameVM.actual()}</SelectItemText>
</SelectItem>
))}
</SelectViewport>
</SelectContent>
</SelectPortal>
</Select>
{/* Action buttons */}
<div className="feature-actions">
<Button
onClick={() => viewModel.loadData()}
disabled={viewModel.isLoading}
>
LOAD DATA
</Button>
</div>
</div>
</Card>
</div>
);
});
Reference: UI_COMPONENT_GUIDELINES.md
Location: {lib}.components/src/lib/{feature}/__tests__/{FeatureName}ViewModel.test.ts
Template Structure:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FeatureNameViewModel } from '../FeatureNameViewModel';
import { IFrameworkServices } from '@{{company}}/framework-api';
describe('FeatureNameViewModel', () => {
let viewModel: FeatureNameViewModel;
let mockServices: IFrameworkServices;
beforeEach(() => {
mockServices = {
logging: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
},
eventBus: {
publish: vi.fn(),
subscribe: vi.fn()
}
} as unknown as IFrameworkServices;
viewModel = new FeatureNameViewModel(mockServices);
});
describe('Initialization', () => {
it('should initialize with default values', () => {
expect(viewModel.isLoading).toBe(false);
expect(viewModel.selectedId).toBeNull();
});
});
describe('Actions', () => {
it('should update selected ID', () => {
viewModel.setSelected('test-id');
expect(viewModel.selectedId).toBe('test-id');
});
});
describe('Computed Properties', () => {
it('should compute selected VM correctly', () => {
// Setup
viewModel.setSelected('test-id');
// Assert
const selected = viewModel.selectedVM;
expect(selected).toBeDefined();
});
});
});
Reference: TESTING_GUIDE.md
If this is a panel or registered display:
Location: {lib}.components/src/lib/{feature}/{FeatureName}Display.tsx
import { registerDisplayInfo } from '@{{company}}/framework-visual-react-shared';
import { useViewModel } from '@{{company}}/framework-visual-react-components';
import { DisplayTypes } from '../displayTypes';
import { FeatureNameViewModel } from './FeatureNameViewModel';
import { FeatureNameView } from './FeatureNameView';
registerDisplayInfo({
id: DisplayTypes.FeatureName,
tags: [],
visible: true,
ordinal: 100,
Renderer: (props) => {
const viewModel = useViewModel(FeatureNameViewModel);
return <FeatureNameView viewModel={viewModel} {...props} />;
}
});
export default {}; // REQUIRED for display files
Add to DisplayTypes enum:
export enum DisplayTypes {
// ... existing
FeatureName = 'FeatureName'
}
Reference: DISPLAY_REGISTRATION_GUIDE.md
Update {lib}.components/src/index.ts:
export * from './lib/{feature}/{FeatureName}ViewModel';
export * from './lib/{feature}/{FeatureName}View';
If display registered, also export display file:
export * from './lib/{feature}/{FeatureName}Display';
Location: {lib}.components/src/lib/{feature}/{FeatureName}.css
Use existing patterns:
.feature-container {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.feature-card {
padding: var(--spacing-lg);
background: var(--neutral2);
border: 1px solid var(--neutral4);
}
.feature-header {
margin-bottom: var(--spacing-md);
}
.feature-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--neutral12);
}
.feature-content {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.feature-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
}
Reference: CSS_GUIDANCE.md
# Check for errors
./tools/build-helpers/count-client-errors.sh
./tools/build-helpers/show-client-errors.sh 10
# Run tests
npm test {FeatureName}
// ViewModel
@action applyChanges(): void {
this.selectedVM?.publishLocal(); // Commit all local changes
}
@action cancelChanges(): void {
this.selectedVM?.clearLocal(); // Discard all local changes
}
@computed get hasUnsavedChanges(): boolean {
return this.selectedVM?.hasLocalChanges ?? false;
}
Reference: PROPERTY_VIEWMODEL_GUIDE.md - Local Changes Pattern
@computed get entityVMs(): Record<string, EntityViewModel> {
return this.computeItemVMsFromItems(
'entityVMs',
() => this._interactor.getAll(),
item => {
const vm = new EntityViewModel(this._services);
vm.setEntityId(item.id);
return vm;
}
);
}
@observable searchTerm: string = '';
@computed get filteredVMs(): EntityViewModel[] {
const all = Object.values(this.entityVMs);
if (!this.searchTerm) return all;
const term = this.searchTerm.toLowerCase();
return all.filter(vm =>
vm.nameVM.actual()?.toLowerCase().includes(term)
);
}
Reference: MOBX_ESSENTIALS.md
makeObservable(this) called in constructor@observable@action@computed (required for reactivity)@computed (prevents re-running config)runInAction after awaitobserver()Reference: COMMON_PITFALLS.md
makeObservable(this)observer()runInAction after awaitSelectPortal for dropdowns{lib}.components/src/lib/{feature}/
├── {FeatureName}ViewModel.ts # MobX state management
├── {FeatureName}View.tsx # React UI component
├── {FeatureName}Display.tsx # Display registration (optional)
├── {FeatureName}.css # Styles (optional)
└── __tests__/
└── {FeatureName}ViewModel.test.ts