# Angular Form

> Use when creating reactive forms with validation, async validators, dependent validation, and FormArrays using platform patterns.

- Skill: `majiayu000/angular-form` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds add majiayu000/angular-form`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/angular-form/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/angular-form

---


# 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:** `PlatformFormComponent` is exported from `@libs/platform-core`. `AppBaseFormComponent` is 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

```typescript
// {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

```typescript
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

```typescript
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

```typescript
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

```html
<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

```html
<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()**

```typescript
// 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**

```typescript
// 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**

```html
<!-- 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

- [ ] `initialFormConfig` returns form configuration
- [ ] `initOrReloadVm` loads data for edit mode
- [ ] `validateForm()` called before submit
- [ ] Async validators use `ifAsyncValidator` for conditional execution
- [ ] `dependentValidations` configured for cross-field validation
- [ ] FormArrays use `modelItems` and `itemControl`
- [ ] Error messages displayed for all validation rules
- [ ] Loading states shown during async operations

