Task Form, Dashboard, and Reactive Forms Patterns

The task form is where users create and edit tasks โ€” a rich form with title, description, priority, due date, tag management, and assignee selection. It demonstrates every Angular reactive form pattern from Chapters 13โ€“14: FormBuilder for schema, custom validators, async validators, ControlValueAccessor components, dynamic form arrays, and the interplay between form validity and submit button state. The workspace dashboard brings together the aggregation pipeline from Chapter 13 with chart rendering, demonstrating full-stack data flow from MongoDB aggregation to an Angular visualisation.

Task Form Field Architecture

Field Control Type Validation UX Pattern
Title FormControl<string> required, minLength(1), maxLength(500) Character counter, error on blur
Description FormControl<string | null> maxLength(10000) Expandable textarea, markdown preview
Priority FormControl<TaskPriority> required, enum Coloured button group (not dropdown)
Due date FormControl<string | null> after today (custom validator) Date picker with clear button
Tags FormArray<FormControl<string>> maxLength(50) per tag, max 10 tags Tag chips with add/remove
Assignees FormControl<string[]> members of workspace only Multi-select with avatar chips
Note: The tags input is implemented as a custom ControlValueAccessor component โ€” it looks like a single input but internally manages an array of strings. The parent form sees it as a FormControl<string[]> and validates the array. The ControlValueAccessor handles the visual chip management (add on Enter/comma, remove on X click) and calls onChange(currentTags) whenever the array changes. This encapsulates the complex tag UI behind a simple form interface.
Tip: Use updateOn: 'blur' for expensive validators (like an async uniqueness check) to avoid running validation on every keystroke. For the title field: new FormControl('', { validators: [Validators.required], updateOn: 'blur' }). Error messages show after the user leaves the field, not while they are still typing. This is the standard UX pattern โ€” errors appear at the moment the user has finished with a field and moved on, not while they are mid-type.
Warning: Always call form.markAllAsTouched() when the user attempts to submit an invalid form. Without this, validation error messages only show for fields the user has interacted with โ€” a user who clicks Submit on an empty form sees no errors at all, because no field has been “touched”. markAllAsTouched() marks all controls as touched simultaneously, triggering all error message displays and making the full validation state visible.

Complete Task Form and Dashboard

// โ”€โ”€ features/tasks/components/task-form/task-form.component.ts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
import { Component, Input, Output, EventEmitter, OnInit, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, FormArray,
         Validators, AbstractControl, ValidationErrors }          from '@angular/forms';
import { CommonModule }  from '@angular/common';
import { Task, CreateTaskDto, UpdateTaskDto, TaskPriority } from '@taskmanager/shared';

function futureDateValidator(control: AbstractControl): ValidationErrors | null {
    if (!control.value) return null;
    const date = new Date(control.value);
    return date > new Date() ? null : { pastDate: 'Due date must be in the future' };
}

@Component({
    selector:   'tm-task-form',
    standalone: true,
    changeDetection: ChangeDetectionStrategy.OnPush,
    imports: [ReactiveFormsModule, CommonModule, TagInputComponent, AssigneeSelectorComponent],
    template: `
        <form [formGroup]="form" (ngSubmit)="onSubmit()" class="task-form">

            <!-- Title -->
            <div class="form-field">
                <label for="title">Title <span class="required">*</span></label>
                <input id="title" formControlName="title" type="text"
                       placeholder="What needs to be done?"
                       [class.error]="titleCtrl.invalid && titleCtrl.touched">
                <div class="char-count">{{ titleCtrl.value?.length || 0 }} / 500</div>
                @if (titleCtrl.invalid && titleCtrl.touched) {
                    <span class="field-error" data-testid="title-error">
                        {{ titleCtrl.errors?.['required'] ? 'Title is required' :
                           titleCtrl.errors?.['maxlength'] ? 'Title is too long' : '' }}
                    </span>
                }
            </div>

            <!-- Description -->
            <div class="form-field">
                <label for="description">Description</label>
                <textarea id="description" formControlName="description"
                          rows="4" placeholder="Add more details..."></textarea>
            </div>

            <!-- Priority -->
            <div class="form-field">
                <label>Priority</label>
                <div class="priority-group">
                    @for (p of priorities; track p.value) {
                        <button type="button"
                                [class]="'priority-btn priority-btn--' + p.value"
                                [class.active]="form.get('priority')?.value === p.value"
                                (click)="form.get('priority')!.setValue(p.value)">
                            {{ p.label }}
                        </button>
                    }
                </div>
            </div>

            <!-- Due date -->
            <div class="form-field">
                <label for="dueDate">Due Date</label>
                <input id="dueDate" formControlName="dueDate" type="date">
                @if (dueDateCtrl.errors?.['pastDate'] && dueDateCtrl.touched) {
                    <span class="field-error">Due date must be in the future</span>
                }
            </div>

            <!-- Tags (custom CVA component) -->
            <div class="form-field">
                <label>Tags</label>
                <tm-tag-input formControlName="tags"
                              placeholder="Add tag, press Enter"></tm-tag-input>
            </div>

            <!-- Submit -->
            <div class="form-actions">
                <button type="button" class="btn btn--ghost" (click)="onCancel.emit()">Cancel</button>
                <button type="submit" class="btn btn--primary"
                        [disabled]="form.invalid || saving()"
                        data-testid="submit-btn">
                    {{ saving() ? 'Saving...' : (task ? 'Update Task' : 'Create Task') }}
                </button>
            </div>
        </form>
    `,
})
export class TaskFormComponent implements OnInit {
    @Input() task?: Task;
    @Input() workspaceId!: string;
    @Input() saving = signal(false);
    @Output() save   = new EventEmitter<CreateTaskDto | UpdateTaskDto>();
    @Output() onCancel = new EventEmitter<void>();

    private fb = inject(FormBuilder);

    readonly priorities: { value: TaskPriority; label: string }[] = [
        { value: 'none',   label: 'None'   },
        { value: 'low',    label: 'Low'    },
        { value: 'medium', label: 'Medium' },
        { value: 'high',   label: 'High'   },
        { value: 'urgent', label: 'Urgent' },
    ];

    form!: FormGroup;
    get titleCtrl()   { return this.form.get('title')!;   }
    get dueDateCtrl() { return this.form.get('dueDate')!; }

    ngOnInit(): void {
        this.form = this.fb.group({
            title:       [this.task?.title ?? '', [Validators.required, Validators.maxLength(500)]],
            description: [this.task?.description ?? '', Validators.maxLength(10000)],
            priority:    [this.task?.priority ?? 'medium', Validators.required],
            dueDate:     [this.task?.dueDate?.slice(0, 10) ?? '', futureDateValidator],
            tags:        [this.task?.tags ?? []],
            assignees:   [this.task?.assignees ?? []],
        });
    }

    onSubmit(): void {
        if (this.form.invalid) {
            this.form.markAllAsTouched();
            return;
        }

        const value = this.form.value;
        const dto: CreateTaskDto | UpdateTaskDto = {
            title:       value.title.trim(),
            description: value.description?.trim() || undefined,
            priority:    value.priority,
            dueDate:     value.dueDate || undefined,
            tags:        value.tags,
            assignees:   value.assignees,
            ...(this.task ? {} : { workspaceId: this.workspaceId }),
        };

        this.save.emit(dto);
    }
}

// โ”€โ”€ Workspace dashboard โ€” aggregation + charts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// features/workspaces/components/workspace-dashboard.component.ts
@Component({
    selector:   'tm-workspace-dashboard',
    standalone: true,
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
        <div class="dashboard">
            @if (stats(); as s) {
                <!-- KPI cards -->
                <div class="kpi-grid">
                    <tm-kpi-card label="Total Tasks"       [value]="s.total"         icon="list"     />
                    <tm-kpi-card label="Completed"         [value]="s.done"           icon="check"    color="green" />
                    <tm-kpi-card label="Overdue"           [value]="s.overdue"        icon="clock"    color="red" />
                    <tm-kpi-card label="Due This Week"     [value]="s.dueThisWeek"   icon="calendar" color="amber" />
                </div>

                <!-- Status breakdown (donut chart) -->
                <tm-donut-chart [data]="s.byStatus" title="By Status" />

                <!-- Completion trend (line chart) -->
                <tm-line-chart  [data]="s.completionTrend" title="Completed (Last 30 Days)" />

                <!-- Tag cloud -->
                <tm-tag-cloud   [tags]="s.tagCloud" (tagClick)="filterByTag($event)" />
            }
        </div>
    `,
})
export class WorkspaceDashboardComponent implements OnInit {
    @Input() workspaceId!: string;
    private workspaceService = inject(WorkspaceService);

    stats = signal<WorkspaceDashboardData | null>(null);

    ngOnInit(): void {
        this.workspaceService.getDashboard(this.workspaceId).subscribe(data => {
            this.stats.set(data);
        });
    }

    filterByTag(tag: string): void {
        // Navigate to task list with tag filter
    }
}

How It Works

Step 1 โ€” Custom Validators Are Pure Functions

futureDateValidator is a plain function that takes an AbstractControl and returns null (valid) or a ValidationErrors object (invalid). Passing it to Validators` list makes Angular call it on every control value change. Custom validators enable domain-specific rules โ€” "due date must be in the future", "title cannot start with a number", "maximum 10 tags" โ€” that Validators.required and Validators.maxLength cannot express.

Step 2 โ€” markAllAsTouched() Reveals All Errors on Submit

Angular only shows validation errors for "touched" controls โ€” controls the user has interacted with. A form submitted without interaction has no touched controls, so no errors are visible even if the form is invalid. form.markAllAsTouched() marks every control as touched simultaneously, triggering the @if (ctrl.invalid && ctrl.touched) error display for every invalid field. Without this, a click on a disabled submit button provides no feedback on why it is disabled.

Step 3 โ€” TagInputComponent as ControlValueAccessor

Implementing ControlValueAccessor makes <tm-tag-input formControlName="tags"> work as a native form control. The parent form knows nothing about the tag chip UI โ€” it sees only a FormControl with a string[] value. When the user adds or removes a tag, onChange(currentTags) notifies Angular Forms. When the parent form sets the value (e.g. for editing an existing task), writeValue(tags) updates the displayed chips.

Step 4 โ€” Priority as Button Group Instead of Dropdown

A button group for priority selection (5 coloured buttons) is more scannable and requires fewer clicks than a dropdown (click to open, scroll, click to select). The selected state is tracked by the FormControl value โ€” a button is highlighted when form.get('priority').value === p.value. Clicking a button calls form.get('priority').setValue(p.value) directly โ€” no separate click handler needed. This pattern works for any small, fixed set of options.

Step 5 โ€” Dashboard Data Comes from a Single Aggregation Query

The workspace dashboard service calls one endpoint that returns all KPI data from a single MongoDB aggregation with $facet โ€” total count, count by status, overdue count, due-this-week count, completion trend (last 30 days grouped by day), and tag cloud. Receiving all this data in one response means the dashboard renders completely in one render cycle rather than staggering as 6 separate API calls complete at different times.

Quick Reference

Pattern Code
Custom validator (control) => control.value > new Date() ? null : { pastDate: 'msg' }
Show errors on submit form.markAllAsTouched()
Disable submit when invalid [disabled]="form.invalid || saving()"
updateOn blur new FormControl('', { validators: [...], updateOn: 'blur' })
FormArray for tags fb.array(tags.map(t => fb.control(t)))
Add to array tagsArray.push(fb.control(newTag))
Remove from array tagsArray.removeAt(index)
ControlValueAccessor Implement writeValue, registerOnChange, registerOnTouched

🧠 Test Yourself

A user opens the task form and immediately clicks Submit without filling in any fields. The form is invalid but no error messages appear. What method must be called and why?