angular-component

Angular Component Development Workflow

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "angular-component" with this command: npx skills add congdon1207/agents.md/congdon1207-agents-md-angular-component

Angular Component Development Workflow

Pre-Flight Checklist

  • Identify correct app: playground-text-snippet , employee , etc.

  • Read the design system docs for the target application (see below)

  • Search for similar components: grep "{FeatureName}Component" --include="*.ts"

  • Determine component type (list, form, detail, dialog)

  • Check if store is needed (complex state)

🎨 Design System Documentation (MANDATORY)

Before creating any component, read the design system documentation for your target application:

Application Design System Location

WebV2 Apps docs/design-system/

TextSnippetClient src/PlatformExampleAppWeb/apps/playground-text-snippet/docs/design-system/

Key docs to read:

  • README.md

  • Component overview, base classes, library summary

  • 02-component-catalog.md

  • Available components and usage examples

  • 01-design-tokens.md

  • Colors, typography, spacing tokens

  • 07-technical-guide.md

  • Implementation checklist

Component Hierarchy

PlatformComponent # Base: lifecycle, subscriptions, signals ├── PlatformVmComponent # + ViewModel injection ├── PlatformFormComponent # + Reactive forms integration └── PlatformVmStoreComponent # + ComponentStore state management

AppBaseComponent # + Auth, roles, company context ├── AppBaseVmComponent # + ViewModel + auth context ├── AppBaseFormComponent # + Forms + auth + validation └── AppBaseVmStoreComponent # + Store + auth + loading/error

Component Type Decision

Scenario Base Class Use When

Simple display AppBaseComponent

Static content, no state

With ViewModel AppBaseVmComponent

Needs mutable view model

Form with validation AppBaseFormComponent

User input forms

Complex state/CRUD AppBaseVmStoreComponent

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)

Component HTML Template Standard (BEM Classes)

All UI elements in component templates MUST have BEM classes, even without styling needs. This makes HTML self-documenting like OOP class hierarchy.

<!-- ✅ CORRECT: All elements have BEM classes for structure clarity --> <div class="feature-list"> <div class="feature-list__header"> <h1 class="feature-list__title">Features</h1> <button class="feature-list__btn --add" (click)="onAdd()">Add New</button> </div> <div class="feature-list__content"> @for (item of vm.items; track trackByItem) { <div class="feature-list__item"> <span class="feature-list__item-name">{{ item.name }}</span> <div class="feature-list__item-actions"> <button class="feature-list__item-btn" (click)="onDelete(item)">Delete</button> </div> </div> } @empty { <div class="feature-list__empty">No items found</div> } </div> </div>

<!-- ❌ WRONG: Elements without classes - structure unclear --> <div class="feature-list"> <div> <h1>Features</h1> <button (click)="onAdd()">Add New</button> </div> <div> @for (item of vm.items; track trackByItem) { <div> <span>{{ item.name }}</span> <div> <button (click)="onDelete(item)">Delete</button> </div> </div> } </div> </div>

BEM Naming Convention:

  • Block: Component name (e.g., feature-list )

  • Element: Child using block__element (e.g., feature-list__header )

  • Modifier: Separate class with -- prefix (e.g., feature-list__btn --add --large )

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 my-component { display: flex; flex-direction: column; }

// Main wrapper class with full styling .my-component { display: flex; flex-direction: column; width: 100%; flex-grow: 1;

&#x26;__header {
    // BEM child elements...
}

&#x26;__content {
    flex: 1;
    overflow-y: auto;
}

}

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

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 { AppBaseVmStoreComponent } from '@libs/apps-domains'; 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 AppBaseVmStoreComponent<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>

    &#x3C;!-- List items -->
    @for (item of vm.items; track trackByItem) {
    &#x3C;div class="item">
        &#x3C;span>{{ item.name }}&#x3C;/span>
        &#x3C;button (click)="onDelete(item)" [disabled]="isDeleting$() === true">Delete&#x3C;/button>
    &#x3C;/div>
    } @empty {
    &#x3C;div class="empty">No items found&#x3C;/div>
    }
&#x3C;/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 { AppBaseFormComponent } from '@libs/apps-domains'; 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 AppBaseFormComponent<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&#x3C;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>

&#x3C;!-- Code field with async validation -->
&#x3C;div class="form-field">
    &#x3C;label for="code">Code *&#x3C;/label>
    &#x3C;input id="code" formControlName="code" />
    @if (formControls('code').errors?.['codeExists']) {
    &#x3C;span class="error">Code already exists&#x3C;/span>
    } @if (formControls('code').pending) {
    &#x3C;span class="info">Checking...&#x3C;/span>
    }
&#x3C;/div>

&#x3C;!-- Status dropdown -->
&#x3C;div class="form-field">
    &#x3C;label for="status">Status *&#x3C;/label>
    &#x3C;select id="status" formControlName="status">
        @for (status of statusOptions; track status.value) {
        &#x3C;option [value]="status.value">{{ status.label }}&#x3C;/option>
        }
    &#x3C;/select>
&#x3C;/div>

&#x3C;!-- Actions -->
&#x3C;div class="actions">
    &#x3C;button type="button" (click)="onCancel()">Cancel&#x3C;/button>
    &#x3C;button type="submit" [disabled]="!form.valid || isLoading$('save')()">{{ isLoading$('save')() ? 'Saving...' : 'Save' }}&#x3C;/button>
&#x3C;/div>

</form>

Pattern 3: Simple Component

// {feature}-card.component.ts import { Component, Input, Output, EventEmitter } from '@angular/core'; import { AppBaseComponent } from '@libs/apps-domains';

@Component({ selector: 'app-feature-card', template: &#x3C;div class="card" [class.selected]="isSelected"> &#x3C;h3>{{ feature.name }}&#x3C;/h3> &#x3C;p>{{ feature.description }}&#x3C;/p> @if (canEdit) { &#x3C;button (click)="onEdit.emit(feature)">Edit&#x3C;/button> } &#x3C;/div> }) export class FeatureCardComponent extends AppBaseComponent { @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: 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>

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

frontend-development

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

clean-code

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

python-patterns

No summary provided by upstream source.

Repository SourceNeeds Review