AsyncValidator
, the validate()
method is called on each keystroke, and doesn't
allow you to debounce.
markAsPending()
. When the validation ends call
setErrors()
.
ngModel
.ngModel.pending
// app/async-validation/user.ts
export interface User {
id: number;
username: string;
password: string;
}
// app/async-validation/unique-username-validator.directive.ts
import { Directive, OnDestroy } from '@angular/core';
import { NgModel } from '@angular/forms';
import { Subscription } from 'rxjs';
import { debounceTime, filter, switchMap, tap } from 'rxjs/operators';
import { UserService } from './user.service';
// For more context see: https://mobiarch.wordpress.com/2017/12/26/angular-async-validator/
@Directive({
selector: '[appUniqueUsername]',
})
export class UniqueUsernameValidatorDirective implements OnDestroy {
private readonly subscription?: Subscription;
constructor(userService: UserService, { control }: NgModel) {
this.subscription = control.valueChanges
.pipe(
// If you filter out empty strings, you cannot use distinctUntilChanged() later
filter((username) => !!username),
tap(() => control.markAsPending()),
debounceTime(500),
switchMap((username) => userService.isUsernameAvailable(username))
)
.subscribe((available) => {
if (!control.value || available) {
// This is needed to report the validation is not pending anymore
return control.setErrors(control.errors);
}
control.setErrors({
...control.errors,
uniqueUsername: { value: control.value },
});
});
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
}
<!-- app/async-validation/sign-up-form-ngxs/sign-up-form-ngxs.component.html -->
<h1>Sign Up Form (NGXS)</h1>
<mat-card>
<form (ngSubmit)="submit()" #signUpForm="ngForm">
<mat-form-field>
<mat-label>Username</mat-label>
<input
matInput
required
appUniqueUsername
name="username"
[ngModel]="(user$ | async)?.username"
(ngModelChange)="setUsername($event)"
#username="ngModel"
/>
<mat-spinner
matSuffix
[diameter]="18"
*ngIf="username.pending"
></mat-spinner>
<mat-error *ngIf="username.errors?.required"
>You must enter a value</mat-error
>
<mat-error *ngIf="username.errors?.uniqueUsername"
>The username has already been taken</mat-error
>
</mat-form-field>
<mat-form-field>
<mat-label>Password</mat-label>
<input
matInput
required
type="password"
name="password"
[ngModel]="(user$ | async)?.password"
(ngModelChange)="setPassword($event)"
#password="ngModel"
/>
<mat-error *ngIf="password.errors?.required"
>You must enter a value</mat-error
>
</mat-form-field>
<div class="form-buttons">
<button
mat-raised-button
type="submit"
color="primary"
[disabled]="(loading$ | async) || !signUpForm.form.valid"
>
Submit
</button>
</div>
</form>
</mat-card>
<h2>User (JSON)</h2>
<pre
>{{ user$ | async | json }}
</pre>
// app/async-validation/sign-up-form-ngxs/sign-up-form-ngxs.component.ts
import { Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Select, Store } from '@ngxs/store';
import { Observable } from 'rxjs';
import { withLatestFrom } from 'rxjs/operators';
import { User } from '../user';
import { SignUpFormNgxs } from './sign-up-form-ngxs.actions';
import { SignUpFormNgxsSelectors } from './sign-up-form-ngxs.selectors';
@Component({
selector: 'app-sign-up-form-ngxs',
templateUrl: './sign-up-form-ngxs.component.html',
})
export class SignUpFormNgxsComponent {
@Select(SignUpFormNgxsSelectors.user)
user$!: Observable<User>;
@Select(SignUpFormNgxsSelectors.loading)
loading$!: Observable<boolean>;
constructor(
private readonly store: Store,
private readonly snackBar: MatSnackBar
) {}
setUsername(username: string) {
this.store.dispatch(new SignUpFormNgxs.SetUsername(username));
}
setPassword(password: string) {
this.store.dispatch(new SignUpFormNgxs.SetPassword(password));
}
submit(): void {
this.store
.dispatch(new SignUpFormNgxs.Submit())
.pipe(withLatestFrom(this.user$))
.subscribe(([, { id }]) => {
this.snackBar.open(`User saved with ID #${id}`, 'Close');
});
}
}
// app/async-validation/user.service.ts
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { User } from './user';
export interface UserSaveResponse {
id: number;
}
@Injectable({
providedIn: 'root',
})
export class UserService {
isUsernameAvailable(username: string): Observable<boolean> {
console.log('Is username available?', username);
// Let's say that all usernames with a dot are taken
return of(username.indexOf('.') === -1 ? true : false).pipe(delay(1000));
}
save(user: User): Observable<UserSaveResponse> {
console.log('Save:', user);
return of({
id: 1,
}).pipe(delay(1000));
}
}
// app/async-validation/sign-up-form-ngxs/sign-up-form-ngxs.state.ts
import { Injectable } from '@angular/core';
import { Action, State, StateContext } from '@ngxs/store';
import { finalize, tap } from 'rxjs/operators';
import { User } from '../user';
import { UserService } from '../user.service';
import { SignUpFormNgxs } from './sign-up-form-ngxs.actions';
export interface SignUpFormNgxsStateModel {
user: User;
loading: boolean;
}
@State<SignUpFormNgxsStateModel>({
name: 'signUpFormNgxs',
defaults: {
user: {
id: 0,
username: '',
password: '',
},
loading: false,
},
})
@Injectable()
export class SignUpFormNgxsState {
constructor(private readonly userService: UserService) {}
@Action(SignUpFormNgxs.SetUsername)
setUsername(
context: StateContext<SignUpFormNgxsStateModel>,
{ username }: SignUpFormNgxs.SetUsername
) {
const state = context.getState();
context.patchState({
user: { ...state.user, username },
});
}
@Action(SignUpFormNgxs.SetPassword)
setPassword(
context: StateContext<SignUpFormNgxsStateModel>,
{ password }: SignUpFormNgxs.SetPassword
) {
const state = context.getState();
context.patchState({
user: { ...state.user, password },
});
}
@Action(SignUpFormNgxs.Submit)
submit(context: StateContext<SignUpFormNgxsStateModel>) {
const state = context.getState();
context.patchState({
loading: true,
});
return this.userService.save(state.user).pipe(
tap(({ id }) =>
context.patchState({
user: {
...state.user,
id,
},
})
),
finalize(() =>
context.patchState({
loading: false,
})
)
);
}
}
// app/async-validation/sign-up-form-ngxs/sign-up-form-ngxs.actions.ts
export namespace SignUpFormNgxs {
export class SetUsername {
static readonly type = '[SignUpFormNgxs] SetUsername';
constructor(readonly username: string) {}
}
export class SetPassword {
static readonly type = '[SignUpFormNgxs] SetPassword';
constructor(readonly password: string) {}
}
export class Submit {
static readonly type = '[SignUpFormNgxs] Submit';
}
}
// app/async-validation/sign-up-form-ngxs/sign-up-form-ngxs.selectors.ts
import { Selector } from '@ngxs/store';
import { User } from '../user';
import {
SignUpFormNgxsState,
SignUpFormNgxsStateModel,
} from './sign-up-form-ngxs.state';
export class SignUpFormNgxsSelectors {
@Selector([SignUpFormNgxsState])
static user({ user }: SignUpFormNgxsStateModel): User {
return user;
}
@Selector([SignUpFormNgxsState])
static loading({ loading }: SignUpFormNgxsStateModel): boolean {
return loading;
}
}