验证角度2模板驱动表单的实验


版本0.0.4我的GuildRunner sandbox Angular 2 application现已推出。

在早期post上的示例后,我分享了我的想法official documentation page for template-driven forms。我对设计用来帮助验证表单输入的功能并不感兴趣:

  • “角度”类添加到表单控件中,表示控件状态基于与DOM的交互,不反映附加到控件的模型数据的状态(例如,一旦表单控件被标记为“脏”,将控件值更改回其原始值不会将其重新标记为“干净”)。
  • 一些基本的表单控件属性(如“必需的”)可以用来设置验证约束,Angular将使用这些约束来切换控件上的有效/无效类,但是HTML5中没有引入验证属性,也没有关于什么有效什么无效的文档。
  • 即使当角度可以正确地切换有效/无效类时,这仅仅表明表单控件应该被认为是无效的,而不是为什么它是无效的。

(最近发布的RC6版本的Angular 2的更改日志提示,其中一些问题可能已经得到解决。(

因此,当我决定GuildRunner的下一个特性将是添加或编辑一个行会的细节组件时,我知道弄清楚我验证表单的策略将是所涉及工作的一大部分。我在这个版本中提出的是一个粗略的方法的初稿,其中表单从存储在组件中的验证逻辑和状态数据中获取验证线索。如果我认为这种方法是可行的,并且是在Angular 2(dynamic forms)。

我开始时对我的公会数据和公会域类做了一些修改。我添加了两个新属性:“email”和“incorporationYear”(行会成立的那一年)。我还重构了行会构造函数,使对象文字参数和所有属性都是可选的:

//domain/guild.ts
...
constructor( guildData?: any  ) {
    if( guildData ) {
      this.id = guildData.id ? guildData.id : null ;
      this.name = guildData.name ? guildData.name : null ;
      this.email = guildData.email ? guildData.email : null;
      this.incorporationYear = guildData.incorporationYear ? guildData.incorporationYear : null;
...

然后,我创建了初始的GuildsDetailComponent,并定义了两条从GuildsMasterComponent到达它的路径,一条用于编辑,另一条用于添加:

//app.routing.ts
{ path: 'guilds/:id', component: GuildsDetailComponent}, //Edit route
{ path: 'guild', component: GuildsDetailComponent },  //Add route

...我考虑过只有一条带有参数值的路由,其中参数值0将用于触发添加行为,但是如果可以的话,我希望避免这种情况。拥有一条不提供参数的路由意味着我必须在GuildsDetailComponent的ngOnInit方法中处理它:

//guilds-detail.component.ts
...
export class GuildsDetailComponent implements OnInit {

  guild: Guild;
  ...
  ngOnInit() {
      this.route.params.forEach( (params: Params ) => {
        let id = params['id'] ? +params['id'] : null ;  //converts param string to number
        if( id ) {
          //Ask the GuildService for a Guild object instantiated with data for that record
        } else {
          this.guild = new Guild();  //Instantiate a "blank slate" Guild object
        }
...

我创建了一个简单的GuildService方法,为GuildsDetailComponent提供一个为所选行会填充的行会对象,然后开始处理表单和表单逻辑。在确定当前版本的格式之前,我尝试了几种不同的方法。

组件超文本标记语言从一个标题块开始:

<!-- guilds-detail.component.html -->
<div class="row">
  <div class="col-md-12">
    <h6 *ngIf="!guild && !serviceErrors">Loading...</h6>
    <h3 *ngIf="guild">{{(guild.id ? 'Edit' : 'Add' )}} {{(guild.name ? guild.name : 'Guild')}}</h3>
  </div>
</div>
...

< h3 >内插逻辑确保我们以基于情况的适当标题结束,而ngIf指令确保我们在适当的条件下显示适当的内容。

该表单采用引导式风格,包含行业协会的名称、公司名称和电子邮件地址的文本输入。除了绑定到每个控件的公会数据特有的属性之外,超文本标记语言本质上是相同的:

<!-- guilds-detail.component.html -->
...
<div class="form-group">
    <label for="name" class="col-md-2 control-label">Name:</label>
    <div class="col-md-6">
      <input id="name" type="text" class="form-control" [(ngModel)]="guild.name" (change)="checkValidity( 'name' )" (keyup)="checkFix( 'name' )" name="name" #name="ngModel" />
      <div *ngIf="status.name.errors.length" class="alert alert-danger">
        <ul>
          <li *ngFor="let error of status.name.errors">
            {{error}}
          </li>
        </ul>
      </div>
    </div>
</div>

因此[(ngModel)]将此控件绑定到组件的Guild对象的名称属性,并执行检查有效性()和检查修复()以响应控件发出的更改和按键事件。关于这个输入的任何错误都是通过附加到组件中定义的简单“状态”对象文字的“名称”属性的错误数组来管理的。

所有的验证工作都在组件的checkValidity()方法中进行:

//guilds-detail.component.ts
...
checkValidity( propertyName: string ) {

      let yearRegEx = /[1-2]\d{3}$/;
      let emailRegEx = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,3}$/;

      switch( propertyName ) {
        case 'name':
          this.status.name.errors = [];
          if( !this.guild[ propertyName ] ) {
            this.status.name.errors.push( 'The name field is required.' );
            break;
          }
          if( this.guild[ propertyName ].length < 5 ) {
            this.status.name.errors.push( 'The guild name is too short.' );
          }
          break;
        case 'incorporationYear':
          this.status.incorporationYear.errors = [];
          if( !this.guild[ propertyName ] ) {
            this.status.incorporationYear.errors.push( 'The year of incorporation is required.' );
            break;
          }
          if( isNaN( +this.guild[ propertyName ] ) ) {
            this.status.incorporationYear.errors.push( 'The incorporation year must be a number.' );
          } else {
            if( !yearRegEx.test( this.guild[ propertyName ] ) ) {
              this.status.incorporationYear.errors.push( 'The incorporation year must a valid 4-digit year (1xxx or 2xxx).' );
            }
          }
          break;
        case 'email':
          this.status.email.errors = [];
          if( !emailRegEx.test( this.guild[ propertyName ] ) ) {
            this.status.email.errors.push( 'Please enter a valid email address.' );
          }
          break;
      }

}

因此,每当一个文本输入发出一个更改事件时,CheckValidation()就会清除该控件/行会属性的现有验证错误数组,并执行相关的验证逻辑,用任何仍然存在的验证问题重新填充该错误数组。基于组件的超文本标记语言,任何这样的错误都会显示在文本输入下面的警告区域。

在表单输入失去焦点之前,文本输入的更改事件不会触发,这在用户第一次向表单控件输入数据时很好,因为您不希望在他们完成键入之前应用任何最小长度验证规则(如公会名称的规则)。但这确实意味着,如果显示了验证错误,它不会消失,直到用户修改了输入,然后再次退出输入。这不是一个糟糕的体验,但是我想确保用户不会再次离开输入,并且仍然以无效值结束。这就是keyup事件在组件中执行checkFix()方法的原因。

//guilds-detail.component.ts
...
checkFix( propertyName: string ) {
    if( this.status[ propertyName ].errors.length > 0 ) {
      this.checkValidity( propertyName );
    }
  }

checkFix()方法会在每次击键时检查有效性,直到验证问题全部解决。在这一点上,人们希望用户不要在输入中输入更多的文本,这会使内容和控件再次无效。

表单和表单逻辑的其余部分非常简单。表单以三个按钮结束:

  • “取消”按钮,它只是简单地导航回公会列表。
  • 只要有任何验证错误,就会禁用“保存”按钮,当单击该按钮时,会对组件的“状态”对象文字中包含的每个属性(因此对所有表单项)执行检查有效性()。
  • “清除表单”按钮,它将公会对象的名称、公司名称和电子邮件属性设置为空,并清除所有以前的验证错误,为数据输入提供了一个全新的开始。

最终结果是一个如下所示的表单:

就行为而言,我对结果很满意,但实现可能更好。大多数(如果不是全部的话)验证配置应该属于行会对象而不是组件,每个表单控件的模板代码的重复表明我可能会创建一个自定义组件来封装共享行为,并且我需要尝试将相同的技术应用到更复杂的表单,以查看在不同的场景下实现是否成立。

关于此版本的其他注意事项:

  • 我创建了一个HttpResponse域类,用于将数据从服务方法传递回调用它们的组件方法。我这样做是为了在如何将来自HTTP调用的响应数据打包并呈现给调用方法方面创建一致性,无论HTTP调用是成功返回还是返回了一个HTTP错误代码。
  • 我想说明一个场景,一个用户在应用程序的网址上添加了书签,这将在GuildsDetailComponent中调出一个特定的行会,但是这个行会已经不在数据中了。因此,如果在这种情况下执行GuidServiCeGetGuild()方法,404错误响应将填充返回给GuildsDetailComponent的HttpResponse对象,并显示一条错误消息,指出不存在与该id号匹配的GuildsDetailComponent,最后将显示该消息而不是表单。