| name | Create Feature Component |
| description | Create a complete feature with ViewModel, View, and tests following Phoenix MVVM patterns. Use when building new UI features, panels, or components that manage their own state. Generates ViewModel with MobX, React view with observer, and comprehensive tests. |
| allowed-tools | Read, Write, Edit, Grep, Glob |
Create Feature Component
This skill scaffolds a complete feature following Phoenix 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 (
faad.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.,
faad.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 '@tektonux/framework-api';
import { BaseViewModel } from '@tektonux/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 Phoenix 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 '@tektonux/phoenix-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 '@tektonux/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 '@tektonux/framework-visual-react-shared';
import { useViewModel } from '@tektonux/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 Phoenix 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)?