| name | angular-component |
| description | Use when creating or modifying Angular components in PlatformExampleAppWeb (Angular 19) with proper base class inheritance, state management, and platform patterns. |
| allowed-tools | Read, Write, Edit, Grep, Glob, Bash |
Angular Component Development Workflow
Pre-Flight Checklist
- Identify correct app:
playground-text-snippet, etc. - Search for similar components:
grep "{FeatureName}Component" --include="*.ts" - Determine component type (list, form, detail, dialog)
- Check if store is needed (complex state)
Component Hierarchy
PlatformComponent # Base: lifecycle, subscriptions, signals (from @libs/platform-core)
├── PlatformVmComponent # + ViewModel injection
├── PlatformFormComponent # + Reactive forms integration
└── PlatformVmStoreComponent # + ComponentStore state management
AppBaseComponent (optional) # App-specific: + Auth, roles, company context
├── AppBaseVmComponent # + ViewModel + auth context (create in your app)
├── AppBaseFormComponent # + Forms + auth + validation (create in your app)
└── AppBaseVmStoreComponent # + Store + auth + loading/error (create in your app)
Note: Platform classes are exported from
@libs/platform-core. AppBase classes are optional app-specific extensions you can create to add auth context, company scope, and role-based access to your components.
Component Type Decision
| Scenario | Base Class | Use When |
|---|---|---|
| Simple display | PlatformComponent |
Static content, no state |
| With ViewModel | PlatformVmComponent |
Needs mutable view model |
| Form with validation | PlatformFormComponent |
User input forms |
| Complex state/CRUD | PlatformVmStoreComponent |
Lists, dashboards, multi-step |
File Location
src/PlatformExampleAppWeb/apps/{app-name}/src/app/
└── features/
└── {feature}/
├── {feature}.component.ts
├── {feature}.component.html
├── {feature}.component.scss
└── {feature}.store.ts (if using store)
Pattern 1: List Component with Store
Store Definition
// {feature}.store.ts
import { Injectable } from '@angular/core';
import { PlatformVmStore } from '@libs/platform-core';
export interface FeatureListState {
items: FeatureDto[];
selectedItem?: FeatureDto;
filters: FeatureFilters;
}
@Injectable()
export class FeatureListStore extends PlatformVmStore<FeatureListState> {
// Initial state
protected override vmConstructor = (data?: Partial<FeatureListState>) => ({ items: [], filters: {}, ...data }) as FeatureListState;
// Selectors
public readonly items$ = this.select(state => state.items);
public readonly selectedItem$ = this.select(state => state.selectedItem);
// Effects
public loadItems = this.effectSimple(() =>
this.featureApi.getList(this.currentVm().filters).pipe(
this.observerLoadingErrorState('loadItems'),
this.tapResponse(items => this.updateState({ items }))
)
);
public saveItem = this.effectSimple((item: FeatureDto) =>
this.featureApi.save(item).pipe(
this.observerLoadingErrorState('saveItem'),
this.tapResponse(saved => {
this.updateState(state => ({
items: state.items.upsertBy(x => x.id, [saved])
}));
})
)
);
public deleteItem = this.effectSimple((id: string) =>
this.featureApi.delete(id).pipe(
this.observerLoadingErrorState('deleteItem'),
this.tapResponse(() => {
this.updateState(state => ({
items: state.items.filter(x => x.id !== id)
}));
})
)
);
constructor(private featureApi: FeatureApiService) {
super();
}
}
List Component
// {feature}-list.component.ts
import { Component, OnInit } from '@angular/core';
import { PlatformVmStoreComponent } from '@libs/platform-core';
import { FeatureListStore, FeatureListState } from './feature-list.store';
@Component({
selector: 'app-feature-list',
templateUrl: './feature-list.component.html',
styleUrls: ['./feature-list.component.scss'],
providers: [FeatureListStore] // Provide store at component level
})
export class FeatureListComponent extends PlatformVmStoreComponent<FeatureListState, FeatureListStore> implements OnInit {
// Track-by for performance
trackByItem = this.ngForTrackByItemProp<FeatureDto>('id');
constructor(store: FeatureListStore) {
super(store);
}
ngOnInit(): void {
this.store.loadItems();
}
onRefresh(): void {
this.reload(); // Reloads all store data
}
onDelete(item: FeatureDto): void {
this.store.deleteItem(item.id);
}
// Check loading state for specific request
get isDeleting$() {
return this.store.isLoading$('deleteItem');
}
}
List Template
<!-- {feature}-list.component.html -->
<app-loading-and-error-indicator [target]="this">
@if (vm(); as vm) {
<div class="feature-list">
<!-- Header with actions -->
<div class="header">
<h1>Features</h1>
<button (click)="onRefresh()" [disabled]="isStateLoading()()">Refresh</button>
</div>
<!-- List items -->
@for (item of vm.items; track trackByItem) {
<div class="item">
<span>{{ item.name }}</span>
<button (click)="onDelete(item)" [disabled]="isDeleting$() === true">Delete</button>
</div>
} @empty {
<div class="empty">No items found</div>
}
</div>
}
</app-loading-and-error-indicator>
Pattern 2: Form Component
// {feature}-form.component.ts
import { Component } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { PlatformFormComponent } from '@libs/platform-core';
import { ifAsyncValidator, noWhitespaceValidator } from '@libs/platform-core';
export interface FeatureFormVm {
id?: string;
name: string;
code: string;
status: FeatureStatus;
effectiveDate?: Date;
}
@Component({
selector: 'app-feature-form',
templateUrl: './feature-form.component.html'
})
export class FeatureFormComponent extends PlatformFormComponent<FeatureFormVm> {
// Form configuration
protected initialFormConfig = () => ({
controls: {
name: new FormControl(this.currentVm().name, [Validators.required, Validators.maxLength(200), noWhitespaceValidator]),
code: new FormControl(
this.currentVm().code,
[Validators.required, Validators.pattern(/^[A-Z0-9-]+$/)],
[
// Async validator only runs if sync validators pass
ifAsyncValidator(() => !this.isViewMode(), this.checkCodeUniqueValidator())
]
),
status: new FormControl(this.currentVm().status, [Validators.required]),
effectiveDate: new FormControl(this.currentVm().effectiveDate)
},
// Re-validate code when status changes
dependentValidations: {
code: ['status']
}
});
// Initialize or reload view model
protected initOrReloadVm = (isReload: boolean) => {
if (this.mode === 'create') {
return of<FeatureFormVm>({
name: '',
code: '',
status: FeatureStatus.Draft
});
}
return this.featureApi.getById(this.featureId);
};
// Custom async validator
private checkCodeUniqueValidator() {
return async (control: AbstractControl) => {
const exists = await firstValueFrom(this.featureApi.checkCodeExists(control.value, this.currentVm().id));
return exists ? { codeExists: true } : null;
};
}
onSubmit(): void {
if (!this.validateForm()) return;
const vm = this.currentVm();
this.featureApi
.save(vm)
.pipe(
this.observerLoadingErrorState('save'),
this.tapResponse(
saved => this.onSaveSuccess(saved),
error => this.onSaveError(error)
),
this.untilDestroyed()
)
.subscribe();
}
constructor(private featureApi: FeatureApiService) {
super();
}
}
Form Template
<!-- {feature}-form.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Name field -->
<div class="form-field">
<label for="name">Name *</label>
<input id="name" formControlName="name" />
@if (formControls('name').errors?.['required']) {
<span class="error">Name is required</span>
}
</div>
<!-- Code field with async validation -->
<div class="form-field">
<label for="code">Code *</label>
<input id="code" formControlName="code" />
@if (formControls('code').errors?.['codeExists']) {
<span class="error">Code already exists</span>
} @if (formControls('code').pending) {
<span class="info">Checking...</span>
}
</div>
<!-- Status dropdown -->
<div class="form-field">
<label for="status">Status *</label>
<select id="status" formControlName="status">
@for (status of statusOptions; track status.value) {
<option [value]="status.value">{{ status.label }}</option>
}
</select>
</div>
<!-- Actions -->
<div class="actions">
<button type="button" (click)="onCancel()">Cancel</button>
<button type="submit" [disabled]="!form.valid || isLoading$('save')()">{{ isLoading$('save')() ? 'Saving...' : 'Save' }}</button>
</div>
</form>
Pattern 3: Simple Component
// {feature}-card.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { PlatformComponent } from '@libs/platform-core';
@Component({
selector: 'app-feature-card',
template: `
<div class="card" [class.selected]="isSelected">
<h3>{{ feature.name }}</h3>
<p>{{ feature.description }}</p>
@if (canEdit) {
<button (click)="onEdit.emit(feature)">Edit</button>
}
</div>
`
})
export class FeatureCardComponent extends PlatformComponent {
@Input() feature!: FeatureDto;
@Input() isSelected = false;
@Output() onEdit = new EventEmitter<FeatureDto>();
get canEdit(): boolean {
return this.hasRole('Admin', 'Manager');
}
}
Key Platform APIs
Lifecycle & Subscriptions
// Auto-cleanup subscription
this.data$.pipe(this.untilDestroyed()).subscribe();
// Store named subscriptions
this.storeSubscription('key', observable.subscribe());
this.cancelStoredSubscription('key');
Loading/Error State
// Track request state
observable.pipe(this.observerLoadingErrorState('requestKey'));
// Check states in template
isLoading$('requestKey')();
getErrorMsg$('requestKey')();
isStateLoading()();
isStateError()();
Response Handling
// Handle success/error
observable.pipe(
this.tapResponse(
result => {
/* success */
},
error => {
/* error */
}
)
);
Track-by Functions
// For @for loops
trackByItem = this.ngForTrackByItemProp<Item>('id');
trackByList = this.ngForTrackByImmutableList(this.items);
Code Responsibility Hierarchy (CRITICAL)
Place logic in the LOWEST appropriate layer to enable reuse and prevent duplication:
| Layer | Responsibility |
|---|---|
| Entity/Model | Display helpers, static factory methods, default values, dropdown options |
| Service | API calls, command factories, data transformation |
| Component | UI event handling ONLY - delegates all logic to lower layers |
// ❌ WRONG: Logic in component (leads to duplication if another component needs it)
readonly authTypes = [{ value: AuthType.OAuth2, label: 'OAuth2' }, ...];
getDefaultBaseUrl(type) { return this.providerUrls[type] ?? ''; }
// ✅ CORRECT: Logic in entity/model (single source of truth, reusable)
readonly authTypes = AuthConfigurationDisplay.getApiAuthTypeOptions();
getDefaultBaseUrl(type) { return JobBoardProviderConfiguration.getDefaultBaseUrl(type); }
Common Refactoring Patterns:
- Dropdown options → static method in entity:
Entity.getOptions() - Display logic (CSS class, text) → instance method in entity:
entity.getStatusCssClass() - Default values → static method in entity:
Entity.getDefaultValue() - Command building → factory class in service:
CommandFactory.buildSaveCommand(formValues)
Anti-Patterns to AVOID
:x: Putting reusable logic in component instead of entity/model
// WRONG - logic that should be in entity
readonly options = [{ value: 1, label: 'Option 1' }];
// CORRECT - delegate to entity
readonly options = Entity.getDropdownOptions();
:x: Using wrong base class
// WRONG - using PlatformComponent when auth needed
export class MyComponent extends PlatformComponent {}
// CORRECT - using AppBaseComponent for auth context
export class MyComponent extends AppBaseComponent {}
:x: Manual subscription management
// WRONG
private sub: Subscription;
ngOnDestroy() { this.sub.unsubscribe(); }
// CORRECT
this.data$.pipe(this.untilDestroyed()).subscribe();
:x: Direct HTTP calls
// WRONG
constructor(private http: HttpClient) { }
// CORRECT
constructor(private featureApi: FeatureApiService) { }
:x: Missing loading states
<!-- WRONG - no loading indicator -->
<div>{{ items }}</div>
<!-- CORRECT - with loading wrapper -->
<app-loading-and-error-indicator [target]="this">
<div>{{ items }}</div>
</app-loading-and-error-indicator>
Component SCSS Standard
Always style both the host element (Angular selector) and the main wrapper class:
@import '~assets/scss/variables';
// Host element styling - ensures Angular element is a proper block container
app-feature-list {
display: flex;
flex-direction: column;
}
// Main wrapper class with full styling
.feature-list {
display: flex;
flex-direction: column;
width: 100%;
flex-grow: 1;
&__header {
// BEM child elements
}
&__content {
flex: 1;
overflow-y: auto;
}
&__btn {
// Modifiers use space-separated --modifier classes
&.--primary {
background: $primary-color;
}
&.--large {
padding: 1rem 2rem;
}
}
}
Why both?
- Host element: Makes the Angular element a real layout element (not an unknown element without display)
- Main class: Contains the full styling, matches the wrapper div in HTML
BEM Naming Convention (MANDATORY)
Rule: ALL UI Elements Must Have BEM Classes
CRITICAL: Every UI element in a component template MUST have a BEM class, even if it doesn't need special styling. This follows OOP principles - treat CSS classes as object-oriented structure for readability.
BEM Structure
block → Component wrapper (e.g., .feature-list)
block__element → Child element (e.g., .feature-list__title)
block__element --modifier → State/variant (e.g., .feature-list__btn --primary --large)
Modifier Convention
Use space-separated --modifier classes (NOT suffix style):
<!-- ✅ CORRECT: Space-separated modifiers -->
<button class="feature-list__btn --primary --large">Save</button>
<div class="feature-list__item --selected --highlighted">Item</div>
<!-- ❌ WRONG: Suffix-style modifiers -->
<button class="feature-list__btn--primary feature-list__btn--large">Save</button>
Template Example
<!-- ✅ CORRECT: Every element has a BEM class -->
<div class="feature-list">
<div class="feature-list__header">
<h1 class="feature-list__title">Features</h1>
<button class="feature-list__btn --icon" (click)="onRefresh()">
<i class="feature-list__icon">refresh</i>
</button>
</div>
<div class="feature-list__content">
@for (item of vm.items; track item.id) {
<div class="feature-list__item --selectable" [class.--selected]="item.isSelected">
<span class="feature-list__item-name">{{ item.name }}</span>
<button class="feature-list__item-action --danger" (click)="onDelete(item)">Delete</button>
</div>
}
</div>
<div class="feature-list__footer">
<button class="feature-list__btn --secondary" (click)="onCancel()">Cancel</button>
<button class="feature-list__btn --primary" (click)="onSave()">Save</button>
</div>
</div>
<!-- ❌ WRONG: Elements without BEM classes -->
<div class="feature-list">
<div>
<!-- Missing class! -->
<h1>Features</h1>
<!-- Missing class! -->
</div>
<button (click)="onSave()">Save</button>
<!-- Missing class! -->
</div>
SCSS with Modifiers
.feature-list {
&__btn {
padding: 0.5rem 1rem;
&.--primary {
background: $primary-color;
color: white;
}
&.--secondary {
background: transparent;
border: 1px solid $border-color;
}
&.--danger {
background: $danger-color;
}
}
&__item {
&.--selected {
background: $selected-bg;
}
&.--selectable {
cursor: pointer;
}
}
}
Verification Checklist
- Correct base class selected for use case
- Store provided at component level (if using store)
- Loading/error states handled with
app-loading-and-error-indicator - Subscriptions use
untilDestroyed() - Track-by functions used in
@forloops - Form validation configured properly
- Auth checks use
hasRole()from base class - API calls use service extending
PlatformApiService - SCSS styles both host element and main wrapper class