نمایش خطاهای اعتبارسنجی سمت سرور ASP.NET Core در برنامه‌های Angular
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: هفت دقیقه

در مطلب «فرم‌های مبتنی بر قالب‌ها در Angular - قسمت چهارم - اعتبارسنجی ورودی‌ها» با نحوه‌ی تنظیمات اعتبارسنجی سمت کلاینت برنامه‌های Angular آشنا شدیم. اما اگر مدل سمت سرور ما یک چنین شکلی را داشته باشد که به همراه خطاهای اعتبارسنجی سفارشی نیز هست:
using System;
using System.ComponentModel.DataAnnotations;

namespace AngularTemplateDrivenFormsLab.Models
{
    public class Movie
    {
        public int Id { get; set; }

        [Required(ErrorMessage = "Movie Title is Required")]
        [MinLength(3, ErrorMessage = "Movie Title must be at least 3 characters")]
        public string Title { get; set; }

        [Required(ErrorMessage = "Movie Director is Required.")]
        public string Director { get; set; }

        [Range(0, 100, ErrorMessage = "Ticket price must be between 0 and 100.")]
        public decimal TicketPrice { get; set; }

        [Required(ErrorMessage = "Movie Release Date is required")]
        public DateTime ReleaseDate { get; set; }
    }
}
و همچنین کنترلر و اکشن متد دریافت کننده‌ی آن نیز به صورت ذیل تعریف شده باشد:
using AngularTemplateDrivenFormsLab.Models;
using Microsoft.AspNetCore.Mvc;

namespace AngularTemplateDrivenFormsLab.Controllers
{
    [Route("api/[controller]")]
    public class MoviesController : Controller
    {
        [HttpPost]
        public IActionResult Post([FromBody]Movie movie)
        {
            if (ModelState.IsValid)
            {
                // TODO: save ...
                return Ok(movie);
            }

            ModelState.AddModelError("", "This record already exists."); // a cross field validation
            return BadRequest(ModelState);
        }
    }
}
دو نوع خطای اعتبارسنجی سمت سرور را به سمت کلاینت ارسال خواهیم کرد:
الف) خطاهای اعتبارسنجی در سطح فیلدها
زمانیکه return BadRequest(ModelState) صورت می‌گیرد، محتویات شیء ModelState به همراه status code مساوی 400 به سمت کلاینت ارسال خواهد شد. در شیء ModelState یک دیکشنری که کلیدهای آن، نام خواص و مقادیر متناظر با آن‌ها، خطاهای اعتبارسنجی تنظیم شده‌ی در مدل است، قرار دارند.
ب) خطاهای اعتبارسنجی عمومی
در این بین می‌توان دیکشنری ModelState را توسط متد AddModelError نیز تغییر داد و برای مثال کلید آن‌را مساوی "" تعریف کرد. در این حالت یک چنین خطایی به کل فرم اشاره می‌کند و نه به یک خاصیت خاص.

نمونه‌ای از خروجی نهایی ارسالی به سمت کاربر:
 {"":["This record already exists."],"TicketPrice":["Ticket price must be between 0 and 100."]}

به همین جهت نیاز است بتوان خطاهای حالت (الف) را دقیقا در ذیل هر فیلد و خطاهای حالت (ب) را در بالای فرم به صورت عمومی به کاربر نمایش داد:



پردازش و دریافت خطاهای اعتبارسنجی سمت سرور در یک برنامه‌ی Angular

با توجه به اینکه سرور، شیء ModelState را توسط return BadRequest به سمت کلاینت ارسال می‌کند، برای پردازش دیکشنری دریافتی از سمت آن، تنها کافی است قسمت بروز خطای عملیات ارسال اطلاعات را بررسی کنیم:


در این HttpErrorResponse دریافتی، دو خاصیت error که همان آرایه‌ی دیکشنری نام خواص و پیام‌های خطای مرتبط با هر کدام و status code دریافتی مهم هستند:
  errors: string[] = [];

  processModelStateErrors(form: NgForm, responseError: HttpErrorResponse) {
    if (responseError.status === 400) {
      const modelStateErrors = responseError.error;
      for (const fieldName in modelStateErrors) {
        if (modelStateErrors.hasOwnProperty(fieldName)) {
          const modelStateError = modelStateErrors[fieldName];
          const control = form.controls[fieldName] || form.controls[this.lowerCaseFirstLetter(fieldName)];
          if (control) {
            // integrate into Angular's validation
            control.setErrors({
              modelStateError: { error: modelStateError }
            });
          } else {
            // for cross field validations -> show the validation error at the top of the screen
            this.errors.push(modelStateError);
          }
        }
      }
    } else {
      this.errors.push("something went wrong!");
    }
  }

  lowerCaseFirstLetter(data: string): string {
    return data.charAt(0).toLowerCase() + data.slice(1);
  }
توضیحات:
در اینجا از آرایه‌ی errors برای نمایش خطاهای عمومی در سطح فرم استفاده می‌کنیم. این خطاها در ModelState، دارای کلید مساوی "" هستند. به همین جهت حلقه‌ای را بر روی شیء responseError.error تشکیل می‌دهیم. به این ترتیب می‌توان به نام خواص و همچنین خطاهای متناظر با آن‌ها رسید.
 const control = form.controls[fieldName] || form.controls[this.lowerCaseFirstLetter(fieldName)];
از نام خاصیت یا فیلد، جهت یافتن کنترل متناظر با آن، در فرم جاری استفاده می‌کنیم. ممکن است کنترل تعریف شده camel case و یا pascal case باشد. به همین جهت دو حالت بررسی را در اینجا مشاهده می‌کنید.
در ادامه اگر control ایی یافت شد، توسط متد setErrors، کلید جدید modelStateError را که دارای خاصیت سفارشی error است، تنظیم می‌کنیم. با اینکار سبب خواهیم شد تا خطای اعتبارسنجی دریافتی از سمت سرور، با سیستم اعتبارسنجی Angular یکی شود. به این ترتیب می‌توان این خطا را دقیقا ذیل همین کنترل در فرم نمایش داد. اگر کنترلی یافت نشد (کلید آن "" بود و یا جزو نام کنترل‌های موجود در آرایه‌ی form.controls نبود)، این خطا را به آرایه‌ی errors اضافه می‌کنیم تا در بالاترین سطح فرم قابل نمایش شود.

نحوه‌ی استفاده‌ی از متد processModelStateErrors فوق را در متد submitForm، در قسمت شکست عملیات ارسال اطلاعات، مشاهده می‌کنید:
  model = new Movie("", "", 0, "");
  successfulSave: boolean;
  errors: string[] = [];

  constructor(private movieService: MovieService) { }

  ngOnInit() {
  }

  submitForm(form: NgForm) {
    console.log(form);

    this.errors = [];
    this.movieService.postMovieForm(this.model).subscribe(
      (data: Movie) => {
        console.log("Saved data", data);
        this.successfulSave = true;
      },
      (responseError: HttpErrorResponse) => {
        this.successfulSave = false;
        console.log("Response Error", responseError);
        this.processModelStateErrors(form, responseError);
      });
  }


نمایش خطاهای اعتبارسنجی عمومی فرم

اکنون که کار مقدار دهی آرایه‌ی errors انجام شده‌است، می‌توان حلقه‌ای را بر روی آن تشکیل داد و عناصر آن‌را در بالای فرم، به صورت عمومی و مستقل از تمام فیلدهای آن نمایش داد:
<form #form="ngForm" (submit)="submitForm(form)" novalidate>
  <div class="alert alert-danger" role="alert" *ngIf="errors.length > 0">
    <ul>
      <li *ngFor="let error of errors">
        {{ error }}
      </li>
    </ul>
  </div>
  <div class="alert alert-success" role="alert" *ngIf="successfulSave">
    Movie saved successfully!
  </div>



نمایش خطاهای اعتبارسنجی در سطح فیلدهای فرم

با توجه به تنظیم خطاهای اعتبارسنجی کنترل‌های Angular در متد processModelStateErrors و داشتن کلید جدید modelStateError
control.setErrors({
  modelStateError: { error: modelStateError }
});
اکنون می‌توان از این کلید جدید (ctrl.errors.modelStateError)، به صورت ذیل جهت نمایش خطای متناظر با آن (ctrl.errors.modelStateError.error) استفاده کرد:


<ng-template #validationErrorsTemplate let-ctrl="control">
  <div *ngIf="ctrl.invalid && ctrl.touched">
    <div class="alert alert-danger"  *ngIf="ctrl.errors.required">
      This field is required.
    </div>
    <div class="alert alert-danger"  *ngIf="ctrl.errors.minlength">
      This field should be minimum {{ctrl.errors.minlength.requiredLength}} characters.
    </div>
    <div class="alert alert-danger"  *ngIf="ctrl.errors.maxlength">
      This field should be max {{ctrl.errors.maxlength.requiredLength}} characters.
    </div>
    <div class="alert alert-danger"  *ngIf="ctrl.errors.pattern">
      This field's pattern: {{ctrl.errors.pattern.requiredPattern}}
    </div>
    <div class="alert alert-danger"  *ngIf="ctrl.errors.modelStateError">
      {{ctrl.errors.modelStateError.error}}
    </div>
  </div>
</ng-template>
چون تکرار خطاهای اعتبارسنجی در ذیل هر فیلد، فرم را بیش از اندازه شلوغ می‌کند، می‌توان توسط یک ng-template این کدهای تکراری را تبدیل به یک قالب کرد و اکنون استفاده‌ی از این قالب، به سادگی فراخوانی یک ng-container است:
  <div class="form-group" [class.has-error]="releaseDate.invalid && releaseDate.touched">
    <label class="control-label" for="releaseDate">Release Date</label>
    <input type="text" name="releaseDate" #releaseDate="ngModel"  class="form-control"
      required [(ngModel)]="model.releaseDate" />
    <ng-container *ngTemplateOutlet="validationErrorsTemplate; context:{ control: releaseDate }"></ng-container>
  </div>
در اینجا در ngTemplateOutlet، ابتدا نام قالب متناظر ذکر می‌شود و سپس در context آن، نام خاصیت control را که توسط قالب دریافت می‌شود، به template reference variable متناظری تنظیم می‌کنیم، تا به کنترل جاری اشاره کند. به این ترتیب می‌توان به فرم‌هایی خلوت‌تر و با قابلیت مدیریت بهتری رسید.


کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید.
  • #
    ‫۵ سال و ۹ ماه قبل، جمعه ۲ آذر ۱۳۹۷، ساعت ۱۹:۵۶
    نکته تکمیلی
    برای خلوت کردن قالب‌های مرتبط با فرم‌ها برای نمایش خطاهای اعتبارسنجی و همچنین برای جلوگیری از تکرار، می‌توان کامپوننت ValidationMessage را به شکل زیر نیز توسعه داد:
    import { Component, Input, OnInit } from '@angular/core';
    import { AbstractControl } from '@angular/forms';
    
    @Component({
      selector: 'validation-message',
      template: `
        <ng-container *ngIf="control.invalid && control.touched">
          {{ message }}
        </ng-container>
      `
    })
    export class ValidationMessageComponent implements OnInit {
      @Input() control: AbstractControl;
      @Input() fieldDisplayName: string;
      @Input() rules: { [key: string]: string };
    
      get message(): string {
        return this.control.hasError('required')
          ? `${this.fieldDisplayName} را وارد نمائید.`
          : this.control.hasError('pattern')
          ? `${this.fieldDisplayName} را به شکل صحیح وارد نمائید.`
          : this.control.hasError('email')
          ? `${this.fieldDisplayName} را به شکل صحیح وارد نمائید.`
          : this.control.hasError('minlength')
          ? `${this.fieldDisplayName} باید بیشتر از  ${
              this.control.errors.minlength.requiredLength
            } کاراکتر باشد.`
          : this.control.hasError('maxlength')
          ? `${this.fieldDisplayName} باید کمتر از  ${
              this.control.errors.maxlength.requiredLength
            } کاراکتر باشد.`
          : this.control.hasError('min')
          ? `${this.fieldDisplayName} باید بیشتر از  ${
              this.control.errors.min.requiredLength
            } باشد.`
          : this.control.hasError('max')
          ? `${this.fieldDisplayName} باید کمتر از  ${
              this.control.errors.max.requiredLength
            } باشد.`
          : this.hasRule()
          ? this.findRule()
          : this.control.hasError('model')
          ? `${this.control.errors.model.messages[0]}`
          : '';
      }
      constructor() {}
    
      private hasRule() {
        return (
          this.rules &&
          Object.keys(this.control.errors).some(ruleKey =>
            this.rules[ruleKey] ? true : false
          )
        );
      }
    
      private findRule(): string {
        let message = '';
        Object.keys(this.control.errors).forEach(ruleKey => {
          if (this.rules[ruleKey]) {
            message += `${this.rules[ruleKey]} `;
          }
        });
    
        return message;
      }
    
      ngOnInit(): void {}
    }

    این کامپوننت، کنترل مورد نظر، یک نام نمایشی برای فیلد متناظر و یک شیء تحت عنوان rules را دریافت می‌کند. در بدنه پراپرتی message، به ترتیب اولویت validator، بررسی انجام شده و پیغام مناسب بازگشت داده خواهد شد. همچنین خطاهای سمت سروری هم که با کد "model" به لیست خطاهای کنترل اضافه شده اند نیز مورد بررسی قرار گرفته شده اند. 
     در اینجا اگر لازم باشد با یکسری قواعد سفارشی هم به صورت یکپارچه با اعتبارسنج‌های پیش فرض رفتار کنیم و از یک مسیر این پیغام‌ها نمایش داده شوند، می‌توان از خصوصیت rules بهره برد. به عنوان مثال:
     <mat-error *ngIf="form.controls['userName'].invalid && form.controls['userName'].touched" 
     class="mat-text-warn">
        <validation-message
            [control]="form.controls['userName']"
            fieldDisplayName="نام کاربری"
            [rules]="{rule1:'پیغام متناظر با rule1'}">
        </validation-message>
    </mat-error>

    به همراه یک Validator سفارشی
    this.form = this.formBuilder.group({
          userName: [
            '',
            [Validators.required, UserNameValidators.rule1)]
          ],
          password: ['', Validators.required],
          rememberMe: [false]
        });
    
    export class UserNameValidators{
       static rule1(control: AbstractControl) {
            if (control.value.indexOf(' ') >= 0) {
                return { rule1: true };
            }
            return null;
        }
    }

  • #
    ‫۵ سال و ۹ ماه قبل، جمعه ۲ آذر ۱۳۹۷، ساعت ۲۰:۱۲
    نکته تکمیلی
    با کمی تغییرات در بدنه متد processModelStateErrors، می‌توان برای سناریوهای master-detail که خطاهایی به شکل زیر از سمت سرور دریافت می‌شود نیز پیغام متناظر به کنترل‌های موجود در FormArray‌ها را نیز به درستی نمایش داد:
    {
      'detail[0].title':['message'],
      'detail[0].detail[0].title':['message']
    }
    برای این منظور متد findFieldControl را به شکل زیر پیاده سازی خواهیم کرد:
      private findFieldControl(fieldName: string): AbstractControl {
        const path = fieldName.replace('[', '.').replace(']', '');
        return (
          this.form.get(path) || this.form.get(this.lowerCaseFirstLetter(path))
        );
      }
    متد get استفاده شده در بدنه متد، امکان دریافت path متناظر با یک کنترل را نیز دارد؛ از این جهت صرفا لازم بود کلید دریافتی از سمت سرور را به شکل زیر تبدیل کنیم:
    'detail.0.title'
    'detail.0.detail.0.title'
    و در نهایت اعمال تغییرات به متد processModelStateErrors به شکل زیر می‌باشد:
    protected handleSubmitError(response: HttpErrorResponse) {
        this.messages = [];
    
        if (response.status === 400) {
          // handle validation errors
          const validationDictionary = response.error;
          for (const fieldName in validationDictionary) {
            if (validationDictionary.hasOwnProperty(fieldName)) {
              const messages = validationDictionary[fieldName];
              const control = this.findFieldControl(fieldName);
              if (control) {
                // integrate into angular's validation if we have field validation
                control.setErrors({
                  model: { messages: messages }
                });
              } else {
                // if we have cross field validation, then show the validation error at the top of the screen
                this.messages.push(...messages);
              }
            }
          }
        } else {
          this.messages.push('something went wrong!');
        }
      }