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;
&__header {
/* ... */
}
&__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: <div class="feature-card" [class.--selected]="isSelected"> <h3 class="feature-card__title">{{ feature.name }}</h3> <p class="feature-card__desc">{{ feature.description }}</p> @if (canEdit) { <button class="feature-card__btn --edit" (click)="onEdit.emit(feature)">Edit</button> } </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<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<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<PagedResult<FeatureDto>> {
return this.get<PagedResult<FeatureDto>>('', query);
}
getById(id: string): Observable<FeatureDto> {
return this.get<FeatureDto>(`/${id}`);
}
save(command: SaveFeatureCommand): Observable<FeatureDto> {
return this.post<FeatureDto>('', command);
}
update(id: string, command: Partial<SaveFeatureCommand>): Observable<FeatureDto> {
return this.put<FeatureDto>(`/${id}`, command);
}
delete(id: string): Observable<void> {
return this.deleteRequest<void>(`/${id}`);
}
checkCodeExists(code: string, excludeId?: string): Observable<boolean> {
return this.get<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<CountryDto[]> {
return this.get<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<DocumentDto> {
const formData = new FormData();
formData.append('file', file, file.name);
if (metadata) formData.append('metadata', JSON.stringify(metadata));
return this.postFormData<DocumentDto>('/upload', formData);
}
download(id: string): Observable<Blob> {
return this.getBlob(`/${id}/download`);
}
downloadAndSave(id: string, fileName: string): Observable<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<EmployeeDto[]> {
if (!term || term.length < 2) return of([]);
return this.get<EmployeeDto[]>('/search', { searchText: term, maxResultCount: 10 });
}
autocomplete(prefix: string): Observable<AutocompleteItem[]> {
return this.get<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