Agent skill

angular-component

Use when creating or modifying Angular components in PlatformExampleAppWeb (Angular 19) with proper base class inheritance, state management, and platform patterns.

Stars 163
Forks 31

Install this agent skill to your Project

npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/design/frontend-angular-component-congdon1207-agents-md

SKILL.md

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

typescript
// {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

typescript
// {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

html
<!-- {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

typescript
// {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

html
<!-- {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

typescript
// {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

typescript
// Auto-cleanup subscription
this.data$.pipe(this.untilDestroyed()).subscribe();

// Store named subscriptions
this.storeSubscription('key', observable.subscribe());
this.cancelStoredSubscription('key');

Loading/Error State

typescript
// Track request state
observable.pipe(this.observerLoadingErrorState('requestKey'));

// Check states in template
isLoading$('requestKey')();
getErrorMsg$('requestKey')();
isStateLoading()();
isStateError()();

Response Handling

typescript
// Handle success/error
observable.pipe(
    this.tapResponse(
        result => {
            /* success */
        },
        error => {
            /* error */
        }
    )
);

Track-by Functions

typescript
// 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
typescript
// ❌ 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

typescript
// 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

typescript
// 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

typescript
// WRONG
private sub: Subscription;
ngOnDestroy() { this.sub.unsubscribe(); }

// CORRECT
this.data$.pipe(this.untilDestroyed()).subscribe();

:x: Direct HTTP calls

typescript
// WRONG
constructor(private http: HttpClient) { }

// CORRECT
constructor(private featureApi: FeatureApiService) { }

:x: Missing loading states

html
<!-- 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:

scss
@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):

html
<!-- ✅ 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

html
<!-- ✅ 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

scss
.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 @for loops
  • 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

Expand your agent's capabilities with these related and highly-rated skills.

Didn't find tool you were looking for?

Be as detailed as possible for better results