Forms in Angular¶
Course Objective¶
Master Angular Forms to build sophisticated user interfaces with validation, dynamic controls, and excellent user experience using both template-driven and reactive approaches.
1. Introduction to Angular Forms¶
Angular provides two approaches to handling user input through forms:
- Template-driven forms: Ideal for simple forms with minimal logic
- Reactive forms: Better for complex forms with dynamic behavior
Key Concepts¶
- FormControl: Tracks the value and validation status of an individual control
- FormGroup: Collection of form controls with validation
- FormArray: Dynamic array of form controls
- FormBuilder: Service to create form controls
- Validators: Functions that validate form inputs
When to Use Each Approach¶
| Template-Driven | Reactive |
|---|---|
| Simple forms | Complex forms |
| Static structure | Dynamic structure |
| Minimal validation | Complex validation |
| Quick prototyping | Production applications |
2. Template-Driven Forms¶
Basic Setup¶
// app.module.ts (Traditional)
import { FormsModule } from '@angular/forms';
@NgModule({
imports: [FormsModule],
// ...
})
export class AppModule { }
// main.ts (Standalone)
import { bootstrapApplication } from '@angular/platform-browser';
import { importProvidersFrom } from '@angular/core';
import { FormsModule } from '@angular/forms';
bootstrapApplication(AppComponent, {
providers: [
importProvidersFrom(FormsModule)
]
});
Simple Template-Driven Form¶
// components/contact-form.component.ts
import { Component } from '@angular/core';
import { NgForm, FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
export interface Contact {
name: string;
email: string;
phone: string;
message: string;
category: string;
subscribe: boolean;
}
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<form #contactForm="ngForm" (ngSubmit)="onSubmit(contactForm)" class="contact-form">
<h2>Contact Us</h2>
<!-- Name Field -->
<div class="form-group">
<label for="name">Name *</label>
<input
type="text"
id="name"
name="name"
[(ngModel)]="contact.name"
#name="ngModel"
required
minlength="2"
class="form-control"
[class.invalid]="name.invalid && name.touched"
>
<div class="error-messages" *ngIf="name.invalid && name.touched">
<div *ngIf="name.errors?.['required']">Name is required</div>
<div *ngIf="name.errors?.['minlength']">Name must be at least 2 characters</div>
</div>
</div>
<!-- Email Field -->
<div class="form-group">
<label for="email">Email *</label>
<input
type="email"
id="email"
name="email"
[(ngModel)]="contact.email"
#email="ngModel"
required
email
class="form-control"
[class.invalid]="email.invalid && email.touched"
>
<div class="error-messages" *ngIf="email.invalid && email.touched">
<div *ngIf="email.errors?.['required']">Email is required</div>
<div *ngIf="email.errors?.['email']">Please enter a valid email</div>
</div>
</div>
<!-- Phone Field -->
<div class="form-group">
<label for="phone">Phone</label>
<input
type="tel"
id="phone"
name="phone"
[(ngModel)]="contact.phone"
#phone="ngModel"
pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"
class="form-control"
[class.invalid]="phone.invalid && phone.touched"
placeholder="123-456-7890"
>
<div class="error-messages" *ngIf="phone.invalid && phone.touched">
<div *ngIf="phone.errors?.['pattern']">Please enter phone in format: 123-456-7890</div>
</div>
</div>
<!-- Category Dropdown -->
<div class="form-group">
<label for="category">Category *</label>
<select
id="category"
name="category"
[(ngModel)]="contact.category"
#category="ngModel"
required
class="form-control"
[class.invalid]="category.invalid && category.touched"
>
<option value="">Select a category</option>
<option value="general">General Inquiry</option>
<option value="support">Support</option>
<option value="sales">Sales</option>
<option value="feedback">Feedback</option>
</select>
<div class="error-messages" *ngIf="category.invalid && category.touched">
<div *ngIf="category.errors?.['required']">Please select a category</div>
</div>
</div>
<!-- Message Textarea -->
<div class="form-group">
<label for="message">Message *</label>
<textarea
id="message"
name="message"
[(ngModel)]="contact.message"
#message="ngModel"
required
minlength="10"
maxlength="500"
rows="4"
class="form-control"
[class.invalid]="message.invalid && message.touched"
placeholder="Please describe your inquiry..."
></textarea>
<div class="char-count">
{{ contact.message.length }} / 500 characters
</div>
<div class="error-messages" *ngIf="message.invalid && message.touched">
<div *ngIf="message.errors?.['required']">Message is required</div>
<div *ngIf="message.errors?.['minlength']">Message must be at least 10 characters</div>
</div>
</div>
<!-- Checkbox -->
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
name="subscribe"
[(ngModel)]="contact.subscribe"
>
Subscribe to newsletter
</label>
</div>
<!-- Form Status Display -->
<div class="form-status" *ngIf="contactForm.dirty">
<p>Form Status:
<span [class]="contactForm.valid ? 'valid' : 'invalid'">
{{ contactForm.valid ? 'Valid' : 'Invalid' }}
</span>
</p>
</div>
<!-- Submit Button -->
<div class="form-actions">
<button
type="submit"
[disabled]="contactForm.invalid || isSubmitting"
class="btn btn-primary"
>
{{ isSubmitting ? 'Submitting...' : 'Send Message' }}
</button>
<button
type="button"
(click)="resetForm(contactForm)"
class="btn btn-secondary"
>
Reset
</button>
</div>
</form>
`,
styles: [`
.contact-form {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.form-control:focus {
border-color: #007bff;
outline: none;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-control.invalid {
border-color: #dc3545;
}
.error-messages {
margin-top: 0.5rem;
color: #dc3545;
font-size: 0.875rem;
}
.char-count {
margin-top: 0.25rem;
font-size: 0.875rem;
color: #666;
text-align: right;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
}
.form-status .valid {
color: #28a745;
}
.form-status .invalid {
color: #dc3545;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0056b3;
}
.btn-primary:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
`]
})
export class ContactFormComponent {
contact: Contact = {
name: '',
email: '',
phone: '',
message: '',
category: '',
subscribe: false
};
isSubmitting = false;
onSubmit(form: NgForm): void {
if (form.valid) {
this.isSubmitting = true;
// Simulate API call
setTimeout(() => {
console.log('Contact form submitted:', this.contact);
alert('Thank you for your message! We will get back to you soon.');
this.resetForm(form);
this.isSubmitting = false;
}, 2000);
} else {
console.log('Form is invalid');
this.markAllFieldsAsTouched(form);
}
}
resetForm(form: NgForm): void {
form.resetForm();
this.contact = {
name: '',
email: '',
phone: '',
message: '',
category: '',
subscribe: false
};
}
private markAllFieldsAsTouched(form: NgForm): void {
Object.keys(form.controls).forEach(key => {
form.controls[key].markAsTouched();
});
}
}
3. Reactive Forms¶
Basic Setup¶
// app.module.ts (Traditional)
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
imports: [ReactiveFormsModule],
// ...
})
export class AppModule { }
// main.ts (Standalone)
import { importProvidersFrom } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
bootstrapApplication(AppComponent, {
providers: [
importProvidersFrom(ReactiveFormsModule)
]
});
Comprehensive Reactive Form¶
// components/user-registration.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import {
FormBuilder,
FormGroup,
FormControl,
Validators,
AbstractControl,
ReactiveFormsModule
} from '@angular/forms';
import { CommonModule } from '@angular/common';
import { Subject } from 'rxjs';
import { takeUntil, debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-user-registration',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<form [formGroup]="registrationForm" (ngSubmit)="onSubmit()" class="registration-form">
<h2>Create Account</h2>
<!-- Personal Information Section -->
<fieldset>
<legend>Personal Information</legend>
<div class="form-row">
<div class="form-group">
<label for="firstName">First Name *</label>
<input
type="text"
id="firstName"
formControlName="firstName"
class="form-control"
[class.invalid]="isFieldInvalid('firstName')"
>
<div class="error-messages" *ngIf="isFieldInvalid('firstName')">
<div *ngIf="getFieldError('firstName', 'required')">First name is required</div>
<div *ngIf="getFieldError('firstName', 'minlength')">
First name must be at least 2 characters
</div>
</div>
</div>
<div class="form-group">
<label for="lastName">Last Name *</label>
<input
type="text"
id="lastName"
formControlName="lastName"
class="form-control"
[class.invalid]="isFieldInvalid('lastName')"
>
<div class="error-messages" *ngIf="isFieldInvalid('lastName')">
<div *ngIf="getFieldError('lastName', 'required')">Last name is required</div>
</div>
</div>
</div>
<div class="form-group">
<label for="email">Email *</label>
<input
type="email"
id="email"
formControlName="email"
class="form-control"
[class.invalid]="isFieldInvalid('email')"
>
<div class="error-messages" *ngIf="isFieldInvalid('email')">
<div *ngIf="getFieldError('email', 'required')">Email is required</div>
<div *ngIf="getFieldError('email', 'email')">Please enter a valid email</div>
<div *ngIf="getFieldError('email', 'emailTaken')">This email is already registered</div>
</div>
<div class="field-status" *ngIf="emailCheckStatus">
{{ emailCheckStatus }}
</div>
</div>
<div class="form-group">
<label for="phone">Phone</label>
<input
type="tel"
id="phone"
formControlName="phone"
class="form-control"
[class.invalid]="isFieldInvalid('phone')"
placeholder="(555) 123-4567"
>
<div class="error-messages" *ngIf="isFieldInvalid('phone')">
<div *ngIf="getFieldError('phone', 'pattern')">
Please enter a valid phone number
</div>
</div>
</div>
<div class="form-group">
<label for="birthDate">Date of Birth</label>
<input
type="date"
id="birthDate"
formControlName="birthDate"
class="form-control"
[class.invalid]="isFieldInvalid('birthDate')"
>
<div class="error-messages" *ngIf="isFieldInvalid('birthDate')">
<div *ngIf="getFieldError('birthDate', 'minimumAge')">
You must be at least 18 years old
</div>
</div>
</div>
</fieldset>
<!-- Account Information Section -->
<fieldset>
<legend>Account Information</legend>
<div class="form-group">
<label for="username">Username *</label>
<input
type="text"
id="username"
formControlName="username"
class="form-control"
[class.invalid]="isFieldInvalid('username')"
>
<div class="error-messages" *ngIf="isFieldInvalid('username')">
<div *ngIf="getFieldError('username', 'required')">Username is required</div>
<div *ngIf="getFieldError('username', 'minlength')">
Username must be at least 3 characters
</div>
<div *ngIf="getFieldError('username', 'pattern')">
Username can only contain letters, numbers, and underscores
</div>
</div>
</div>
<div class="form-group">
<label for="password">Password *</label>
<input
type="password"
id="password"
formControlName="password"
class="form-control"
[class.invalid]="isFieldInvalid('password')"
>
<div class="password-strength">
<div class="strength-indicator">
<div
class="strength-bar"
[class]="passwordStrength"
[style.width.%]="passwordStrengthWidth"
></div>
</div>
<span class="strength-text">{{ passwordStrengthText }}</span>
</div>
<div class="error-messages" *ngIf="isFieldInvalid('password')">
<div *ngIf="getFieldError('password', 'required')">Password is required</div>
<div *ngIf="getFieldError('password', 'minlength')">
Password must be at least 8 characters
</div>
<div *ngIf="getFieldError('password', 'pattern')">
Password must contain at least one uppercase letter, one lowercase letter, and one number
</div>
</div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password *</label>
<input
type="password"
id="confirmPassword"
formControlName="confirmPassword"
class="form-control"
[class.invalid]="isFieldInvalid('confirmPassword')"
>
<div class="error-messages" *ngIf="isFieldInvalid('confirmPassword')">
<div *ngIf="getFieldError('confirmPassword', 'required')">
Please confirm your password
</div>
<div *ngIf="getFieldError('confirmPassword', 'passwordMismatch')">
Passwords do not match
</div>
</div>
</div>
</fieldset>
<!-- Preferences Section -->
<fieldset>
<legend>Preferences</legend>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" formControlName="agreeToTerms">
I agree to the <a href="/terms" target="_blank">Terms of Service</a> *
</label>
<div class="error-messages" *ngIf="isFieldInvalid('agreeToTerms')">
<div *ngIf="getFieldError('agreeToTerms', 'required')">
You must agree to the terms of service
</div>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" formControlName="subscribeToNewsletter">
Subscribe to newsletter
</label>
</div>
</fieldset>
<!-- Form Actions -->
<div class="form-actions">
<button
type="submit"
[disabled]="registrationForm.invalid || isSubmitting"
class="btn btn-primary"
>
{{ isSubmitting ? 'Creating Account...' : 'Create Account' }}
</button>
<button
type="button"
(click)="resetForm()"
class="btn btn-secondary"
>
Reset
</button>
</div>
<!-- Debug Information (Development Only) -->
<div class="debug-info" *ngIf="showDebugInfo">
<h4>Form Debug Info</h4>
<p>Form Valid: {{ registrationForm.valid }}</p>
<p>Form Value: {{ registrationForm.value | json }}</p>
<p>Form Errors: {{ getFormErrors() | json }}</p>
</div>
</form>
`,
styles: [`
.registration-form {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
fieldset {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
legend {
font-weight: bold;
font-size: 1.2rem;
padding: 0 0.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.form-control:focus {
border-color: #007bff;
outline: none;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-control.invalid {
border-color: #dc3545;
}
.error-messages {
margin-top: 0.5rem;
color: #dc3545;
font-size: 0.875rem;
}
.field-status {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #007bff;
}
.password-strength {
margin-top: 0.5rem;
}
.strength-indicator {
height: 4px;
background-color: #e9ecef;
border-radius: 2px;
overflow: hidden;
}
.strength-bar {
height: 100%;
transition: width 0.3s, background-color 0.3s;
}
.strength-bar.weak { background-color: #dc3545; }
.strength-bar.fair { background-color: #ffc107; }
.strength-bar.good { background-color: #17a2b8; }
.strength-bar.strong { background-color: #28a745; }
.strength-text {
font-size: 0.875rem;
margin-left: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-weight: normal;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0056b3;
}
.btn-primary:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.debug-info {
margin-top: 2rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 4px;
font-family: monospace;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
}
`]
})
export class UserRegistrationComponent implements OnInit, OnDestroy {
registrationForm: FormGroup;
isSubmitting = false;
emailCheckStatus = '';
passwordStrength = 'weak';
passwordStrengthWidth = 0;
passwordStrengthText = '';
showDebugInfo = false; // Set to true for development
private destroy$ = new Subject<void>();
constructor(private fb: FormBuilder) {
this.registrationForm = this.createForm();
}
ngOnInit(): void {
this.setupFormListeners();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private createForm(): FormGroup {
return this.fb.group({
firstName: ['', [Validators.required, Validators.minLength(2)]],
lastName: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email], [this.emailExistsValidator.bind(this)]],
phone: ['', [Validators.pattern(/^\(\d{3}\) \d{3}-\d{4}$/)]],
birthDate: ['', [this.minimumAgeValidator(18)]],
username: ['', [
Validators.required,
Validators.minLength(3),
Validators.pattern(/^[a-zA-Z0-9_]+$/)
]],
password: ['', [
Validators.required,
Validators.minLength(8),
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
]],
confirmPassword: ['', [Validators.required]],
agreeToTerms: [false, [Validators.requiredTrue]],
subscribeToNewsletter: [false]
}, {
validators: [this.passwordMatchValidator]
});
}
private setupFormListeners(): void {
// Email existence check
this.registrationForm.get('email')?.valueChanges
.pipe(
takeUntil(this.destroy$),
debounceTime(500),
distinctUntilChanged()
)
.subscribe(email => {
if (email && this.registrationForm.get('email')?.valid) {
this.emailCheckStatus = 'Checking availability...';
}
});
// Password strength check
this.registrationForm.get('password')?.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(password => {
this.updatePasswordStrength(password);
});
}
// Custom Validators
private emailExistsValidator(control: AbstractControl): Promise<any> {
if (!control.value) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
// Simulate API call
setTimeout(() => {
const existingEmails = ['test@example.com', 'admin@example.com'];
if (existingEmails.includes(control.value)) {
this.emailCheckStatus = 'Email is already taken';
resolve({ emailTaken: true });
} else {
this.emailCheckStatus = 'Email is available';
resolve(null);
}
}, 1000);
});
}
private minimumAgeValidator(minAge: number) {
return (control: AbstractControl) => {
if (!control.value) {
return null;
}
const birthDate = new Date(control.value);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age >= minAge ? null : { minimumAge: { requiredAge: minAge, actualAge: age } };
};
}
private passwordMatchValidator(group: AbstractControl) {
const password = group.get('password');
const confirmPassword = group.get('confirmPassword');
if (!password || !confirmPassword) {
return null;
}
return password.value === confirmPassword.value ? null : { passwordMismatch: true };
}
private updatePasswordStrength(password: string): void {
if (!password) {
this.passwordStrength = 'weak';
this.passwordStrengthWidth = 0;
this.passwordStrengthText = '';
return;
}
let score = 0;
if (password.length >= 8) score++;
if (/[a-z]/.test(password)) score++;
if (/[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z\d]/.test(password)) score++;
switch (score) {
case 0:
case 1:
this.passwordStrength = 'weak';
this.passwordStrengthWidth = 25;
this.passwordStrengthText = 'Weak';
break;
case 2:
case 3:
this.passwordStrength = 'fair';
this.passwordStrengthWidth = 50;
this.passwordStrengthText = 'Fair';
break;
case 4:
this.passwordStrength = 'good';
this.passwordStrengthWidth = 75;
this.passwordStrengthText = 'Good';
break;
case 5:
this.passwordStrength = 'strong';
this.passwordStrengthWidth = 100;
this.passwordStrengthText = 'Strong';
break;
}
}
// Utility Methods
isFieldInvalid(fieldName: string): boolean {
const field = this.registrationForm.get(fieldName);
return field ? field.invalid && (field.dirty || field.touched) : false;
}
getFieldError(fieldName: string, errorType: string): boolean {
const field = this.registrationForm.get(fieldName);
return field ? field.hasError(errorType) : false;
}
getFormErrors(): any {
const formErrors: any = {};
Object.keys(this.registrationForm.controls).forEach(key => {
const controlErrors = this.registrationForm.get(key)?.errors;
if (controlErrors) {
formErrors[key] = controlErrors;
}
});
return formErrors;
}
onSubmit(): void {
if (this.registrationForm.valid) {
this.isSubmitting = true;
const formData = this.registrationForm.value;
delete formData.confirmPassword; // Remove confirm password from submission
// Simulate API call
setTimeout(() => {
console.log('Registration data:', formData);
alert('Account created successfully!');
this.resetForm();
this.isSubmitting = false;
}, 2000);
} else {
this.markAllFieldsAsTouched();
}
}
resetForm(): void {
this.registrationForm.reset();
this.emailCheckStatus = '';
this.passwordStrength = 'weak';
this.passwordStrengthWidth = 0;
this.passwordStrengthText = '';
}
private markAllFieldsAsTouched(): void {
Object.keys(this.registrationForm.controls).forEach(key => {
this.registrationForm.get(key)?.markAsTouched();
});
}
}
4. Form Validation¶
Built-in Validators¶
import { Validators, AbstractControl, ValidationErrors } from '@angular/forms';
// Common built-in validators
export class FormValidationExamples {
createFormWithValidators() {
return this.fb.group({
// Required field
name: ['', Validators.required],
// Email validation
email: ['', [Validators.required, Validators.email]],
// Length validation
username: ['', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(20)
]],
// Pattern validation
phone: ['', Validators.pattern(/^\d{3}-\d{3}-\d{4}$/)],
// Numeric validation
age: ['', [
Validators.required,
Validators.min(18),
Validators.max(120)
]],
// Multiple validators
password: ['', [
Validators.required,
Validators.minLength(8),
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
]]
});
}
}
Error Message Component¶
// components/error-message.component.ts
import { Component, Input } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-error-message',
standalone: true,
imports: [CommonModule],
template: `
<div class="error-messages" *ngIf="shouldShowError">
<div *ngFor="let error of errorMessages">
{{ error }}
</div>
</div>
`,
styles: [`
.error-messages {
margin-top: 0.5rem;
color: #dc3545;
font-size: 0.875rem;
}
`]
})
export class ErrorMessageComponent {
@Input() control: AbstractControl | null = null;
@Input() fieldName = '';
get shouldShowError(): boolean {
return !!(this.control && this.control.invalid && (this.control.dirty || this.control.touched));
}
get errorMessages(): string[] {
if (!this.control || !this.control.errors) {
return [];
}
const messages: string[] = [];
const errors = this.control.errors;
if (errors['required']) {
messages.push(`${this.fieldName} is required`);
}
if (errors['email']) {
messages.push('Please enter a valid email address');
}
if (errors['minlength']) {
const requiredLength = errors['minlength'].requiredLength;
messages.push(`${this.fieldName} must be at least ${requiredLength} characters`);
}
if (errors['maxlength']) {
const requiredLength = errors['maxlength'].requiredLength;
messages.push(`${this.fieldName} cannot exceed ${requiredLength} characters`);
}
if (errors['pattern']) {
messages.push(`${this.fieldName} format is invalid`);
}
if (errors['min']) {
messages.push(`${this.fieldName} must be at least ${errors['min'].min}`);
}
if (errors['max']) {
messages.push(`${this.fieldName} cannot exceed ${errors['max'].max}`);
}
return messages;
}
}
// Usage in forms
/*
<input formControlName="email" class="form-control">
<app-error-message [control]="form.get('email')" fieldName="Email"></app-error-message>
*/
5. Custom Validators¶
Sync Custom Validators¶
// validators/custom-validators.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export class CustomValidators {
// Password strength validator
static passwordStrength(control: AbstractControl): ValidationErrors | null {
const value = control.value;
if (!value) {
return null;
}
const hasUpperCase = /[A-Z]/.test(value);
const hasLowerCase = /[a-z]/.test(value);
const hasNumeric = /[0-9]/.test(value);
const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(value);
const passwordValid = hasUpperCase && hasLowerCase && hasNumeric && hasSpecialChar;
if (!passwordValid) {
return {
passwordStrength: {
hasUpperCase,
hasLowerCase,
hasNumeric,
hasSpecialChar
}
};
}
return null;
}
// Age range validator
static ageRange(min: number, max: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) {
return null;
}
const age = parseInt(control.value, 10);
if (isNaN(age) || age < min || age > max) {
return {
ageRange: {
min,
max,
actual: age
}
};
}
return null;
};
}
// Credit card validator
static creditCard(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null;
}
const value = control.value.replace(/\s/g, '');
// Luhn algorithm
let sum = 0;
let isEven = false;
for (let i = value.length - 1; i >= 0; i--) {
let digit = parseInt(value.charAt(i), 10);
if (isEven) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
isEven = !isEven;
}
return sum % 10 === 0 ? null : { creditCard: true };
}
// URL validator
static url(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null;
}
try {
new URL(control.value);
return null;
} catch {
return { url: true };
}
}
// No whitespace validator
static noWhitespace(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null;
}
const isWhitespace = (control.value || '').trim().length === 0;
return isWhitespace ? { whitespace: true } : null;
}
// Date range validator
static dateRange(startDate: Date, endDate: Date): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) {
return null;
}
const inputDate = new Date(control.value);
if (inputDate < startDate || inputDate > endDate) {
return {
dateRange: {
min: startDate,
max: endDate,
actual: inputDate
}
};
}
return null;
};
}
}
Async Custom Validators¶
// validators/async-validators.ts
import { Injectable } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { map, switchMap, catchError } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class AsyncValidators {
constructor(private http: HttpClient) {}
// Check if username is available
usernameAvailable(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
return timer(500).pipe( // Debounce
switchMap(() =>
this.http.get<{available: boolean}>(`/api/check-username/${control.value}`)
),
map(response => response.available ? null : { usernameTaken: true }),
catchError(() => of(null)) // Return null on error
);
};
}
// Email existence validator
emailExists(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
return timer(300).pipe(
switchMap(() =>
this.http.post<{exists: boolean}>('/api/check-email', { email: control.value })
),
map(response => response.exists ? { emailExists: true } : null),
catchError(() => of(null))
);
};
}
// Domain validation (check if domain exists)
domainValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
try {
const url = new URL(control.value);
const domain = url.hostname;
return this.http.get(`https://dns.google/resolve?name=${domain}&type=A`).pipe(
map(() => null), // Domain exists
catchError(() => of({ invalidDomain: true }))
);
} catch {
return of({ invalidUrl: true });
}
};
}
}
6. Dynamic Forms¶
Dynamic Form Builder¶
// models/form-config.interface.ts
export interface FormFieldConfig {
type: 'text' | 'email' | 'password' | 'number' | 'select' | 'checkbox' | 'textarea' | 'date';
key: string;
label: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
options?: { value: any; label: string; }[];
validators?: any[];
asyncValidators?: any[];
cssClass?: string;
description?: string;
group?: string;
}
export interface FormConfig {
title: string;
description?: string;
fields: FormFieldConfig[];
submitButtonText?: string;
}
// components/dynamic-form.component.ts
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { FormConfig, FormFieldConfig } from '../models/form-config.interface';
@Component({
selector: 'app-dynamic-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<form [formGroup]="dynamicForm" (ngSubmit)="onSubmit()" class="dynamic-form">
<h2 *ngIf="config.title">{{ config.title }}</h2>
<p *ngIf="config.description" class="form-description">{{ config.description }}</p>
<div *ngFor="let group of fieldGroups; trackBy: trackByGroup" class="field-group">
<h3 *ngIf="group.name" class="group-title">{{ group.name }}</h3>
<div *ngFor="let field of group.fields; trackBy: trackByField"
class="form-field"
[ngClass]="field.cssClass">
<!-- Text Input -->
<div *ngIf="field.type === 'text' || field.type === 'email' || field.type === 'password'"
class="form-group">
<label [for]="field.key">
{{ field.label }}
<span *ngIf="field.required" class="required">*</span>
</label>
<input
[id]="field.key"
[type]="field.type"
[formControlName]="field.key"
[placeholder]="field.placeholder || ''"
class="form-control"
[class.invalid]="isFieldInvalid(field.key)"
>
<small *ngIf="field.description" class="field-description">
{{ field.description }}
</small>
<app-error-message
[control]="dynamicForm.get(field.key)"
[fieldName]="field.label">
</app-error-message>
</div>
<!-- Number Input -->
<div *ngIf="field.type === 'number'" class="form-group">
<label [for]="field.key">
{{ field.label }}
<span *ngIf="field.required" class="required">*</span>
</label>
<input
[id]="field.key"
type="number"
[formControlName]="field.key"
[placeholder]="field.placeholder || ''"
class="form-control"
[class.invalid]="isFieldInvalid(field.key)"
>
<small *ngIf="field.description" class="field-description">
{{ field.description }}
</small>
<app-error-message
[control]="dynamicForm.get(field.key)"
[fieldName]="field.label">
</app-error-message>
</div>
<!-- Date Input -->
<div *ngIf="field.type === 'date'" class="form-group">
<label [for]="field.key">
{{ field.label }}
<span *ngIf="field.required" class="required">*</span>
</label>
<input
[id]="field.key"
type="date"
[formControlName]="field.key"
class="form-control"
[class.invalid]="isFieldInvalid(field.key)"
>
<small *ngIf="field.description" class="field-description">
{{ field.description }}
</small>
<app-error-message
[control]="dynamicForm.get(field.key)"
[fieldName]="field.label">
</app-error-message>
</div>
<!-- Select Dropdown -->
<div *ngIf="field.type === 'select'" class="form-group">
<label [for]="field.key">
{{ field.label }}
<span *ngIf="field.required" class="required">*</span>
</label>
<select
[id]="field.key"
[formControlName]="field.key"
class="form-control"
[class.invalid]="isFieldInvalid(field.key)"
>
<option value="">{{ field.placeholder || 'Select an option' }}</option>
<option *ngFor="let option of field.options" [value]="option.value">
{{ option.label }}
</option>
</select>
<small *ngIf="field.description" class="field-description">
{{ field.description }}
</small>
<app-error-message
[control]="dynamicForm.get(field.key)"
[fieldName]="field.label">
</app-error-message>
</div>
<!-- Textarea -->
<div *ngIf="field.type === 'textarea'" class="form-group">
<label [for]="field.key">
{{ field.label }}
<span *ngIf="field.required" class="required">*</span>
</label>
<textarea
[id]="field.key"
[formControlName]="field.key"
[placeholder]="field.placeholder || ''"
rows="4"
class="form-control"
[class.invalid]="isFieldInvalid(field.key)"
></textarea>
<small *ngIf="field.description" class="field-description">
{{ field.description }}
</small>
<app-error-message
[control]="dynamicForm.get(field.key)"
[fieldName]="field.label">
</app-error-message>
</div>
<!-- Checkbox -->
<div *ngIf="field.type === 'checkbox'" class="form-group">
<label class="checkbox-label">
<input
[id]="field.key"
type="checkbox"
[formControlName]="field.key"
>
{{ field.label }}
<span *ngIf="field.required" class="required">*</span>
</label>
<small *ngIf="field.description" class="field-description">
{{ field.description }}
</small>
<app-error-message
[control]="dynamicForm.get(field.key)"
[fieldName]="field.label">
</app-error-message>
</div>
</div>
</div>
<div class="form-actions">
<button
type="submit"
[disabled]="dynamicForm.invalid || isSubmitting"
class="btn btn-primary"
>
{{ isSubmitting ? 'Submitting...' : (config.submitButtonText || 'Submit') }}
</button>
<button
type="button"
(click)="resetForm()"
class="btn btn-secondary"
>
Reset
</button>
</div>
</form>
`,
styles: [`
.dynamic-form {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.form-description {
margin-bottom: 2rem;
color: #666;
}
.field-group {
margin-bottom: 2rem;
padding: 1.5rem;
border: 1px solid #e9ecef;
border-radius: 8px;
}
.group-title {
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 1.2rem;
font-weight: bold;
color: #333;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.required {
color: #dc3545;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.form-control:focus {
border-color: #007bff;
outline: none;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-control.invalid {
border-color: #dc3545;
}
.field-description {
display: block;
margin-top: 0.25rem;
color: #666;
font-size: 0.875rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e9ecef;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0056b3;
}
.btn-primary:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
`]
})
export class DynamicFormComponent implements OnInit {
@Input() config!: FormConfig;
@Input() initialData: any = {};
@Output() formSubmit = new EventEmitter<any>();
@Output() formChange = new EventEmitter<any>();
dynamicForm!: FormGroup;
isSubmitting = false;
fieldGroups: { name: string; fields: FormFieldConfig[] }[] = [];
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
this.createForm();
this.organizeFieldsByGroup();
}
private createForm(): void {
const group: any = {};
this.config.fields.forEach(field => {
const validators = this.buildValidators(field);
const asyncValidators = field.asyncValidators || [];
group[field.key] = [
{ value: this.initialData[field.key] || '', disabled: field.disabled },
validators,
asyncValidators
];
});
this.dynamicForm = this.fb.group(group);
// Listen to form changes
this.dynamicForm.valueChanges.subscribe(value => {
this.formChange.emit(value);
});
}
private buildValidators(field: FormFieldConfig): any[] {
const validators: any[] = [];
if (field.required) {
validators.push(Validators.required);
}
if (field.type === 'email') {
validators.push(Validators.email);
}
// Add custom validators from field config
if (field.validators) {
validators.push(...field.validators);
}
return validators;
}
private organizeFieldsByGroup(): void {
const groups: { [key: string]: FormFieldConfig[] } = {};
this.config.fields.forEach(field => {
const groupName = field.group || 'default';
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(field);
});
this.fieldGroups = Object.keys(groups).map(groupName => ({
name: groupName === 'default' ? '' : groupName,
fields: groups[groupName]
}));
}
isFieldInvalid(fieldName: string): boolean {
const field = this.dynamicForm.get(fieldName);
return field ? field.invalid && (field.dirty || field.touched) : false;
}
onSubmit(): void {
if (this.dynamicForm.valid) {
this.isSubmitting = true;
this.formSubmit.emit(this.dynamicForm.value);
} else {
this.markAllFieldsAsTouched();
}
}
resetForm(): void {
this.dynamicForm.reset();
this.isSubmitting = false;
}
private markAllFieldsAsTouched(): void {
Object.keys(this.dynamicForm.controls).forEach(key => {
this.dynamicForm.get(key)?.markAsTouched();
});
}
trackByGroup(index: number, group: any): string {
return group.name;
}
trackByField(index: number, field: FormFieldConfig): string {
return field.key;
}
}
Using Dynamic Forms¶
// components/survey.component.ts
import { Component } from '@angular/core';
import { FormConfig } from '../models/form-config.interface';
import { DynamicFormComponent } from './dynamic-form.component';
@Component({
selector: 'app-survey',
standalone: true,
imports: [DynamicFormComponent],
template: `
<app-dynamic-form
[config]="surveyConfig"
[initialData]="initialData"
(formSubmit)="onSurveySubmit($event)"
(formChange)="onFormChange($event)">
</app-dynamic-form>
`
})
export class SurveyComponent {
surveyConfig: FormConfig = {
title: 'Customer Satisfaction Survey',
description: 'Please help us improve by sharing your feedback.',
submitButtonText: 'Submit Survey',
fields: [
{
type: 'text',
key: 'name',
label: 'Full Name',
required: true,
group: 'Personal Information'
},
{
type: 'email',
key: 'email',
label: 'Email Address',
required: true,
group: 'Personal Information'
},
{
type: 'select',
key: 'satisfaction',
label: 'Overall Satisfaction',
required: true,
options: [
{ value: 'very-satisfied', label: 'Very Satisfied' },
{ value: 'satisfied', label: 'Satisfied' },
{ value: 'neutral', label: 'Neutral' },
{ value: 'dissatisfied', label: 'Dissatisfied' },
{ value: 'very-dissatisfied', label: 'Very Dissatisfied' }
],
group: 'Feedback'
},
{
type: 'textarea',
key: 'comments',
label: 'Additional Comments',
placeholder: 'Please share any additional feedback...',
group: 'Feedback'
},
{
type: 'checkbox',
key: 'subscribe',
label: 'Subscribe to our newsletter',
group: 'Preferences'
}
]
};
initialData = {
name: '',
email: '',
satisfaction: '',
comments: '',
subscribe: false
};
onSurveySubmit(data: any): void {
console.log('Survey submitted:', data);
// Handle survey submission
}
onFormChange(data: any): void {
console.log('Form changed:', data);
// Handle form changes (auto-save, etc.)
}
}
I'll continue this comprehensive forms document in the next response, covering Form Arrays, Cross-Field Validation, State Management, Testing, and Best Practices. Would you like me to continue with the remaining sections?