ご意見送信フォームを作成する① [プレーンTypeScript]
概要
この記事について
かんたんな概要と結論
Model-View-Controllerの3モデルを用いて情報・表示・動作を管理した。
フロントエンド開発の練習として、
クラウドソーシングサイトなどにある案件の開発要望を調べていた。
そちらに、ご意見送信フォームの構築の要望があったため、
似たような要望を想定して自分で作成してみることにした。
開発要望(想定)
- 名前などのプロファイルや意見内容を入力できるフォームを構築する。
- バリデーションチェックし、送信内容にエラーが有った場合に画面に表示する。
- 意見送信があった場合に、メールアドレスに内容を送信する。また、ユーザが送信後に送信内容を確認できる。
設計
シーケンス設計
要望を受けた上での処理の流れをどのようにするかを考え、次のように書き下した。
フォーム送信シーケンス
送信完了モーダル画面表示シーケンス
■メール送信はメールサーバの設定などが必要で煩雑だったため、
メールを送信した体で擬似的にテキストファイルを作成して、ダウンロードできるようにしました。
クラス設計
About
フロントエンドにおいてMVCモデルを適用するイメージで作成する。
アプリの機能の役割を「Model」+「View」+「Controller」に分割し、
それぞれをモジュールとして分離する。
- Model: ユーザとのインタラクションなどに用いるデータを管理。ビジネスロジックもここに。
- View: データを表示する機能を担当。
- Controller: ModelとViewを連携される。
クラス一覧
所属 | クラス名 | 役割 |
---|---|---|
フォーム | FormView | フォームのコンポーネントを参照し、 イベントの設定などをおこなう |
フォーム | FormModel | フォームの入力値を保持。 バリデーション関数を保有。 |
フォーム | FormController | ViewからModelに入力値を渡したり Viewのイベントを受け取り他のイベントを発火させる。 |
送信完了 モーダル画面 |
ModalView | モーダル画面のコンポーネントを参照し、 イベントの設定などをおこなう |
送信完了 モーダル画面 |
ModalModel | 送信内容データを保持。 ファイル作成ロジックを保持。 |
■モーダル画面は処理が簡単のため、Controllerは省略しています。
クラス図
実装
作成環境
1node v14.16.1
2npm 6.14.12
依存パッケージ
一部抜粋。
1{
2 "devDependencies":{
3 "babel-loader": "^8.2.2",
4 "core-js": "^3.15.2",
5 "regenerator-runtime": "^0.13.9",
6 "html-webpack-plugin": "^5.3.2",
7 "mini-css-extract-plugin": "^2.1.0",
8 "css-loader": "^6.2.0",
9 "style-loader": "^3.2.1",
10 "sass": "^1.35.2",
11 "sass-loader": "^12.1.0",
12 "typescript": "^4.4.3",
13 "webpack": "^5.45.1",
14 "webpack-cli": "^4.7.2",
15 "moment": "^2.29.1",
16 }
17}
18
ディレクトリ構成
一部抜粋。
1myapp
2│ .babelrc
3│ .eslintrc.js
4│ jest.config.js
5│ package-lock.json
6│ package.json
7│ webpack.config.js
8│
9├─node_modules
10│
11└─src
12 │ index.html
13 │ tsconfig.json
14 │
15 ├─style
16 │ style.scss
17 │ _active.scss
18 │ _form.scss
19 │ _mixin.scss
20 │ _modal.scss
21 │ _variables.scss
22 │
23 └─ts
24 │ index.ts
25 │ NameSpace.ts
26 │
27 ├─form
28 │ FormController.ts
29 │ FormErrorMessages.ts
30 │ FormModel.ts
31 │ FormView.ts
32 │
33 └─modal
34 ModalModel.ts
35 ModalView.ts
コード
ソースコードの全体はこちらから
FormView
1
2interface IFormViewArg {
3 address: HTMLInputElement;
4 age: HTMLInputElement;
5 errArea: HTMLDivElement;
6 form: HTMLFormElement;
7 gender: RadioNodeList;
8 message: HTMLTextAreaElement;
9 name: HTMLInputElement;
10 submitBtn: HTMLInputElement;
11 modalScreen: HTMLDivElement;
12
13}
14
15export class FormView {
16 private address: HTMLInputElement;
17 private age: HTMLInputElement;
18 public errArea: HTMLDivElement;
19 private form: HTMLFormElement;
20 private formController: FormController;
21 private gender: RadioNodeList;
22 private message: HTMLTextAreaElement;
23 private name: HTMLInputElement;
24 private submitBtn: HTMLInputElement;
25 constructor({
26 form,
27 name,
28 gender,
29 age,
30 address,
31 message,
32 submitBtn,
33 errArea,
34 modalScreen
35 }: IFormViewArg) {
36 this.formController = new FormController({
37 formView: this,
38 modalScreen: modalScreen,
39 });
40 this.form = form;
41 this.name = name;
42 this.gender = gender;
43 this.age = age;
44 this.address = address;
45 this.message = message;
46 this.submitBtn = submitBtn;
47 this.errArea = errArea;
48
49 this.setSubmitEvt(this.submitBtn);
50
51 for (const elem of [this.name, this.age, this.address, this.message]) {
52 this.setInputBoxFocusEvts(elem);
53 }
54
55 }
56
57 // submitイベントを設定する
58 private setSubmitEvt(elem: HTMLElement) {
59 elem.onclick = (ev) => {
60 ev.preventDefault();
61 this.onSubmit();
62 };
63 }
64
65 // input要素がアクティブになった際にラベルにクラスを付与するイベントを設定する
66 private setInputBoxFocusEvts(elem: HTMLElement) {
67 elem.onfocus = function (ev) {
68 document
69 .querySelector(`label[for="${(ev.currentTarget as HTMLElement)?.id}"]`)
70 ?.classList.add("active");
71 };
72 elem.onblur = function (ev: any) {
73 if (ev.currentTarget.value === "") {
74
75 document
76 .querySelector(`label[for="${(ev.currentTarget as HTMLElement)?.id}"]`)
77 ?.classList.remove("active");
78
79 }
80 };
81 }
82
83 // ControllerのonSubmitイベントに処理を移譲する
84 private onSubmit() {
85 this.formController.onSubmit({
86 name: this.name.value,
87 gender: this.gender.value,
88 age: this.age.value,
89 address: this.address.value,
90 message: this.message.value,
91 });
92 }
93}
94
onSubmit
メソッドでController
のonSubmit
メソッドを呼び出して
モーダルを表示するためのデータを引き渡す。
setInputBoxFocusEvts
メソッドによって各input要素にイベントを設定し、
要素を選択した際にラベルがきれいに移動するようにしている。
FormModel
1export interface IFormModelArg {
2 name: string;
3 gender: string;
4 age: string;
5 address: string;
6 message: string;
7};
8
9export class FormModel {
10 private address!: string;
11 private age!: string;
12 private gender!: string;
13 private message!: string;
14 private name!: string;
15 public isvalid: { [method: string]: () => boolean };
16 constructor(formData: IFormModelArg) {
17 Object.assign(this, formData);
18 // 各入力欄のバリデーションメソッドを保有するオブジェクト
19 this.isvalid = {
20 name: this.isValidName.bind(this),
21 gender: this.isValidGender.bind(this),
22 age: this.isValidAge.bind(this),
23 address: this.isValidAddress.bind(this),
24 message: this.isValidMessage.bind(this),
25 };
26 }
27 private isValidName(): boolean {
28 let trimed = this.name.trim();
29 return trimed.length > 0 && trimed.length < 21;
30
31 }
32 private isValidGender(): boolean {
33 const alloweds = [0, 1, 2];
34 return alloweds.includes(Number(this.gender));
35
36 }
37 private isValidAge(): boolean {
38 if (!this.age.trim().length) return false;
39 const age = Number(this.age);
40 return Number.isInteger(age) && Number(age) > -1;
41
42 }
43 private isValidAddress(): boolean {
44 let trimed = this.address.trim();
45 return trimed.length < 101;
46
47 }
48 private isValidMessage(): boolean {
49 let trimed = this.message.trim();
50 return trimed.length > 0 && trimed.length < 2001;
51
52 }
53
54 // 入力値をJSONに変換して渡す
55 public createSerializedData(): string {
56 return JSON.stringify({
57 name: this.name,
58 gender: this.gender,
59 age: this.age,
60 address: this.address,
61 message: this.message,
62 });
63 }
64}
this.isvalid
プロパティが、各入力項目のバリデーション関数を保持する役割を持つ。
createSerializedData
メソッドにより、各入力地をJSON形式で他のクラスに渡す。
FormController
1interface IFormControllerArg {
2 formView: FormView;
3 modalScreen: HTMLDivElement;
4};
5
6export class FormController {
7 private _formModel: FormModel | undefined;
8 private _formView: FormView;
9 private _modalScreen: HTMLDivElement;
10 constructor({
11 formView,
12 modalScreen
13 }: IFormControllerArg) {
14 this._formView = formView;
15 this._formModel;
16 this._modalScreen = modalScreen;
17 }
18
19 // submit時の実行メソッド
20 public onSubmit({
21 name,
22 gender,
23 age,
24 address,
25 message
26 }: IFormModelArg): void {
27 this._formView.errArea.innerHTML = "";
28
29 this._formModel = new FormModel({
30 name: name,
31 gender: gender,
32 age: age,
33 address: address,
34 message: message,
35 });
36
37 let errFounds = this.isvalid();
38 if (errFounds) {
39 this.setError(errFounds);
40 this._formView.errArea.scrollIntoView(false);
41 return;
42 }
43
44 // モーダル要素のshowイベントを発火する
45 this._modalScreen.dispatchEvent(
46 new CustomEvent("show", {
47 detail: {
48 serializedData: this._formModel.createSerializedData(),
49 },
50 })
51 );
52 }
53
54 // バリデーションチェック
55 private isvalid() {
56 let errFounds = [];
57 for (const prop of ["name", "gender", "age", "address", "message"]) {
58 if (this._formModel && !this._formModel.isvalid[prop]()) errFounds.push(prop);
59 }
60 return errFounds.length === 0 ? null : errFounds;
61 }
62
63 // エラー表示欄に内容を表示
64 private setError(errFounds: string[]) {
65 this._formView.errArea.innerHTML = errFounds.reduce((acc, curr) => {
66
67 return acc + formErrorMessages[curr] + "<br>";
68 }, "");
69 }
70}
71
Viewから入力値を受け取ってModelに渡し、
Modelよりバリデーション結果を受け取る。
バリデーションエラー発生時はViewにエラー内容を渡し、
正常にSubmitされた場合はCustomEvent
の[show]
イベントを発火させる。
イベント発火時、FormModel
から渡されたJSONデータをモーダル要素に渡す。
ModalView
1interface IModalViewArg {
2 screen: HTMLDivElement;
3 screenCover: HTMLDivElement;
4 dlBtn: HTMLButtonElement;
5 closeBtn: HTMLDivElement;
6
7}
8
9export class ModalView {
10 private screen: HTMLDivElement;
11 private screenCover: HTMLDivElement;
12 private dlBtn: HTMLButtonElement;
13 private closeBtn: HTMLDivElement;
14 private modalModel: ModalModel;
15 constructor({
16 screen,
17 screenCover,
18 dlBtn,
19 closeBtn
20 }: IModalViewArg) {
21 this.modalModel = new ModalModel();
22 this.screen = screen;
23 this.screenCover = screenCover;
24 this.dlBtn = dlBtn;
25 this.closeBtn = closeBtn;
26
27 this.screen.addEventListener("show", ((ev: CustomEvent) => {
28 // モーダル要素を可視化させる
29 this.modalModel.serializedData = ev.detail.serializedData;
30 this.screen.hidden = !this.screen.hidden;
31 this.screenCover.hidden = !this.screenCover.hidden;
32 document.body.classList.add("preventScroll");
33 }) as EventListener);
34 this.dlBtn.onclick = this.download.bind(this);
35 this.closeBtn.onclick = this.close.bind(this);
36 }
37 private download() {
38 const text = this.modalModel.createText();
39 const link = document.createElement("a");
40 link.download = "受理内容.txt";
41 link.href = this.modalModel.createTextBlob(text);
42
43 link.click();
44
45 URL.revokeObjectURL(link.href);
46 }
47 private close() {
48 this.screen.hidden = !this.screen.hidden;
49 this.screenCover.hidden = !this.screenCover.hidden;
50 document.body.classList.remove("preventScroll");
51 }
52}
53
show
イベントが発火されると、
スクリーンカバー用のdiv
要素とModal要素を出現させる。
ダウンロードボタン、☓ボタン押下時のイベントをそれぞれ持つ(download
, close
)。
download
イベント発火時、
フォームに入力した値を、受理内容.txtというテキストファイルとしてローカルにダウンロードする。
ModalModel
1const textTemplate = `※ご意見フォーム送信フェイク※
2下記内容で承りました。
3
4【氏名】$name
5【性別】$gender
6【年齢】$age歳
7【住所】$address
8【ご意見内容】
9$message
10
11-----------------------
12
13受理日時:$date
14
15`;
16
17export class ModalModel {
18 private _serializedData: string = "";
19 set serializedData(value: string) {
20 this._serializedData = value;
21 }
22
23 // JSONをパースして受理内容のテキストを作成する
24 public createText() {
25 const data = JSON.parse(this._serializedData) as IFormModelArg;
26
27 // convert gender:int to string
28 data.gender = ["その他", "男性", "女性"][Number(data.gender)];
29
30 let text = textTemplate;
31 let key: keyof IFormModelArg;
32 for (key in data) {
33 if (Object.hasOwnProperty.call(data, key)) {
34 text = text.replace("$" + key, data[key]);
35 }
36 }
37 const momentFunc = (moment as any).default ? (moment as any).default : moment;
38 text = text.replace("$date", momentFunc().format("YYYY年MM月DD日"));
39
40 return text;
41 }
42 public createTextBlob(text: string) {
43 const blob = new Blob([text], { type: "text/plain" });
44 return URL.createObjectURL(blob);
45 }
46}
createText
メソッドにより、JSONデータをテキストデータに変換する。
デモ(Vercel)
終わりに
練習として、
Reactなどのフレームワークは使用せずにプレーンなTypescriptで実装した。
こうしてみると、各種フレームワークがどのような要望や要請をもとに作成されているのか
(フレームワークを使わないことによる作業のボトルネックはどこにあるか)が少しはわかるような気がする。