Create Feature Component
This skill scaffolds a complete feature following {{sharedLib}} MVVM architecture: ViewModel + View + Tests.
When to Use
- •Creating a new UI feature or panel
- •Building a settings or configuration component
- •Adding a new view with business logic
- •Creating a component that manages collections
- •Building forms with validation
What Gets Created
- •ViewModel - State management and business logic (MobX)
- •View - React component (observer)
- •Tests - Unit tests for ViewModel
- •Display Registration - Optional panel/display registration
- •Barrel Exports - Updated index.ts files
Prerequisites
- •Determine library location (
{{projectName}}.components,alpha.components, etc.) - •Identify required entity ViewModels
- •Understand data flow and services needed
Process
Step 1: Gather Requirements
Ask user:
- •Feature name (e.g., "InitConfigEditor", "PlatformSettings")
- •Library to create in (e.g.,
{{projectName}}.components,alpha.components) - •Does it manage entity ViewModels? Which ones?
- •Does it need to be a registered display/panel?
- •What services does it need? (EventBus, Logging, etc.)
Step 2: Choose ViewModel Pattern
For UI-only state (no entities):
- •Extend
BaseViewModel - •Manage local
@observablestate - •Example: Dialog, Modal, Filter panel
For entity management (wraps entities):
- •Extend
BaseViewModel - •Create entity VMs with
computeItemVMsFromItemsorcreateVisualPlugin - •Example: Equipment manager, Platform list
For features (combines both):
- •Extend
BaseViewModel - •Mix local UI state with entity VMs
- •Example: Settings panel with multiple entities
Reference: FRAMEWORK_GUIDE.md - ViewModel Types
Step 3: Create ViewModel File
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
Step 4: Create View File
Location: {lib}.components/src/lib/{feature}/{FeatureName}View.tsx
CRITICAL UI Component Rules:
- •✅ ALWAYS use {{sharedLib}} shared components
- •✅ ALWAYS wrap with
observer() - •✅ ALWAYS use
SelectPortalfor Select dropdowns - •❌ NEVER use native HTML (
<span>,<button>,<input>) - •❌ NEVER wrap Checkbox/Radio in Label
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
Step 5: Create Tests
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
Step 6: Display Registration (Optional)
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
Step 7: Update Barrel Exports
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';
Step 8: Create CSS (if needed)
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
Step 9: Verify Build
# Check for errors
./tools/build-helpers/count-client-errors.sh
./tools/build-helpers/show-client-errors.sh 10
# Run tests
npm test {FeatureName}
Common Patterns
Pattern 1: Form with Apply/Cancel
// 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
Pattern 2: Collection Management
@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;
}
);
}
Pattern 3: Filtered/Sorted Collections
@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)
);
}
MobX Checklist
Reference: MOBX_ESSENTIALS.md
- •
makeObservable(this)called in constructor - • UI state properties marked
@observable - • State modifiers marked
@action - • Derived values use
@computed(required for reactivity) - • Property VMs with configuration use
@computed(prevents re-running config) - • Async updates use
runInActionafterawait - • Arrays replaced, not mutated
- • View wrapped with
observer()
Common Pitfalls
Reference: COMMON_PITFALLS.md
- •❌ Forgetting
makeObservable(this) - •❌ Not wrapping View with
observer() - •❌ Using native HTML instead of {{sharedLib}} components
- •❌ Forgetting
runInActionafterawait - •❌ Not using
SelectPortalfor dropdowns - •❌ Wrapping Checkbox/Radio in Label
File Structure Summary
{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
Ask User
- •Feature name and purpose
- •Which library to create in
- •Does it manage entities? Which ones?
- •Should it be a registered display/panel?
- •What services are needed?
- •Should it have Apply/Cancel buttons (local changes pattern)?