# Form 表單

這次使用 stackblitz 來建立這次範例的 demo link (opens new window)

首先先引入 bootstrap

在左側 DEPENDENCIES enter package name 收尋 bootstrap 安裝後會拰要不要加裝套件,直接 enter 就好了,再將 bootstrap 的樣式表引入 style.css

@import "~bootstrap/dist/css/bootstrap.min.css";

引入完後測試一下有沒有成功,打入 button 後看有沒有吃到樣式,有吃到樣式就可以開始囉

直接在 app.component.html

<button class="btn btn-secondary">Publish Article</button>

先建立一個 component 引入到 app.module.ts 並引入到模板中

建立一個 form 表單,我直接拿 bootstrap 範例預設的表單來改

表單內容請看 link (opens new window)

確認有 imports FormsModule

import { FormsModule } from '@angular/forms';
@NgModule({
  imports: [ FormsModule],
})

在 app 裡面多一個 article.model.ts 裡面定義 Article,Author,ArticleResponse ,可以查看 link (opens new window)

editor.component.ts 在 class 裡自定義 title, description, body 的變數

import { ArticleResponse, Article, Author } from "../article.model";
export class EditorComponent implements OnInit {
  title: string;
  description: string;
  body: string;
  constructor() {}

  ngOnInit() {}
}

# Template Driven From

接下來可以把 input 雙向綁定資料

<div class="mb-3 col-6">
  <label for="exampleFormControlInput1" class="form-label">Article Title</label>
  <input
    type="text"
    class="form-control"
    id="exampleFormControlInput1"
    placeholder="Article Title"
    [(ngModel)]="title"
  />
</div>
...

接下來會報錯

# Error 為如果 ngModel 在 form tag 裡面的話,必須給 name
Error: If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions.

所以 input 改成以下程式碼,加個 name 就不會報錯

<div class="mb-3 col-6">
  <label for="exampleFormControlInput1" class="form-label">Article Title</label>
  <input
    type="email"
    class="form-control"
    id="exampleFormControlInput1"
    placeholder="Article Title"
    name="title"
    [(ngModel)]="title"
  />
</div>
...

如何取得所有表單內容,使用樣板變數來獲得表單內容 在 form 中加入樣板變數,再到其他的地方顯示是否有雙向綁定

ngForm 會建立出一個 FormGroup 裡面會包含一些東西

{{ f.value | json }}
<form #f="ngForm">...</form>

這邊得到的值是跟 input 裡面的 name 來獲得的,跟 ngModel 所綁定的名稱無關係

這樣可以再改寫 input 成以下程式碼,把原本的 ngModel 改寫一下

ngModel 代表的是 FormControl

ngModel 可以從 angular 原始碼找出來,它繼承了 NgControl

...
<div class="mb-3 col-6">
  <label for="exampleFormControlInput1" class="form-label">Article Title</label>
  <input
    type="email"
    class="form-control"
    id="exampleFormControlInput1"
    placeholder="Article Title"
    name="title"
    ngModel
  />
</div>
...

如果要放預設值,需改成

[(ngModel)]

<div class="mb-3 col-6">
  <label for="exampleFormControlInput1" class="form-label">Article Title</label>
  <input
    type="email"
    class="form-control"
    id="exampleFormControlInput1"
    placeholder="Article Title"
    name="title"
    [(ngModel)]="title"
  />
</div>
title = "lorem@scd.com";
假如目前的需求是某個表單需要驗證還能填寫,這樣需改寫 input。
input 多一個 #t="ngModel", ngModel 會回傳 Formcontrol 的東西,下面的 required 為 !!t.value,然後再將 t.valid 列印出來

以下寫法代表假如說第一個 input 有值,那第二個 input 為必填;如果第一個 input 沒有值,第二的 input 為非必填
{{ f.valid }}
<div class="mb-3 col-6">
  <label for="exampleFormControlInput1" class="form-label">Article Title</label>
  <input
    type="email"
    class="form-control"
    id="exampleFormControlInput1"
    placeholder="Article Title"
    name="title"
    [(ngModel)]="title"
    #t="ngModel"
  />
</div>
<div class="mb-3 col-6">
  <label for="exampleFormControlInput1" class="form-label">Article about</label>
  <input
    type="email"
    class="form-control"
    id="exampleFormControlInput1"
    placeholder="Article about"
    name="email"
    ngModel
    [required]="!!t.value"
  />
</div>

如果要在表單 valid 時才能送出,直接綁定 disabled 屬性

<button type="button" class="btn btn-secondary" [disabled]="!f.valid">
  Publish Article
</button>

# Model driven form (ReactForm)

概念為把 FromControl 先在 class 裡面定義好,定義完之後在 template 裡面 implement 對應的 FromControl 是哪一個

首先需 import ReactiveFormsModule app.module.ts

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";

import { AppComponent } from "./app.component";
import { EditorComponent } from "./editor/editor.component";

@NgModule({
  imports: [BrowserModule, FormsModule, ReactiveFormsModule],
  declarations: [AppComponent, EditorComponent],
  bootstrap: [AppComponent],
})
export class AppModule {}

把 editor.component.ts 原本的資料結構改寫成 FormGroup 與 FormControl

import { Component, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
@Component({
  selector: "app-editor",
  templateUrl: "./editor.component.html",
  styleUrls: ["./editor.component.css"],
})
export class EditorComponent implements OnInit {
  formData = new FormGroup({
    title: new FormControl(),
    email: new FormControl(),
    description: new FormControl(),
    body: new FormControl(),
  });
  constructor() {}

  ngOnInit() {}
}

editor.component.html

把原本的 form 使用 template driven 改成使用 form > formGroup 並綁定 formData

原本的 name 改成 formControlName 這樣就會從 formGroup 裡面抓它的屬性

取值的方式使用 {{ formData.value | json}},一開始會給 null (因為沒給預設值)而非空值

若要給預設值直接在 new FormControl('abc') 即可

{{ formData.value | json}}
<form [formGroup]="formData">
  <div class="mb-3 col-6">
    <label for="exampleFormControlInput1" class="form-label"
      >Article Title</label
    >
    <input
      type="email"
      class="form-control"
      id="exampleFormControlInput1"
      placeholder="Article Title"
      formControlName="title"
    />
  </div>
  <div class="mb-3 col-6">
    <label for="exampleFormControlInput1" class="form-label"
      >Article about</label
    >
    <input
      type="email"
      class="form-control"
      id="exampleFormControlInput1"
      placeholder="Article about"
      formControlName="email"
    />
  </div>
  <div class="mb-3 col-6">
    <label for="exampleFormControlTextarea1" class="form-label"
      >Write your article</label
    >
    <textarea
      class="form-control"
      id="exampleFormControlTextarea1"
      rows="3"
      formControlName="body"
    ></textarea>
  </div>
  <div class="mb-3 col-6">
    <label for="exampleFormControlInput1" class="form-label">Enter tags</label>
    <input
      type="email"
      class="form-control"
      id="exampleFormControlInput1"
      placeholder="Enter tags"
      formControlName="description"
    />
  </div>
  <button type="button" class="btn btn-secondary" [disabled]="formData.invalid">
    Publish Article
  </button>
</form>

若想給預設值如下

formData = new FormGroup({
  title: new FormControl("title"),
  email: new FormControl("lore@sdf.com"),
  description: new FormControl(""),
  body: new FormControl(""),
});

button 若要符合才可點選的話,直接從 ts 那邊寫

先知道該怎麼取得 FormGroup 裡面的值

export class EditorComponent implements OnInit {
  formData = new FormGroup({
    title: new FormControl("aaa"),
    email: new FormControl(""),
    description: new FormControl(""),
    body: new FormControl(""),
    // group: new FormGroup({
    //   field: new FormControl(''),
    // }),
  });
  constructor() {}

  ngOnInit(): void {
    // 取得 formData 的值的兩種方式
    // 多層寫法較複雜
    // this.formData.controls.group.controls.field;
    // 單層寫法 1
    // this.formData.controls.title;
    // 很多層可以這樣寫,TS 嚴謹模式也可以使用
    // this.formData.get('group.field')
    // 單層寫法 2
    // this.formData.get("title");
  }
}

接下來我們要監聽當 title 屬性改變的時候進行操作,在 valueChanges 時我們訂閱這個值,並先 console 初回傳的值,回傳的值為修改後的值,當我們有這個東西後可以在這邊做一些判斷

ngOnInit(): void {
  this.formData.controls.title.valueChanges.subscribe({
    next: (v) => {
      console.log(v);
    },
  });
}

下面改寫原本 template driven form > formControl driven form

ngOnInit(): void {
  // 持續監聽,元件摧毀就會跟著消失
  this.formData.controls.title.valueChanges.subscribe({
    next: (v) => {
      if (!!v) {
        // 如果 title 有值,那 email 需要放置 Validators
        this.formData.controls.email.setValidators([Validators.required]);
      } else {
        // 如果沒有值就不需要 Validators
        this.formData.controls.email.clearValidators();
      }
      // 驗證表單狀態
      this.formData.controls.email.updateValueAndValidity();
    },
  });
}

取 formData 的值的方式,兩個都可以取到值,後面使用下面的方式來演示

ngOnInit(): void{
  // 方法一
  console.log(this.formData.value);
  // 方法二
  this.formData.valueChanges.subscribe({
    next: (value) => console.log(value),
  });
}
export class EditorComponent implements OnInit {
  formData = new FormGroup({
    title: new FormControl("aaa"),
    email: new FormControl(""),
    description: new FormControl(""),
    // 預設 disabled formData 的 body
    body: new FormControl({ value: "", disabled: true }),
  });
  ngOnInit(): void {
    // 在這拿到的 formData 不包括 disabled 的 data,這邊拿不到 body 的資訊,所以傳到後端的資料會少 body
    this.formData.valueChanges.subscribe({
      next: (value) => {
        console.log(value);
        // 這個方式就可以連 disabled 的 FormControl 都可以拿到
        // 如果要傳到後端要注意這點
        console.log("getRawValue", this.formData.getRawValue());
      },
    });
    this.formData.controls.title.valueChanges.subscribe({
      next: (v) => {
        if (!!v) {
          this.formData.controls.email.setValidators([Validators.required]);
        } else {
          this.formData.controls.email.clearValidators();
        }
        // 這邊如果開啟代表會觸發 valueChanges
        this.formData.controls.email.updateValueAndValidity({
          // 避免不必要的異動偵測行為,要把 emitEvent 關掉
          emitEvent: false,
        });
      },
    });
  }
}

接下來介紹當輸入 input 時資料加入到 Array 裡面

首先先新增 tag, tags 儲存資料的變數

editor.component.ts

formData = new FormGroup({
  title: new FormControl("aaa"),
  email: new FormControl(""),
  description: new FormControl(""),
  body: new FormControl({ value: "", disabled: true }),
  tag: new FormControl(""),
  // group: new FormGroup({
  //   field: new FormControl(''),
  // }),
});
tags = [];

方式一

下面有個 ul > li 來跑 tag 的迴圈 然後在 input 那邊加入 keyup.enter 觸發 addtag 方法

editor.component.html

<div class="mb-3 col-6">
  <label for="exampleFormControlInput1" class="form-label">Enter tags</label>
  <input
    type="email"
    class="form-control"
    id="exampleFormControlInput1"
    placeholder="Enter tags"
    formControlName="tag"
    (keyup.enter)="addtag()"
  />
  <ul>
    <li *ngFor="let tag of tags">{{ tag }}</li>
  </ul>
</div>

editor.component.ts

addtag() {
  this.tags.push(this.formData.controls.tag.value);
  // 若要清空 formData 的 form controls 需要使用 setValue
  this.formData.controls.tag.setValue('', {
      emitEvent: false,
    });
}

這樣就能新增輸入的 tag 在下方

: 使用 formArray 改寫

formData = new FormGroup({
  title: new FormControl("aaa"),
  email: new FormControl(""),
  description: new FormControl(""),
  body: new FormControl({ value: "", disabled: true }),
  tag: new FormControl(),
  // 可以放FormGroup, FormControl, 在 reactForm 裡面有
  tagList: new FormArray([]),
});
tags = [];

迴圈改成用 tagList.controls 來跑

<div formArrayName="tagList">
  <ul>
    <!-- <li *ngFor="let tag of tags">{{ tag }}</li> -->
    <li *ngFor="let tagControl of tagList.controls">
      <input type="text" [formControl]="$any(tagControl)" />
    </li>
  </ul>
</div>
get tagList() {
  return this.formData.controls.tagList as FormArray;
}

addtag() {
  // 在 FormArray push FormControl
  this.tagList.push(new FormControl(this.formData.value.tag));
  // this.tags.push(this.formData.controls.tag.value);
  this.formData.controls.tag.setValue('', {
    emitEvent: false,
  });
}
此時在 <input type="text" [formControl]="$any(tagControl)" /> 修改值時會直接改動

在迴圈在跑時添加 idx 變數並加入 <button (click)="remove(idx)">x</button>
<div formArrayName="tagList">
  <ul>
    <!-- <li *ngFor="let tag of tags">{{ tag }}</li> -->
    <li *ngFor="let tagControl of tagList.controls; let idx = index">
      <input type="text" [formControl]="$any(tagControl)" />
      <button (click)="remove(idx)">x</button>
    </li>
  </ul>
</div>

36:00 button remove 那邊有 bug QQ,影片好像也有 bug 的樣子

part2 link (opens new window)

因為 tagList formControl 有點複雜,把它用一個 component 去接

先建立一個 editor/tag-select component

記得在 module 引入 editor/tag-select component

然後把 tagList 的 html 貼到 editor/tag-select component,然後 button type="button" 不然預設為 submit 會直接送出表單

<label for="exampleFormControlInput1" class="form-label">Enter tags</label>
<input
  type="email"
  class="form-control"
  id="exampleFormControlInput1"
  placeholder="Enter tags"
  [formControl]="tag"
  (keyup.enter)="addtag()"
/>
<div>
  <ul>
    <!-- <li *ngFor="let tag of tags">{{ tag }}</li> -->
    <li *ngFor="let tagControl of tagList.controls; let idx = index">
      <input type="text" [formControl]="$any(tagControl)" />
      <button type="button" (click)="remove(idx)">x</button>
    </li>
  </ul>
</div>

搬移程式碼後會報錯,因為 tag-select 變數還沒給,那就一個一個補上去吧

import { Component, OnInit } from "@angular/core";
import { FormArray, FormControl } from "@angular/forms";

export class TagSelectComponent implements OnInit {
  tag = new FormControl();
  tagList = new FormArray([]);
  constructor() {}

  ngOnInit() {}
  addtag() {
    this.tagList.push(new FormControl(this.tag.value));
    this.tag.reset();
  }
  remove(idx) {
    this.tagList.removeAt(idx);
  }
}

這樣就可以完成新增、修改、刪除的動作了 yeah!!!!

但因為將這部分元件化了,尚未把值傳到父層去,此時需實做 ControlValueAccessor

import { Component, OnInit } from '@angular/core';
import { FormArray, FormControl, ControlValueAccessor } from '@angular/forms';
export class TagSelectComponent implements OnInit, ControlValueAccessor {
  ...
}

ControlValueAccessor 裡面 Angular 提供了四個方法

onTouched;

// ControlValueAccessor
// Input
writeValue(obj: string[]): void {
  this.tagList.clear();
  obj.forEach((x) => this.tagList.push(new FormControl(x)));
}
// Output 傳一個callback function 把值回傳回去
registerOnChange(fn): void {
  this.tagList.valueChanges.subscribe({
    next: (value) => {
      fn(value);
      if (!!this.onTouched) {
        this.onTouched();
      }
    },
  });
}
// 有沒有被碰過
registerOnTouched(fn) {
  this.onTouched = fn;
}
// 當設定 disable/enable 時所對應的畫面操作
setDisabledState() {}

停在 part2 10:00 左右