Skip to content

Commit

Permalink
feat(todoapp): フォームとイベント (#449)
Browse files Browse the repository at this point in the history
* feat(todoapp): Todoとイベント

* feat(todoapp): 入力内容をコンソールに表示する

* feat(todoapp): Todoアイテムの追加

* add summary

* chore: add title

* test(todoapp): add test for event-driven

* fix(todoapp): form eventに経こう

* fix: test name

* fix title

* bold

* fix

* fix link

* fix link

* fix link

* add <!-- doctest:disable -->

* fix typo

* fix lint
  • Loading branch information
azu authored May 5, 2018
1 parent d58bbfb commit 91eccf9
Show file tree
Hide file tree
Showing 41 changed files with 676 additions and 25 deletions.
1 change: 1 addition & 0 deletions source/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@
- [Todoアプリ](./use-case/todoapp/README.md)
- [エントリポイント](./use-case/todoapp/entrypoint/README.md)
- [アプリの構成要素](./use-case/todoapp/app-structure/README.md)
- [フォームとイベント](./use-case/todoapp/form-event/README.md)
6 changes: 3 additions & 3 deletions source/use-case/todoapp/app-structure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ author: azu
HTMLとJavaScriptの[エントリポイント][]を作成しましたが、次のはこのTodoアプリの構成要素をあらためて見ていきましょう。
Todoアプリは、次のような機能を実装していくため複数の機能を実装していく必要があります。

- Todoを追加する
- Todoを更新する
- Todoを削除する
- Todoアイテムを追加する
- Todoアイテムを更新する
- Todoアイテムを削除する

また、アプリと呼ぶからには見た目もちょっとしたものにしないと雰囲気が出ません。
このセクションでは、多くのウェブアプリケーションを構成するHTML、CSS、JavaScriptの役割について見ていきます。
Expand Down
2 changes: 1 addition & 1 deletion source/use-case/todoapp/app-structure/todo-html/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { App } from "./src/App";
import { App } from "./src/App.js";
const app = new App();
13 changes: 13 additions & 0 deletions source/use-case/todoapp/cypress/helper/todo-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// MIT © 2018 azu
"use strict";
/**
* TODOアイテムを追加
* @param {string} title
* @returns {Cypress.Chainable<JQuery<HTMLElement>>}
*/
const addNewTodo = (title) => {
cy.get("#js-form-input").type(title);
return cy.get("#js-form").submit();
};

module.exports.addNewTodo = addNewTodo;
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
const URL = "/app-structure/todo-html";
const visitWithConsole = require("../../../helper/visit-with-console").visitWithConsole;
describe(URL, function() {
it(".todoappにスタイルが適応されている", function() {
cy.visit(URL).then((win) => {
const position = win.getComputedStyle(win.document.querySelector(".todoapp")).position;
expect(position).to.equal("relative");
});
});
it("ロードするとApp.jsのログが表示される", function() {
visitWithConsole(URL).then(({ logSpy }) => {
const log0 = logSpy.getCall(0).args[0];
const log1 = logSpy.getCall(1).args[0];
expect(log0).to.equal("App.js: loaded");
expect(log1).to.equal("App initialized");
});
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
/**
* Add new Todo
* @param {string} title
*/
const addNewTodo = (title) => {
cy.get("#js-form-input").type(title);
cy.get("#js-form").submit();
};
const addNewTodo = require("../../../helper/todo-helper").addNewTodo;

const URL = "/final/final";
describe("Todo", function() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const addNewTodo = require("../../../helper/todo-helper").addNewTodo;
const URL = "/form-event/add-todo-item";
describe(URL, function() {
it("入力欄を埋めて送信するとTodoアイテム(li)のみが追加される", function() {
cy.visit(URL);
const inputText = "test";
addNewTodo(inputText).then(() => {
// ulはない
cy.get("#js-todo-list ul").should(items => {
expect(items).to.have.length(0);
});
// liはある
cy.get("#js-todo-list li").should(items => {
expect(items).to.have.length(1);
});
});
addNewTodo(inputText).then(() => {
// liが増える
cy.get("#js-todo-list li").should(items => {
expect(items).to.have.length(2);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const addNewTodo = require("../../../helper/todo-helper").addNewTodo;
const URL = "/form-event/prevent-event";
const visitWithConsole = require("../../../helper/visit-with-console").visitWithConsole;
describe(URL, function() {
it("入力欄を埋めて送信するとコンソールログに表示される", function() {
visitWithConsole(URL).then(({ logSpy }) => {
const inputText = "test";
addNewTodo(inputText).then(() => {
const logCalls = logSpy.getCalls();
const lastLog = logCalls[logCalls.length - 1].args[0];
expect(lastLog).to.equal(`入力欄の値: ${inputText}`);
});
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions source/use-case/todoapp/entrypoint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ serving "." at http://127.0.0.1:3030
起動したローカルサーバのURL(`http://127.0.0.1:3030`)にブラウザでアクセスしてみましょう。
ブラウザには`index.html`の内容が表示され、開発者ツールのコンソールに`index.js: loaded`というログが出力されていることが確認できます。

![ログが表示されているWebコンソール](img/entry-point.png)
![ログが表示されているWebコンソール](img/first-entry.png)

----

Expand Down Expand Up @@ -236,7 +236,7 @@ import { App } from "./src/App.js";
```


[Ajax通信:エントリポイント]: ../ajaxapp/entrypoint/README.md
[Ajax通信:エントリポイント]: ../../ajaxapp/entrypoint/README.md
[Same Origin Policy]: https://developer.mozilla.org/ja/docs/Web/Security/Same-origin_policy
[Webコンソールを開く]: https://developer.mozilla.org/ja/docs/Tools/Web_Console/Opening_the_Web_Console
[npmを使ってパッケージをインストールする]: ../../nodecli/argument-parse/README.md#use-npm
Expand Down
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions source/use-case/todoapp/final/final/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { App } from "./src/App.js";

const app = new App();
window.addEventListener("load", () => {
const container = document.getElementById("js-todo-list");
app.render(container);
const container = document.querySelector("#js-todo-list");
app.mount(container);
});
window.addEventListener("unload", () => {
app.release();
Expand Down
14 changes: 7 additions & 7 deletions source/use-case/todoapp/final/final/src/App.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { render } from "./views/html-util.js";
import { TodoListView } from "./views/TodoListView.js";
import { TodoItem } from "./models/TodoItem.js";
import { TodoList } from "./models/TodoList.js";
import { render } from "./view/html-util.js";
import { TodoListView } from "./view/TodoListView.js";
import { TodoItem } from "./model/TodoItem.js";
import { TodoList } from "./model/TodoList.js";

export class App {
constructor() {
Expand Down Expand Up @@ -42,9 +42,9 @@ export class App {
* `containerElement`に対してTodoListを描画する
* @param {HTMLElement} containerElement
*/
render(containerElement) {
const form = document.getElementById("js-form");
const inputElement = document.getElementById("js-form-input");
mount(containerElement) {
const form = document.querySelector("#js-form");
const inputElement = document.querySelector("#js-form-input");
form.addEventListener("submit", (event) => {
// prevent submit action
event.preventDefault();
Expand Down
4 changes: 2 additions & 2 deletions source/use-case/todoapp/final/final/test/TodoList-test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// LICENSE : MIT
"use strict";
const assert = require("assert");
import { TodoItem } from "../src/models/TodoItem.js";
import { TodoList } from "../src/models/TodoList.js";
import { TodoItem } from "../src/model/TodoItem.js";
import { TodoList } from "../src/model/TodoList.js";

const assertTodo = (todo) => {
assert.ok(typeof todo.id === "number");
Expand Down
190 changes: 190 additions & 0 deletions source/use-case/todoapp/form-event/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
---
author: azu
---

# フォームとイベント {#form-event}

ここからはJavaScriptでTodoアプリの動作を実際に作っていきます。

このセクションでは、前のセクションでHTMLに目印を付けたTodoリスト(`#js-todo-list`)に対してTodoアイテムを追加する処理を作っていきます。

## Todoアイテムの追加 {#add-todo-item}

ユーザーが次のような操作を行い、Todoアイテムを追加します。

1. 入力欄にTodoアイテムのタイトルを入力する
2. 入力欄でEnterを押し送信する
3. TodoリストにTodoアイテムが追加される

これをJavaScriptで実現するには次のことが必要です。

- form要素から送信(`submit`)されたことをイベントで受け取る
- input要素(入力欄)に入力された内容を取得する
- 入力内容をタイトルにしたTodoアイテムを作成し、Todoリスト(`#js-todo-list`)にTodoアイテム要素を追加する

まずは、form要素から送信されたイベントを受け取り、入力内容をコンソールログに表示してみることから始めてみましょう。

## 入力内容をコンソールに表示 {#input-to-console}

form要素でEnterを押し送信すると`submit`イベントが発火されます。
この`submit`イベントは`addEventListener`メソッドを利用することで受け取れます。

<!-- doctest:disable -->
```js
// id="js-form`の要素を取得
const formElement = document.querySelector("#js-form");
// form要素から発火されたsubmitイベントを受け取る
formElement.addEventListener("submit", (event) => {
// イベントが発火された時に呼ばれるコールバック関数
});
```

フォームが送信されたときに入力内容をコンソールに表示するには、
`addEventListener`コールバック関数内で入力内容をConsole APIで出力すればよいことになります。

入力内容はinput要素の`value`プロパティから取得できます。

<!-- doctest:disable -->
```js
const inputElement = document.querySelector("#js-form-input");
console.log(inputElement.value); // => "input要素の入力内容"
```

これらを組み合わせて`App.js`に「入力内容をコンソールに表示」する機能を実装してみましょう。
`App`クラスに`mount`というメソッドを定義して、その中に処理を書いていきましょう。

次のようにフォーム(`#js-form`)をEnteで送信すると、input要素(`#js-form-input`)に書かれた内容が開発者ツールのコンソールに表示するという実装を行います。

[import, title:"src/App.js"](./prevent-event/src/App.js)

このままでは、`App#mount`は呼び出されないため何も行われません。
そんのため、`index.js`も変更して、`App`クラスの`mount`メソッドを呼び出すようにします。

[import, title:"index.js"](./prevent-event/index.js)

これらの変更後にブラウザでページをリロードすると、`App#mount`が実行されるようになります。
`submit`イベントが監視されているので、入力欄に何か入力してEnterで送信してみるとその内容がコンソールに表示されます。

![入力内容がコンソールに表示される](./img/event-driven.png)

先ほどの`App#mount`では、発火された`submit`イベントのコールバック関数内で`event.preventDefault();`を呼び出しています。
`preventDefault`メソッドは`submit`イベントが発火されたフォーム本来の動作をキャンセルするメソッドです。
フォーム本来の処理とは、フォームの内容を指定したURLへ送信するという動作です。
ここでは`form`要素に送信先が指定されていないため、現在のURLに対してフォームを送信が行われるのをキャンセルしています。

<!-- doctest:disable -->
```js
formElement.addEventListener("submit", (event) => {
// submitイベントの本来の動作を止める
event.preventDefault();
console.log(`入力欄の値: ${inputElement.value}`);
});
```

<!-- textlint-disable no-js-function-paren -->

現在のURLに対してフォームを送信が行われると、結果的にページがリロードされてしまうため、`event.preventDefault()`を呼び出していました。
これは`event.preventDefault()`をコメントアウトすると、ページがリロードされてしまうことが確認できます。

<!-- textlint-enable no-js-function-paren -->

<!-- doctest:disable -->
```js
formElement.addEventListener("submit", (event) => {
// preventDefaultしないとページがリロードされてしまう
// event.preventDefault();
console.log(`入力欄の値: ${inputElement.value}`);
});
```

ここまでで`todoapp`ディレクトリは次のような変更を加えました。

```
todoapp
├── index.html
├── index.js (App#mountの呼び出し)
├── package.json
└── src
└── App.js (App#mountの実装)
```


ここまでのTodoアプリは次のURLで実際に確認できます。

<a href="./prevent-event//" target="_blank">https://asciidwango.github.io/js-primer/use-case/todoapp/form-event/prevent-event/</a>

## 入力内容をTodoリストに表示 {#input-to-todolist}

フォーム送信時に入力内容を取得する方法が分かったので、次はその入力内容をTodoリスト(`#js-todo-list`)に表示します。

HTMLではリストのアイテムを記述する際には`<li>`タグを使います。
また後ほどTodoリストに表示するTodoアイテムの要素には、完了状態を表すチェックボックスや削除ボタンなども含めたいです。
これらの要素を含むものを手続き的にDOM APIで作成すると見通しが悪くなるため、HTML文字列からHTML要素を生成するユーティリティモジュールを作成しましょう。

次の`html-util.js``src/view/html-util.js`というパスに作成します。

この`html-util.js`は「[ajaxapp: HTML文字列をDOMに追加する][]」でも利用した`escapeSpecialChars`をベースにしています。
ajaxappでの`escapeHTML`タグ関数では出力は**HTML文字列**でしたが、今回作成する`element`タグ関数の出力は**HTML要素**(Element)です。

これはTodoリスト(`#js-todo-list`)というすでに存在する要素に対して要素を**追加**するには、HTML文字列ではなく要素が必要になります。
また、HTML文字列に対しては`addEventListener`でイベントを監視するということはできません。
そのため、チェックボックスの状態が変わったことや削除ボタンが押されたことを知る必要があるTodoアプリでは要素が必要になります。

[import, title:"src/view/html-util.js"](./add-todo-item/src/view/html-util.js)

`element`タグ関数では、同じファイルに定義した`htmlToElement`関数を使ってHTML文字列からHTML要素を作成しています。
`htmlToElement`関数の中で利用している[template要素][]はHTML5で追加された、HTML文字列の断片からHTML要素を作成できる要素です。

この`element`タグ関数を使うことで、次のようにHTML文字列からHTML要素を作成できます。
作成した要素は、`appendChild`メソッドなどで既存の要素に子要素として追加できます。

<!-- doctest:disable -->
```js
// HTML文字列からHTML要素を作成
const newElement = element`<ul>
<li>新しい要素</li>
</ul>`;
// 作成した要素を既存の要素に追加(appendChild)する
document.body.appendChild(newElement);
```

次に、この`element`タグ関数を使い、フォームから送信された入力内容をTodoリストに要素として追加してみます。

`App.js`から先ほど作成した`html-util.js``element`タグ関数を`import`します。
そして`submit`イベントのハンドラで、Todoアイテムを表現する要素を作成し、Todoリスト(`#js-todo-list`)の子要素として追加(`appendChild`)します。

[import, title:"src/App.js"](./add-todo-item/src/App.js)

これらの変更後にブラウザでページをリロードすると、入力内容を送信するたびにTodoリスト下へTodoアイテムが追加されます。

このセクションでの変更点は次のとおりです。

```
todoapp
├── index.html
├── index.js
├── package.json
└── src
├── App.js(Todoアイテムの表示の実装)
└── view
└── html-util.js(追加)
```


現在のTodoアプリは次のURLで実際に確認できます。

<a href="./add-todo-item/" target="_blank">https://asciidwango.github.io/js-primer/use-case/todoapp/form-event/add-todo-item/</a>

## まとめ {#conclusion}

このセクションではform要素の`submit`イベントを監視し、入力内容を元にTodoアイテムをTodoリストの追加を実装しました。
今回のTodoアイテムの追加のように多くのウェブアプリは、何らかのイベントが発生うぃ、そのイベントを監視してJavaScriptで処理し表示を更新します。
このようなイベントが発生したことを元に処理を進める方法を**イベント駆動**(イベントドリブン)と呼びます。

今回のTodoアイテムの追加では、`submit`イベントを入力にして、**直接**Todoリスト要素の内容を更新という出力をしていました。
このように直接DOMを更新するという方法はコードが短くなりますが、柔軟性がなくなるという問題があります。

次のセクションではこの問題点を解消するために、今回扱ったイベントの仕組みをより深く見ていきます。

[ajaxapp: HTML文字列をDOMに追加する]: ../../ajaxapp/display/README.md#html-to-dom
[template要素]: https://developer.mozilla.org/ja/docs/Web/HTML/Element/template
18 changes: 18 additions & 0 deletions source/use-case/todoapp/form-event/add-todo-item/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Todo App</title>
<link href="https://asciidwango.github.io/js-primer/use-case/todoapp/final/final/index.css" rel="stylesheet" />
</head>
<body>
<div class="todoapp">
<form id="js-form">
<input id="js-form-input" class="new-todo" type="text" placeholder="What need to be done?" autocomplete="off">
</form>
<div id="js-todo-list" class="todo-list">
<!-- 動的に更新されるTodoリスト -->
</div>
</div>
<script src="./index.js" type="module"></script>
</body>
</html>
3 changes: 3 additions & 0 deletions source/use-case/todoapp/form-event/add-todo-item/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { App } from "./src/App.js";
const app = new App();
app.mount();
Loading

0 comments on commit 91eccf9

Please sign in to comment.