frontend-angular-form
Use when creating reactive forms with validation, async validators, dependent validation, and FormArrays using platform patterns.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Angular Form Development Workflow
When to Use This Skill
- User input forms (create, edit, settings)
- Complex validation requirements
- Async validation (uniqueness checks)
- Dynamic form fields (FormArrays)
- Dependent field validation
Pre-Flight Checklist
- Identify form mode (create, update, view)
- Read the design system docs for the target application (see below)
- List all validation rules (sync and async)
- Identify field dependencies
- Search similar forms:
grep "{Feature}FormComponent" --include="*.ts"
🎨 Design System Documentation (MANDATORY)
Before creating any form, read the design system documentation for your target application:
| Application | Design System Location |
|---|---|
| WebV2 Apps | docs/design-system/ |
| TextSnippetClient | src/Frontend/apps/playground-text-snippet/docs/design-system/ |
Key docs to read:
-
README.md- Component overview, base classes, library summary -
03-form-patterns.md- Form validation, modes, error handling patterns -
02-component-catalog.md- Available form components and usage examples -
01-design-tokens.md- Colors, typography, spacing tokens
File Location
src/Frontend/apps/{app-name}/src/app/
└── features/
└── {feature}/
├── {feature}-form.component.ts
├── {feature}-form.component.html
└── {feature}-form.component.scss
Form Base Class Selection
| Base Class | Use When |
|---|---|
PlatformFormComponent |
Basic form without auth |
AppBaseFormComponent |
Form with auth context |
Pattern 1: Basic Form
// {feature}-form.component.ts
import { Component, Input } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { AppBaseFormComponent } from '@libs/apps-domains';
import { noWhitespaceValidator } from '@libs/platform-core';
// ═══════════════════════════════════════════════════════════════════════════
// VIEW MODEL
// ═══════════════════════════════════════════════════════════════════════════
export interface FeatureFormVm {
id?: string;
name: string;
code: string;
description?: string;
status: FeatureStatus;
isActive: boolean;
}
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENT
// ═══════════════════════════════════════════════════════════════════════════
@Component({
selector: 'app-feature-form',
templateUrl: './feature-form.component.html'
})
export class FeatureFormComponent extends AppBaseFormComponent<FeatureFormVm> {
@Input() featureId?: string;
// ─────────────────────────────────────────────────────────────────────────
// 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_-]+$/), Validators.maxLength(50)]),
description: new FormControl(this.currentVm().description, [Validators.maxLength(2000)]),
status: new FormControl(this.currentVm().status, [Validators.required]),
isActive: new FormControl(this.currentVm().isActive)
}
});
// ─────────────────────────────────────────────────────────────────────────
// INIT/RELOAD VIEW MODEL
// ─────────────────────────────────────────────────────────────────────────
protected initOrReloadVm = (isReload: boolean) => {
if (!this.featureId) {
// Create mode - return empty view model
return of<FeatureFormVm>({
name: '',
code: '',
status: FeatureStatus.Draft,
isActive: true
});
}
// Edit mode - load from API
return this.featureApi.getById(this.featureId);
};
// ─────────────────────────────────────────────────────────────────────────
// ACTIONS
// ─────────────────────────────────────────────────────────────────────────
onSubmit(): void {
if (!this.validateForm()) return;
const vm = this.currentVm();
this.featureApi
.save(vm)
.pipe(
this.observerLoadingErrorState('save'),
this.tapResponse(
saved => this.onSuccess(saved),
error => this.onError(error)
),
this.untilDestroyed()
)
.subscribe();
}
onCancel(): void {
this.router.navigate(['/features']);
}
// ─────────────────────────────────────────────────────────────────────────
// CONSTRUCTOR
// ─────────────────────────────────────────────────────────────────────────
constructor(
private featureApi: FeatureApiService,
private router: Router
) {
super();
}
}
Pattern 2: Form with Async Validation
export class FeatureFormComponent extends AppBaseFormComponent<FeatureFormVm> {
protected initialFormConfig = () => ({
controls: {
code: new FormControl(
this.currentVm().code,
// Sync validators
[Validators.required, Validators.pattern(/^[A-Z0-9_-]+$/)],
// Async validators (only run if sync pass)
[
ifAsyncValidator(
ctrl => ctrl.valid, // Condition to run
this.checkCodeUniqueValidator()
)
]
),
email: new FormControl(
this.currentVm().email,
[Validators.required, Validators.email],
[
ifAsyncValidator(
() => !this.isViewMode(), // Skip in view mode
this.checkEmailUniqueValidator()
)
]
)
}
});
// ─────────────────────────────────────────────────────────────────────────
// ASYNC VALIDATORS
// ─────────────────────────────────────────────────────────────────────────
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)) // Debounce API calls
);
return exists ? { codeExists: 'Code already exists' } : null;
};
}
private checkEmailUniqueValidator(): AsyncValidatorFn {
return async (control: AbstractControl): Promise<ValidationErrors | null> => {
if (!control.value) return null;
const exists = await firstValueFrom(this.employeeApi.checkEmailExists(control.value, this.currentVm().id));
return exists ? { emailExists: 'Email already in use' } : null;
};
}
}
Pattern 3: Form with Dependent Validation
export class DateRangeFormComponent extends AppBaseFormComponent<DateRangeVm> {
protected initialFormConfig = () => ({
controls: {
startDate: new FormControl(this.currentVm().startDate, [Validators.required]),
endDate: new FormControl(this.currentVm().endDate, [
Validators.required,
// Cross-field validation
startEndValidator(
'invalidRange',
ctrl => ctrl.parent?.get('startDate')?.value,
ctrl => ctrl.value,
{ allowEqual: true }
)
]),
category: new FormControl(this.currentVm().category, [Validators.required]),
subcategory: new FormControl(this.currentVm().subcategory, [Validators.required])
},
// Re-validate these fields when dependencies change
dependentValidations: {
endDate: ['startDate'], // Re-validate endDate when startDate changes
subcategory: ['category'] // Re-validate subcategory when category changes
}
});
// Custom cross-field validator
private dateRangeValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const form = control.parent;
if (!form) return null;
const start = form.get('startDate')?.value;
const end = control.value;
if (start && end && new Date(end) < new Date(start)) {
return { invalidRange: 'End date must be after start date' };
}
return null;
};
}
}
Pattern 4: Form with FormArray
export interface ProductFormVm {
name: string;
price: number;
specifications: Specification[];
tags: string[];
}
export interface Specification {
name: string;
value: string;
}
export class ProductFormComponent extends AppBaseFormComponent<ProductFormVm> {
protected initialFormConfig = () => ({
controls: {
name: new FormControl(this.currentVm().name, [Validators.required]),
price: new FormControl(this.currentVm().price, [Validators.required, Validators.min(0)]),
// FormArray configuration
specifications: {
// Model items to create controls from
modelItems: () => this.currentVm().specifications,
// How to create control for each item
itemControl: (spec: Specification, index: number) =>
new FormGroup({
name: new FormControl(spec.name, [Validators.required]),
value: new FormControl(spec.value, [Validators.required])
})
},
// Simple array of primitives
tags: {
modelItems: () => this.currentVm().tags,
itemControl: (tag: string) => new FormControl(tag, [Validators.required])
}
}
});
// ─────────────────────────────────────────────────────────────────────────
// FORM ARRAY HELPERS
// ─────────────────────────────────────────────────────────────────────────
get specificationsArray(): FormArray {
return this.form.get('specifications') as FormArray;
}
addSpecification(): void {
const newSpec: Specification = { name: '', value: '' };
// Update view model
this.updateVm(vm => ({
specifications: [...vm.specifications, newSpec]
}));
// Add form control
this.specificationsArray.push(
new FormGroup({
name: new FormControl('', [Validators.required]),
value: new FormControl('', [Validators.required])
})
);
}
removeSpecification(index: number): void {
// Update view model
this.updateVm(vm => ({
specifications: vm.specifications.filter((_, i) => i !== index)
}));
// Remove form control
this.specificationsArray.removeAt(index);
}
}
Template Patterns
Basic Form Template
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Text input -->
<div class="form-field">
<label for="name">Name *</label>
<input id="name" formControlName="name" />
@if (formControls('name').errors?.['required'] && formControls('name').touched) {
<span class="error">Name is required</span>
} @if (formControls('name').errors?.['maxlength']) {
<span class="error">Name is too long</span>
}
</div>
<!-- Async validation feedback -->
<div class="form-field">
<label for="code">Code *</label>
<input id="code" formControlName="code" />
@if (formControls('code').pending) {
<span class="info">Checking availability...</span>
} @if (formControls('code').errors?.['codeExists']) {
<span class="error">{{ formControls('code').errors?.['codeExists'] }}</span>
}
</div>
<!-- Select dropdown -->
<div class="form-field">
<label for="status">Status *</label>
<select id="status" formControlName="status">
@for (option of statusOptions; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
</div>
<!-- Checkbox -->
<div class="form-field">
<label>
<input type="checkbox" formControlName="isActive" />
Active
</label>
</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>
FormArray Template
<div formArrayName="specifications">
@for (spec of specificationsArray.controls; track $index; let i = $index) {
<div [formGroupName]="i" class="specification-row">
<input formControlName="name" placeholder="Name" />
<input formControlName="value" placeholder="Value" />
<button type="button" (click)="removeSpecification(i)">Remove</button>
</div>
}
<button type="button" (click)="addSpecification()">Add Specification</button>
</div>
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; |
Component HTML Template Standard (BEM Classes)
All UI elements in form templates MUST have BEM classes, even without styling needs. This makes forms self-documenting.
<!-- ✅ CORRECT: All form elements have BEM classes -->
<form class="feature-form" [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="feature-form__section">
<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>
}
</div>
</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">Save</button>
</div>
</form>
<!-- ❌ WRONG: Missing BEM classes -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<div>
<label for="name">Name *</label>
<input id="name" formControlName="name" />
</div>
</div>
<div>
<button type="button">Cancel</button>
<button type="submit">Save</button>
</div>
</form>
Anti-Patterns to AVOID
:x: Missing BEM classes on form elements
<!-- WRONG -->
<div><label>Name</label><input formControlName="name" /></div>
<!-- CORRECT -->
<div class="form__field">
<label class="form__label">Name</label>
<input class="form__input" formControlName="name" />
</div>
:x: Not using validateForm()
// WRONG - form may be invalid
onSubmit() {
this.api.save(this.currentVm());
}
// CORRECT - validate first
onSubmit() {
if (!this.validateForm()) return;
this.api.save(this.currentVm());
}
:x: Async validator always runs
// WRONG - runs even if sync validators fail
new FormControl('', [], [asyncValidator]);
// CORRECT - conditional
new FormControl('', [], [ifAsyncValidator(ctrl => ctrl.valid, asyncValidator)]);
:x: Missing form group name in array
<!-- WRONG -->
@for (item of formArray.controls; track $index) {
<input formControlName="name" />
}
<!-- CORRECT -->
@for (item of formArray.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input formControlName="name" />
</div>
}
Verification Checklist
-
initialFormConfigreturns form configuration -
initOrReloadVmloads data for edit mode -
validateForm()called before submit - Async validators use
ifAsyncValidatorfor conditional execution -
dependentValidationsconfigured for cross-field validation - FormArrays use
modelItemsanditemControl - Error messages displayed for all validation rules
- Loading states shown during async operations
Task Planning Notes
- Always plan and break many small todo tasks
- Always add a final review todo task to review the works done at the end to find any fix or enhancement needed
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-01-28
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!