| name | angular-form |
| description | Use when creating reactive forms with validation, async validators, dependent validation, and FormArrays using platform patterns. |
| allowed-tools | Read, Write, Edit, Grep, Glob, Bash |
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)
- List all validation rules (sync and async)
- Identify field dependencies
- Search similar forms:
grep "{Feature}FormComponent" --include="*.ts"
File Location
src/PlatformExampleAppWeb/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 (from @libs/platform-core) |
AppBaseFormComponent |
Form with auth context (optional app-specific extension) |
Note:
PlatformFormComponentis exported from@libs/platform-core.AppBaseFormComponentis an optional app-specific extension you can create to add auth context and company scope to your forms. If your app doesn't have AppBase classes, use the Platform classes directly.
Pattern 1: Basic Form
// {feature}-form.component.ts
import { Component, Input } from "@angular/core";
import { FormControl, Validators } from "@angular/forms";
import {
PlatformFormComponent,
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 PlatformFormComponent<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 PlatformFormComponent<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 PlatformFormComponent<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 PlatformFormComponent<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; |
Anti-Patterns to AVOID
: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