Перейти к основному содержимому

Form Guardian для Angular

Полное руководство по использованию Form Guardian с Angular приложениями (Reactive Forms и Template-driven Forms).

🚀 Быстрый старт

Установка

npm install @form-guardian/dom

Template-driven Forms

import { Component, ViewChild, ElementRef, OnInit, OnDestroy } from '@angular/core';
import { attachFormAutosave, FormAutosaveHandle } from '@form-guardian/dom';

@Component({
selector: 'app-contact-form',
template: `
<form #contactForm (ngSubmit)="onSubmit()">
<input name="name" [(ngModel)]="form.name" placeholder="Имя" />
<input name="email" [(ngModel)]="form.email" type="email" placeholder="Email" />
<textarea name="message" [(ngModel)]="form.message" placeholder="Сообщение"></textarea>
<button type="submit">Отправить</button>
</form>
`
})
export class ContactFormComponent implements OnInit, OnDestroy {
@ViewChild('contactForm') formElement!: ElementRef<HTMLFormElement>;

private autosave?: FormAutosaveHandle;

form = {
name: '',
email: '',
message: ''
};

ngOnInit() {
// Ожидание инициализации view
setTimeout(() => {
this.autosave = attachFormAutosave({
formId: 'contact-form',
root: this.formElement.nativeElement,
autoRestore: true,
debounceMs: 500,
});
});
}

ngOnDestroy() {
this.autosave?.destroy();
}

async onSubmit() {
await this.autosave?.clear();
console.log('Отправка:', this.form);
// Логика отправки...
}
}

Reactive Forms

import { Component, ViewChild, ElementRef, OnInit, OnDestroy } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { attachFormAutosave, FormAutosaveHandle } from '@form-guardian/dom';

@Component({
selector: 'app-registration-form',
template: `
<form #formElement [formGroup]="registrationForm" (ngSubmit)="onSubmit()">
<div *ngIf="hasDraft" class="draft-alert">
Черновик сохранен в {{ draftTime | date:'short' }}
</div>

<div>
<input formControlName="name" placeholder="Имя" />
<div *ngIf="registrationForm.get('name')?.errors?.['required']">
Имя обязательно
</div>
</div>

<div>
<input formControlName="email" type="email" placeholder="Email" />
<div *ngIf="registrationForm.get('email')?.errors?.['email']">
Неверный email
</div>
</div>

<div>
<textarea formControlName="message" placeholder="Сообщение"></textarea>
</div>

<button type="submit" [disabled]="registrationForm.invalid">Отправить</button>
</form>
`
})
export class RegistrationFormComponent implements OnInit, OnDestroy {
@ViewChild('formElement') formElement!: ElementRef<HTMLFormElement>;

private autosave?: FormAutosaveHandle;
registrationForm: FormGroup;
hasDraft = false;
draftTime: Date | null = null;

constructor(private fb: FormBuilder) {
this.registrationForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
message: ['']
});
}

ngOnInit() {
setTimeout(async () => {
this.autosave = attachFormAutosave({
formId: 'registration-form',
root: this.formElement.nativeElement,
autoRestore: false, // Ручное восстановление для reactive forms
onAfterSave: () => {
this.hasDraft = true;
this.draftTime = new Date();
}
});

// Проверка и восстановление черновика
const hasDraft = await this.autosave.hasDraft();
if (hasDraft) {
await this.autosave.restore();
this.hasDraft = true;
const meta = await this.autosave.getDraftMeta();
this.draftTime = new Date(meta.updatedAt);
}

// Автосохранение при изменении значений
this.registrationForm.valueChanges.subscribe(() => {
this.autosave?.saveValues();
});
});
}

ngOnDestroy() {
this.autosave?.destroy();
}

async onSubmit() {
if (this.registrationForm.valid) {
await this.autosave?.clear();
this.hasDraft = false;
console.log('Отправка:', this.registrationForm.value);
// Логика отправки...
}
}
}

📋 Полный пример сервиса

Сервис автосохранения

// autosave.service.ts
import { Injectable } from '@angular/core';
import { attachFormAutosave, FormAutosaveHandle } from '@form-guardian/dom';

export interface AutosaveOptions {
formId: string;
root: HTMLFormElement;
autoRestore?: boolean;
debounceMs?: number;
onAfterSave?: () => void;
onAfterRestore?: () => void;
}

@Injectable({
providedIn: 'root'
})
export class AutosaveService {
private handles = new Map<string, FormAutosaveHandle>();

attach(options: AutosaveOptions): FormAutosaveHandle {
const handle = attachFormAutosave(options);
this.handles.set(options.formId, handle);
return handle;
}

get(formId: string): FormAutosaveHandle | undefined {
return this.handles.get(formId);
}

async clear(formId: string): Promise<void> {
const handle = this.handles.get(formId);
if (handle) {
await handle.clear();
handle.destroy();
this.handles.delete(formId);
}
}

destroy(formId: string): void {
const handle = this.handles.get(formId);
if (handle) {
handle.destroy();
this.handles.delete(formId);
}
}

destroyAll(): void {
this.handles.forEach(handle => handle.destroy());
this.handles.clear();
}
}

Использование:

import { Component, ViewChild, ElementRef, OnInit, OnDestroy } from '@angular/core';
import { AutosaveService } from './autosave.service';

@Component({
selector: 'app-my-form',
template: `<form #formElement><!-- поля --></form>`
})
export class MyFormComponent implements OnInit, OnDestroy {
@ViewChild('formElement') formElement!: ElementRef<HTMLFormElement>;

private readonly formId = 'my-form';

constructor(private autosaveService: AutosaveService) {}

ngOnInit() {
setTimeout(() => {
this.autosaveService.attach({
formId: this.formId,
root: this.formElement.nativeElement,
autoRestore: true,
onAfterSave: () => console.log('Черновик сохранен'),
});
});
}

ngOnDestroy() {
this.autosaveService.destroy(this.formId);
}

async onSubmit() {
await this.autosaveService.clear(this.formId);
// Отправка...
}
}

🔧 Продвинутые паттерны

Со статусом черновика через Observable

import { Component, OnInit, OnDestroy } from '@angular/core';
import { BehaviorSubject, interval, Subscription } from 'rxjs';
import { attachFormAutosave, FormAutosaveHandle } from '@form-guardian/dom';

@Component({
selector: 'app-form-with-status',
template: `
<div *ngIf="draftStatus$ | async as status" class="draft-status">
<span *ngIf="status.hasDraft">
📝 Черновик сохранен {{ status.timeAgo }}
</span>
</div>
<form #formElement><!-- поля --></form>
`
})
export class FormWithStatusComponent implements OnInit, OnDestroy {
@ViewChild('formElement') formElement!: ElementRef<HTMLFormElement>;

private autosave?: FormAutosaveHandle;
private subscription?: Subscription;

draftStatus$ = new BehaviorSubject<{
hasDraft: boolean;
timestamp: number | null;
timeAgo: string;
}>({
hasDraft: false,
timestamp: null,
timeAgo: ''
});

ngOnInit() {
setTimeout(async () => {
this.autosave = attachFormAutosave({
formId: 'my-form',
root: this.formElement.nativeElement,
autoRestore: true,
onAfterSave: async () => {
await this.updateDraftStatus();
}
});

await this.updateDraftStatus();

// Обновление "времени назад" каждые 30 секунд
this.subscription = interval(30000).subscribe(() => {
this.updateDraftStatus();
});
});
}

ngOnDestroy() {
this.autosave?.destroy();
this.subscription?.unsubscribe();
}

private async updateDraftStatus() {
const hasDraft = await this.autosave?.hasDraft();
if (hasDraft) {
const meta = await this.autosave?.getDraftMeta();
this.draftStatus$.next({
hasDraft: true,
timestamp: meta.updatedAt,
timeAgo: this.getTimeAgo(meta.updatedAt)
});
} else {
this.draftStatus$.next({
hasDraft: false,
timestamp: null,
timeAgo: ''
});
}
}

private getTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'только что';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} мин назад`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} ч назад`;
return `${Math.floor(hours / 24)} дн назад`;
}
}

Многошаговый визард

// wizard.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { saveDraftCore, loadDraftCore, clearDraft } from '@form-guardian/core';

export interface WizardStep {
id: string;
data: any;
}

@Injectable({
providedIn: 'root'
})
export class WizardService {
private currentStep$ = new BehaviorSubject<number>(0);
private steps: WizardStep[] = [];

async saveStep(stepId: string, data: any) {
await saveDraftCore(stepId, data);
}

async loadStep(stepId: string): Promise<any> {
const draft = await loadDraftCore(stepId);
return draft?.values;
}

async clearAllSteps() {
for (const step of this.steps) {
await clearDraft(step.id);
}
}

registerStep(stepId: string) {
this.steps.push({ id: stepId, data: null });
}

nextStep() {
this.currentStep$.next(this.currentStep$.value + 1);
}

prevStep() {
this.currentStep$.next(this.currentStep$.value - 1);
}

getCurrentStep() {
return this.currentStep$.asObservable();
}
}

💡 Лучшие практики

✅ ДЕЛАЙТЕ

  • Используйте setTimeout() в ngOnInit для обеспечения готовности view
  • Всегда вызывайте destroy() в ngOnDestroy
  • Используйте сервисы для общей логики автосохранения
  • Для Reactive Forms используйте autoRestore: false и ручное восстановление
  • Подписывайтесь на valueChanges для автосохранения Reactive Forms

❌ НЕ ДЕЛАЙТЕ

  • Не подключайте автосохранение до инициализации view
  • Не забывайте отписываться от observables
  • Не сохраняйте поля паролей (уже исключены по умолчанию)
  • Не используйте autoRestore: true с Reactive Forms

🔗 Связанные ресурсы