frontend-angular

Prerequisites: MUST READ before executing:

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 "frontend-angular" with this command: npx skills add duc01226/easyplatform/duc01226-easyplatform-frontend-angular

Prerequisites: MUST READ before executing:

  • .claude/skills/shared/understand-code-first-protocol.md

  • .claude/skills/shared/evidence-based-reasoning-protocol.md

Quick Summary

Goal: Create or modify Angular frontend code -- components, forms, stores, and API services -- following platform patterns.

Project Pattern Discovery

Before implementation, search your codebase for project-specific patterns:

  • Search for: BaseComponent , VmStore , FormComponent , ApiService , untilDestroyed

  • Look for: project base component classes, shared component libraries, state store conventions

MANDATORY IMPORTANT MUST Plan ToDo Task to READ frontend-patterns-reference.md for project-specific patterns and code examples. If file not found, continue with search-based discovery above.

Workflow:

  • Pre-Flight -- Identify app, search similar code, determine type (component/form/store/service)

  • Select Pattern -- Choose from Component, Form, Store, or API Service patterns below

  • Implement -- Follow BEM template standard, SCSS host+wrapper pattern, platform base classes

  • Wire Up -- Route config, module imports, API service integration

Key Rules:

  • Never extend Platform* directly; always use AppBase* intermediaries

  • All HTML elements MUST have BEM classes (block__element --modifier )

  • Use .pipe(this.untilDestroyed()) for all subscriptions

  • Always extend PlatformApiService for HTTP calls, never use HttpClient directly

  • Always use PlatformVmStore for state management, never manual signals

  • MUST READ .ai/docs/frontend-code-patterns.md and design system docs before implementation

Prerequisites: MUST READ .claude/skills/shared/design-system-check.md for mandatory design system checks.

Pre-Flight Checklist

  • Identify correct app: growth-for-company , employee , etc.

  • Read the design system docs for the target application

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

  • Determine what you need: component, form, store, API service, or combination

  • Read .ai/docs/frontend-code-patterns.md

File Locations

Search your project to find the frontend directory structure. Common pattern:

{frontend-apps-dir}/{app-name}/src/app/ features/ {feature}/ {feature}.component.ts # Component {feature}.component.html # Template {feature}.component.scss # Styles {feature}.store.ts # Store (if complex state) {feature}-form.component.ts # Form component

{frontend-libs-dir}/{domain-lib}/src/lib/ {domain}/ services/ {feature}-api.service.ts # API Service

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

// BAD: Logic in component (leads to duplication) readonly authTypes = [{ value: AuthType.OAuth2, label: 'OAuth2' }, ...];

// GOOD: Logic in entity/model (single source of truth, reusable) readonly authTypes = AuthConfigurationDisplay.getApiAuthTypeOptions();

Part 1: Components

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

Component HTML Template Standard (BEM Classes)

All UI elements MUST have BEM classes, even without styling needs. This makes HTML self-documenting.

<!-- GOOD: All elements have BEM classes --> <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> } @empty { <div class="feature-list__empty">No items found</div> } </div> </div>

BEM Naming: Block = component name, Element = block__element , Modifier = separate --modifier class.

Component SCSS Standard

Always style both the host element and the main wrapper class:

@import '~assets/scss/variables';

// Host element -- makes Angular element a proper block container my-component { display: flex; flex-direction: column; }

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

&#x26;__header {
    /* ... */
}
&#x26;__content {
    flex: 1;
    overflow-y: auto;
}

}

Pattern: List Component with Store

@Component({ selector: 'app-feature-list', templateUrl: './feature-list.component.html', styleUrls: ['./feature-list.component.scss'], providers: [FeatureListStore] }) export class FeatureListComponent extends AppBaseVmStoreComponent<FeatureListState, FeatureListStore> implements OnInit { trackByItem = this.ngForTrackByItemProp<FeatureDto>('id');

constructor(store: FeatureListStore) {
    super(store);
}

ngOnInit(): void {
    this.store.loadItems();
}

onRefresh(): void {
    this.reload();
}

onDelete(item: FeatureDto): void {
    this.store.deleteItem(item.id);
}

}

<app-loading-and-error-indicator [target]="this"> @if (vm(); as vm) { <div class="feature-list"> <div class="feature-list__header"> <h1 class="feature-list__title">Features</h1> <button class="feature-list__btn --refresh" (click)="onRefresh()" [disabled]="isStateLoading()()">Refresh</button> </div> @for (item of vm.items; track trackByItem) { <div class="feature-list__item"> <span class="feature-list__item-name">{{ item.name }}</span> <button class="feature-list__item-btn --delete" (click)="onDelete(item)">Delete</button> </div> } @empty { <div class="feature-list__empty">No items found</div> } </div> } </app-loading-and-error-indicator>

Pattern: Simple Component

@Component({ selector: 'app-feature-card', template: &#x3C;div class="feature-card" [class.--selected]="isSelected"> &#x3C;h3 class="feature-card__title">{{ feature.name }}&#x3C;/h3> &#x3C;p class="feature-card__desc">{{ feature.description }}&#x3C;/p> @if (canEdit) { &#x3C;button class="feature-card__btn --edit" (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 (Components)

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

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

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

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

// Track-by for @for loops trackByItem = this.ngForTrackByItemProp<Item>('id');

Part 2: Forms

Form Base Class Selection

Base Class Use When

PlatformFormComponent

Basic form without auth

AppBaseFormComponent

Form with auth context

Pattern: Basic Form

export interface FeatureFormVm { id?: string; name: string; code: string; status: FeatureStatus; isActive: boolean; }

@Component({ selector: 'app-feature-form', templateUrl: './feature-form.component.html' }) export class FeatureFormComponent extends AppBaseFormComponent<FeatureFormVm> { @Input() featureId?: string;

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_-]+$/)]),
        status: new FormControl(this.currentVm().status, [Validators.required]),
        isActive: new FormControl(this.currentVm().isActive)
    }
});

protected initOrReloadVm = (isReload: boolean) => {
    if (!this.featureId) {
        return of&#x3C;FeatureFormVm>({ name: '', code: '', status: FeatureStatus.Draft, isActive: true });
    }
    return this.featureApi.getById(this.featureId);
};

onSubmit(): void {
    if (!this.validateForm()) return;
    this.featureApi
        .save(this.currentVm())
        .pipe(
            this.observerLoadingErrorState('save'),
            this.tapResponse(
                saved => this.onSuccess(saved),
                error => this.onError(error)
            ),
            this.untilDestroyed()
        )
        .subscribe();
}

constructor(private featureApi: FeatureApiService) {
    super();
}

}

Pattern: Async Validation

protected initialFormConfig = () => ({ controls: { code: new FormControl( this.currentVm().code, [Validators.required, Validators.pattern(/^[A-Z0-9_-]+$/)], [ifAsyncValidator(ctrl => ctrl.valid, this.checkCodeUniqueValidator())] ) } });

private checkCodeUniqueValidator(): AsyncValidatorFn { return async (control: AbstractControl): Promise<ValidationErrors | null> => { if (!control.value) return null; const exists = await firstValueFrom( this.featureApi.checkCodeExists(control.value, this.currentVm().id).pipe(debounceTime(300)) ); return exists ? { codeExists: 'Code already exists' } : null; }; }

Pattern: Dependent Validation

protected initialFormConfig = () => ({ controls: { startDate: new FormControl(this.currentVm().startDate, [Validators.required]), endDate: new FormControl(this.currentVm().endDate, [ Validators.required, startEndValidator('invalidRange', ctrl => ctrl.parent?.get('startDate')?.value, ctrl => ctrl.value, { allowEqual: true }) ]) }, dependentValidations: { endDate: ['startDate'] // Re-validate endDate when startDate changes } });

Pattern: FormArray

protected initialFormConfig = () => ({ controls: { name: new FormControl(this.currentVm().name, [Validators.required]), specifications: { modelItems: () => this.currentVm().specifications, itemControl: (spec: Specification) => new FormGroup({ name: new FormControl(spec.name, [Validators.required]), value: new FormControl(spec.value, [Validators.required]) }) } } });

get specificationsArray(): FormArray { return this.form.get('specifications') as FormArray; }

addSpecification(): void { this.updateVm(vm => ({ specifications: [...vm.specifications, { name: '', value: '' }] })); this.specificationsArray.push(new FormGroup({ name: new FormControl('', [Validators.required]), value: new FormControl('', [Validators.required]) })); }

removeSpecification(index: number): void { this.updateVm(vm => ({ specifications: vm.specifications.filter((_, i) => i !== index) })); this.specificationsArray.removeAt(index); }

Form Template

<form class="feature-form" [formGroup]="form" (ngSubmit)="onSubmit()"> <div class="feature-form__field"> <label class="feature-form__label" for="name">Name *</label> <input class="feature-form__input" id="name" formControlName="name" /> @if (formControls('name').errors?.['required'] && formControls('name').touched) { <span class="feature-form__error">Name is required</span> } </div> <div class="feature-form__field"> <label class="feature-form__label" for="code">Code *</label> <input class="feature-form__input" id="code" formControlName="code" /> @if (formControls('code').pending) { <span class="feature-form__info">Checking availability...</span> } @if (formControls('code').errors?.['codeExists']) { <span class="feature-form__error">{{ formControls('code').errors?.['codeExists'] }}</span> } </div> <div class="feature-form__actions"> <button class="feature-form__btn --cancel" type="button" (click)="onCancel()">Cancel</button> <button class="feature-form__btn --submit" type="submit" [disabled]="!form.valid || isLoading$('save')()"> {{ isLoading$('save')() ? 'Saving...' : 'Save' }} </button> </div> </form>

Built-in Validators

Validator Import Usage

noWhitespaceValidator

@libs/platform-core

No empty strings

startEndValidator

@libs/platform-core

Date/number range

ifAsyncValidator

@libs/platform-core

Conditional async

validator

@libs/platform-core

Custom validator factory

Key Form APIs

Method Purpose Example

validateForm()

Validate and mark touched if (!this.validateForm()) return;

formControls(key)

Get form control this.formControls('name').errors

currentVm()

Get current view model const vm = this.currentVm()

updateVm()

Update view model this.updateVm({ name: 'new' })

mode

Form mode this.mode === 'create'

isViewMode()

Check view mode if (this.isViewMode()) return;

Part 3: Stores (State Management)

Store Architecture

PlatformVmStore<TState> State: TState (reactive signal) Selectors: select() -> Signal<T> Effects: effectSimple() -> side effects Updaters: updateState() -> mutations Loading/Error: observerLoadingErrorState()

Pattern: Basic CRUD Store

export interface FeatureListState { items: FeatureDto[]; selectedItem?: FeatureDto; filters: FeatureFilters; pagination: PaginationState; }

@Injectable() export class FeatureListStore extends PlatformVmStore<FeatureListState> { protected override vmConstructor = (data?: Partial<FeatureListState>) => ({ items: [], filters: {}, pagination: { pageIndex: 0, pageSize: 20, totalCount: 0 }, ...data }) as FeatureListState;

// Selectors
public readonly items$ = this.select(state => state.items);
public readonly selectedItem$ = this.select(state => state.selectedItem);
public readonly hasItems$ = this.select(state => state.items.length > 0);

// Effects
public loadItems = this.effectSimple(() => {
    const state = this.currentVm();
    return this.featureApi
        .getList({
            ...state.filters,
            skipCount: state.pagination.pageIndex * state.pagination.pageSize,
            maxResultCount: state.pagination.pageSize
        })
        .pipe(
            this.tapResponse(result => {
                this.updateState({ items: result.items, pagination: { ...state.pagination, totalCount: result.totalCount } });
            })
        );
}, 'loadItems');

public saveItem = this.effectSimple(
    (item: FeatureDto) =>
        this.featureApi.save(item).pipe(
            this.tapResponse(saved => {
                this.updateState(state => ({ items: state.items.upsertBy(x => x.id, [saved]), selectedItem: saved }));
            })
        ),
    'saveItem'
);

public deleteItem = this.effectSimple(
    (id: string) =>
        this.featureApi.delete(id).pipe(
            this.tapResponse(() => {
                this.updateState(state => ({
                    items: state.items.filter(x => x.id !== id),
                    selectedItem: state.selectedItem?.id === id ? undefined : state.selectedItem
                }));
            })
        ),
    'deleteItem'
);

// Updaters
public setFilters(filters: Partial&#x3C;FeatureFilters>): void {
    this.updateState(state => ({ filters: { ...state.filters, ...filters }, pagination: { ...state.pagination, pageIndex: 0 } }));
}

public setPage(pageIndex: number): void {
    this.updateState(state => ({ pagination: { ...state.pagination, pageIndex } }));
}

constructor(private featureApi: FeatureApiService) {
    super();
}

}

Pattern: Store with Dependent Data

@Injectable() export class EmployeeFormStore extends PlatformVmStore<EmployeeFormState> { public loadFormData = this.effectSimple( (employeeId?: string) => forkJoin({ employee: employeeId ? this.employeeApi.getById(employeeId) : of(this.createNewEmployee()), departments: this.departmentApi.getActive(), positions: this.positionApi.getAll() }).pipe(this.tapResponse(result => this.updateState(result))), 'loadFormData' ); }

Pattern: Store with Caching

@Injectable({ providedIn: 'root' }) // Singleton for caching export class LookupDataStore extends PlatformVmStore<LookupDataState> { protected override get enableCaching() { return true; } protected override cachedStateKeyName = () => 'LookupDataStore'; protected override get cacheExpirationMs() { return 5 * 60 * 1000; }

public loadCountries = this.effectSimple(() => {
    if (this.currentVm().countries.length > 0) return EMPTY;
    return this.lookupApi.getCountries().pipe(this.tapResponse(countries => this.updateState({ countries })));
}, 'loadCountries');

}

Key Store APIs

Method Purpose Example

select()

Create selector this.select(s => s.items)

updateState()

Update state this.updateState({ items })

effectSimple()

Create effect this.effectSimple(() => api.call(), 'requestKey')

currentVm()

Get current state const state = this.currentVm()

observerLoadingErrorState()

Track loading/error Use outside effectSimple only

tapResponse()

Handle success/error .pipe(this.tapResponse(success, error))

isLoading$()

Loading signal this.store.isLoading$('loadItems')

Part 4: API Services

Pattern: Basic CRUD API Service

@Injectable({ providedIn: 'root' }) export class FeatureApiService extends PlatformApiService { protected get apiUrl(): string { return environment.apiUrl + '/api/Feature'; }

getList(query?: FeatureListQuery): Observable&#x3C;PagedResult&#x3C;FeatureDto>> {
    return this.get&#x3C;PagedResult&#x3C;FeatureDto>>('', query);
}

getById(id: string): Observable&#x3C;FeatureDto> {
    return this.get&#x3C;FeatureDto>(`/${id}`);
}

save(command: SaveFeatureCommand): Observable&#x3C;FeatureDto> {
    return this.post&#x3C;FeatureDto>('', command);
}

update(id: string, command: Partial&#x3C;SaveFeatureCommand>): Observable&#x3C;FeatureDto> {
    return this.put&#x3C;FeatureDto>(`/${id}`, command);
}

delete(id: string): Observable&#x3C;void> {
    return this.deleteRequest&#x3C;void>(`/${id}`);
}

checkCodeExists(code: string, excludeId?: string): Observable&#x3C;boolean> {
    return this.get&#x3C;boolean>('/check-code-exists', { code, excludeId });
}

}

Pattern: API Service with Caching

@Injectable({ providedIn: 'root' }) export class LookupApiService extends PlatformApiService { protected get apiUrl(): string { return environment.apiUrl + '/api/Lookup'; }

getCountries(): Observable&#x3C;CountryDto[]> {
    return this.get&#x3C;CountryDto[]>('/countries', null, { enableCache: true, cacheKey: 'countries', cacheDurationMs: 60 * 60 * 1000 });
}

invalidateCountriesCache(): void {
    this.clearCache('countries');
}
invalidateAllCache(): void {
    this.clearAllCache();
}

}

Pattern: File Upload/Download

@Injectable({ providedIn: 'root' }) export class DocumentApiService extends PlatformApiService { protected get apiUrl(): string { return environment.apiUrl + '/api/Document'; }

upload(file: File, metadata?: DocumentMetadata): Observable&#x3C;DocumentDto> {
    const formData = new FormData();
    formData.append('file', file, file.name);
    if (metadata) formData.append('metadata', JSON.stringify(metadata));
    return this.postFormData&#x3C;DocumentDto>('/upload', formData);
}

download(id: string): Observable&#x3C;Blob> {
    return this.getBlob(`/${id}/download`);
}

downloadAndSave(id: string, fileName: string): Observable&#x3C;void> {
    return this.download(id).pipe(
        tap(blob => {
            const url = window.URL.createObjectURL(blob);
            const link = document.createElement('a');
            link.href = url;
            link.download = fileName;
            link.click();
            window.URL.revokeObjectURL(url);
        }),
        map(() => void 0)
    );
}

}

Pattern: Search/Autocomplete API

@Injectable({ providedIn: 'root' }) export class EmployeeApiService extends PlatformApiService { protected get apiUrl(): string { return environment.apiUrl + '/api/Employee'; }

search(term: string): Observable&#x3C;EmployeeDto[]> {
    if (!term || term.length &#x3C; 2) return of([]);
    return this.get&#x3C;EmployeeDto[]>('/search', { searchText: term, maxResultCount: 10 });
}

autocomplete(prefix: string): Observable&#x3C;AutocompleteItem[]> {
    return this.get&#x3C;AutocompleteItem[]>(
        '/autocomplete',
        { prefix },
        {
            enableCache: true,
            cacheKey: `autocomplete-${prefix}`,
            cacheDurationMs: 30 * 1000
        }
    );
}

}

Base PlatformApiService Methods

Method Purpose Example

get<T>()

GET request this.get<User>('/users/1')

post<T>()

POST request this.post<User>('/users', data)

put<T>()

PUT request this.put<User>('/users/1', data)

patch<T>()

PATCH request this.patch<User>('/users/1', partial)

deleteRequest<T>()

DELETE request this.deleteRequest('/users/1')

postFormData<T>()

POST with FormData this.postFormData('/upload', formData)

getBlob()

GET binary data this.getBlob('/file/download')

clearCache()

Clear specific cache this.clearCache('cacheKey')

clearAllCache()

Clear all cache this.clearAllCache()

Request Options

interface RequestOptions { enableCache?: boolean; cacheKey?: string; cacheDurationMs?: number; headers?: { [key: string]: string }; responseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; reportProgress?: boolean; observe?: 'body' | 'events' | 'response'; }

Anti-Patterns to AVOID

Wrong base class:

// BAD: using PlatformComponent when auth needed export class MyComponent extends PlatformComponent {} // GOOD: export class MyComponent extends AppBaseComponent {}

Manual subscription management:

// BAD private sub: Subscription; ngOnDestroy() { this.sub.unsubscribe(); } // GOOD this.data$.pipe(this.untilDestroyed()).subscribe();

Direct HTTP calls:

// BAD constructor(private http: HttpClient) { } // GOOD constructor(private featureApi: FeatureApiService) { }

Missing loading states:

<!-- BAD --> <div>{{ items }}</div> <!-- GOOD --> <app-loading-and-error-indicator [target]="this"> <div>{{ items }}</div> </app-loading-and-error-indicator>

Not using validateForm():

// BAD onSubmit() { this.api.save(this.currentVm()); } // GOOD onSubmit() { if (!this.validateForm()) return; this.api.save(this.currentVm()); }

Async validator always runs:

// BAD new FormControl('', [], [asyncValidator]); // GOOD new FormControl('', [], [ifAsyncValidator(ctrl => ctrl.valid, asyncValidator)]);

Mutating state directly:

// BAD this.currentVm().items.push(newItem); // GOOD this.updateState(state => ({ items: [...state.items, newItem] }));

Missing BEM classes:

<!-- BAD --> <div><label>Name</label><input formControlName="name" /></div> <!-- GOOD --> <div class="form__field"><label class="form__label">Name</label><input class="form__input" formControlName="name" /></div>

Verification Checklist

Components

  • 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

  • Auth checks use hasRole() from base class

  • API calls use service extending PlatformApiService

Forms

  • initialFormConfig returns form configuration

  • initOrReloadVm loads data for edit mode

  • validateForm() called before submit

  • Async validators use ifAsyncValidator

  • dependentValidations configured for cross-field validation

  • Error messages displayed for all validation rules

Stores

  • State interface defines all required properties

  • vmConstructor provides default state

  • Effects use effectSimple() with request key

  • Effects use tapResponse() for handling

  • Selectors memoized with select()

  • State updates are immutable

  • Store provided at correct level (component vs root)

API Services

  • Extends PlatformApiService

  • apiUrl getter returns correct base URL

  • All methods have return type annotations

  • DTOs defined for request/response

  • @Injectable({ providedIn: 'root' }) for singleton

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.

General

pdf-to-markdown

No summary provided by upstream source.

Repository SourceNeeds Review
General

markdown-to-docx

No summary provided by upstream source.

Repository SourceNeeds Review
General

docx-to-markdown

No summary provided by upstream source.

Repository SourceNeeds Review