🏫 はじめに
こんにちは。
HPSサークル顧問の権藤俊です。
ここまでで、TODOアプリはこんな機能ができています。
- レベル1:タスクの登録と一覧表示
- レベル2:タスクの削除(論理削除:deletedフラグ)
今回はいよいよ、現実的なTODOアプリに必須の機能…
✅ タスクのタイトルを編集する機能
を追加していきます。
しかも UI はちゃんと、
各行に「編集」ボタン → クリックするとその行だけ入力モード →
「保存」ボタンに変わって、更新完了後に一覧リロード
という、実務でもよくある形にします 💡
🧾 本記事の前提・注意事項
⚠️ 対象
北海道情報専門学校 HPSサークルの技術強化チーム向け教材です。
高校生・初学者・企業の方にも読めるように、やさしめに書いています。
⚠️ 技術前提
- レベル2までのTODOアプリ(登録+削除+論理削除)が完成していること
- スプレッドシートの構造が
id | title | done | deleted | createdAt | updatedAt
になっていること
⚠️ 動作保証
- 記事の内容は執筆時点の仕様を前提としています。
- 将来の仕様変更等により動かなくなる可能性があります。
🎯 レベル3のゴール
レベル3の完成イメージはこんな感じです。
-
各タスクの右側に 「編集」ボタン がついている
-
「編集」を押すと、その行だけ
- タイトルがテキストボックスに変わる
- 「編集」ボタンが「保存」ボタンに変わる
-
「保存」を押すと
- 入力チェック(空白禁止 / 100文字以内)を通過
- スプレッドシートの
titleとupdatedAtを更新 -
load()で一覧を再読み込み
-
削除済みフラグ(
deleted)はそのまま維持
🧠 実装の全体像
今回の追加は大きく分けて2つです。
-
Code.gs:編集用の関数
updateTodoTitleを追加-
idをキーに対象行を探す -
titleとupdatedAtだけ書き換える - レベル2と同じバリデーション(空白NG / 100文字以内)
-
-
view.html:編集ボタンと編集モードのUIを実装
- 各行に「編集」ボタンと隠れた
<input>を持たせる - 「編集」クリックで input 表示&ボタンを「保存」に
- 「保存」クリックで
google.script.run.updateTodoTitle(...)を呼ぶ
- 各行に「編集」ボタンと隠れた
順番にやっていきます。
🧰 Step 1:Code.gs に編集用関数を追加する
まずはサーバ側(GAS)です。
レベル2の Code.gs はそのままにして、一番下に関数を1つ追加します。
✅ 既存の
sheet()/fetchLatest()/addTodo()/deleteTodo()はそのままでOKです。
// 1件のタスクのタイトルを編集する関数
function updateTodoTitle(id, newTitle) {
// 受け取ったidを文字列化し、前後の空白を取り除く
const targetId = String(id || '').trim();
// idが空の場合はエラー
if (!targetId) {
throw new Error('更新するタスクのIDが指定されていません');
}
// 受け取ったタイトルを文字列化し、前後の空白を取り除く
const t = String(newTitle || '').trim();
// 空白だけの入力は禁止(レベル2と同じルール)
if (!t) {
throw new Error('タイトルは必須です(空白のみは不可)');
}
// 100文字以上は禁止(レベル2と同じルール)
if (t.length > 100) {
throw new Error('タイトルは100文字以内で入力してください');
}
// 作業対象のシートを取得
const sh = sheet();
// シートの全データを2次元配列で取得
const values = sh.getDataRange().getValues();
// 対象行のシート上の行番号(1始まり)を保持する変数
let targetRowNumber = -1;
// 2行目(index 1)以降をループして、idが一致する行を探す
for (let i = 1; i < values.length; i++) {
// i行目のid列(A列, index 0)を取り出す
const rowId = values[i][0];
// idが一致したら、その行が更新対象
if (rowId === targetId) {
// シート上の行番号は index + 1 になるので i + 1 を保存
targetRowNumber = i + 1;
// 見つかったのでループを抜ける
break;
}
}
// 該当するidが見つからなかった場合はエラー
if (targetRowNumber === -1) {
throw new Error('指定されたIDのタスクが見つかりません(編集)');
}
// 現在時刻をISO形式の文字列で取得
const now = new Date().toISOString();
// title列の列番号(B列なので2)
const titleCol = 2;
// updatedAt列の列番号(F列なので6)
const updatedAtCol = 6;
// 対象行のtitle列を新しいタイトルで上書き
sh.getRange(targetRowNumber, titleCol).setValue(t);
// 対象行のupdatedAt列を現在時刻で更新
sh.getRange(targetRowNumber, updatedAtCol).setValue(now);
// フロント側に成功を伝えるシンプルなオブジェクト
return { ok: true };
}
これでサーバ側の準備はOKです 🎉
🎨 Step 2:view.html に「編集モード」を組み込む
次にフロント側(view.html)です。
レベル2の view.html をベースに、
一覧部分とJSの一部を編集機能対応に差し替えるイメージです。
ここでは完成版の
view.htmlを丸ごと載せます。
レベル3ではこちらに置き換えてしまってOKです。
🧩 view.html(レベル3 完成版・コメント付き)
<!DOCTYPE html>
<html>
<head>
<!-- 文字コードをUTF-8に設定 -->
<meta charset="utf-8" />
<!-- スマホでも見やすくするための設定 -->
<meta name="viewport" content="width=device-width,initial-scale=1" />
<!-- ブラウザタブに表示されるタイトル -->
<title>TODO APP - Level 3</title>
<style>
/* 画面全体の基本スタイル */
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
max-width: 700px;
margin: 40px auto;
padding: 0 16px;
}
/* タイトル見出し */
h1 {
text-align: center;
color: #1976d2;
font-size: 1.8rem;
margin-bottom: 24px;
}
/* 入力フォームを横並びにするコンテナ */
.input-box {
display: flex;
gap: 8px;
}
/* タイトル入力欄のスタイル */
input[type="text"] {
flex: 1;
padding: 10px 12px;
border: 1px solid #ccc;
border-radius: 8px;
}
/* ボタン共通のスタイル */
button {
padding: 10px 16px;
background: #1976d2;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
/* 押せない状態のボタンの見た目 */
button:disabled {
opacity: .5;
cursor: not-allowed;
}
/* メッセージ表示領域のスタイル */
.msg {
margin-top: 8px;
font-size: 0.9rem;
}
/* 成功メッセージの色 */
.ok {
color: #1b8e3f;
}
/* エラーメッセージの色 */
.error {
color: #b71c1c;
}
/* 未完タスク数の表示部分 */
.count-box {
margin: 16px 0;
font-weight: bold;
color: #333;
}
/* TODO一覧(ul)のスタイル */
ul.todo-list {
list-style: none;
padding: 0;
}
/* 各タスク行(li)のスタイル */
.todo-item {
display: flex;
align-items: center;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
margin-bottom: 8px;
background: #fafafa;
gap: 8px;
}
/* タイトル部分のテキスト */
.todo-title {
flex: 1;
word-break: break-all;
}
/* 編集用の入力欄 */
.edit-input {
flex: 1;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 6px;
display: none; /* 最初は非表示(編集モードのときだけ表示) */
}
/* ボタンをまとめる領域 */
.btn-group {
display: flex;
gap: 4px;
}
/* 削除ボタン用の色(赤) */
.delete-btn {
background: #e53935;
}
/* 編集モード中の行を分かりやすくするための背景色 */
.todo-item.editing {
background: #e3f2fd;
}
</style>
</head>
<body>
<!-- アプリのタイトル -->
<h1>📝 TODO APP(Level 3)</h1>
<!-- タスクの新規登録フォーム -->
<div class="input-box">
<!-- タイトル入力欄(最大100文字) -->
<input
id="title"
type="text"
placeholder="新しいタスクを入力…"
maxlength="100"
/>
<!-- 追加ボタン(入力が空のときはdisabled) -->
<button id="add" disabled>追加</button>
</div>
<!-- 成功・エラーメッセージの表示エリア -->
<div id="msg" class="msg"></div>
<!-- 未完タスク数の表示エリア -->
<div id="count" class="count-box">未完タスク数: 0</div>
<!-- TODO一覧(liはJavaScriptで生成) -->
<ul id="list" class="todo-list"></ul>
<script>
// document.querySelector の短縮版ヘルパー
const $ = (selector) => document.querySelector(selector);
// メッセージを表示する関数(text: テキスト, cls: "ok" or "error")
function showMsg(text, cls = '') {
const m = $('#msg');
m.textContent = text;
m.className = 'msg ' + cls;
// 何かメッセージがあれば2秒後に自動で消す
if (text) {
setTimeout(() => {
m.textContent = '';
m.className = 'msg';
}, 2000);
}
}
// タイトル入力欄の内容が変わったときの処理
$('#title').addEventListener('input', () => {
// 現在の入力値を取得
const value = $('#title').value;
// 前後の空白を取り除いた文字列
const trimmed = value.trim();
// 有効な文字数
const length = trimmed.length;
// 空かどうか
const isEmpty = length === 0;
// 100文字を超えているか
const isTooLong = length > 100;
// 1〜100文字のときだけ追加ボタンを有効にする
$('#add').disabled = isEmpty || isTooLong;
});
// タスクを追加する関数
function add() {
// 入力欄の値を取得し、trimで前後の空白を削除
const raw = $('#title').value;
const title = raw.trim();
const length = title.length;
// 空白のみは禁止
if (length === 0) {
showMsg('タイトルは必須です(空白のみは不可)', 'error');
return;
}
// 100文字以上は禁止
if (length > 100) {
showMsg('タイトルは100文字以内で入力してください', 'error');
return;
}
// 通信中は二重押しを防ぐためボタンを無効にする
$('#add').disabled = true;
// GAS側の addTodo(title) を呼び出して登録する
google.script.run
.withSuccessHandler(() => {
// 成功したら入力欄を空にする
$('#title').value = '';
// 追加ボタンを再度無効化(空なので)
$('#add').disabled = true;
// 成功メッセージを表示
showMsg('追加しました!', 'ok');
// 最新状態を取得して一覧をリロード
load();
})
.withFailureHandler((err) => {
// 失敗時はエラーメッセージを表示
showMsg(err && err.message ? err.message : 'エラーが発生しました', 'error');
// ボタンを再度有効にする
$('#add').disabled = false;
})
// Code.gs の addTodo(title) を呼ぶ
.addTodo(title);
}
// 「追加」ボタンが押されたとき
$('#add').addEventListener('click', add);
// Enterキーでも追加できるようにする
$('#title').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
add();
}
});
// 一覧を読み込み、画面に表示する関数
function load() {
// GAS側の fetchLatest() を呼び出す
google.script.run
.withSuccessHandler((data) => {
// rows: 表示対象のタスク配列, total: 未完タスク数
const rows = data.rows || [];
const total = data.total || 0;
// 未完タスク数を表示
$('#count').textContent = '未完タスク数: ' + total;
// 各行のHTMLを組み立てる
const html = rows
.map((r) => {
// r[0] = id, r[1] = title
const id = r[0];
const title = r[1];
// li要素の中にタイトル表示用spanと、編集用input、ボタンを置く
return `
<li class="todo-item">
<span class="todo-title">${title}</span>
<input class="edit-input" type="text" value="${title}">
<div class="btn-group">
<button class="edit-btn" data-id="${id}">編集</button>
<button class="delete-btn" data-id="${id}">削除</button>
</div>
</li>`;
})
.join('');
// ul要素にHTMLをセット
$('#list').innerHTML = html;
// 削除ボタンと編集ボタンにイベントを登録
bindDeleteButtons();
bindEditButtons();
})
.withFailureHandler((err) => {
// 一覧取得失敗時の処理
showMsg(err && err.message ? err.message : '一覧の取得に失敗しました', 'error');
})
// Code.gs の fetchLatest() を呼び出す
.fetchLatest();
}
// 削除ボタンにイベントを設定する関数(レベル2とほぼ同じ)
function bindDeleteButtons() {
// すべての削除ボタンを取得
const buttons = document.querySelectorAll('.delete-btn');
// 各ボタンにクリックイベントを登録
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
// data-id 属性からタスクIDを取得
const id = btn.dataset.id;
// 行(li)要素を取得
const li = btn.closest('.todo-item');
// タイトル部分のテキストを取得(確認メッセージ用)
const titleSpan = li.querySelector('.todo-title');
const titleText = titleSpan ? titleSpan.textContent : '';
// 削除してよいか確認ダイアログを表示
const ok = confirm(`「${titleText}」を削除してよろしいですか?`);
// キャンセルなら何もしない
if (!ok) return;
// 削除中はボタンを無効化
btn.disabled = true;
// GAS側の deleteTodo(id) を呼び出す
google.script.run
.withSuccessHandler(() => {
// 成功メッセージを表示
showMsg('削除しました', 'ok');
// 一覧を再読み込み
load();
})
.withFailureHandler((err) => {
// エラーメッセージを表示
showMsg(err && err.message ? err.message : '削除に失敗しました', 'error');
// ボタンを再度有効にする
btn.disabled = false;
})
.deleteTodo(id);
});
});
}
// 編集ボタンにイベントを設定する関数(レベル3のメイン)
function bindEditButtons() {
// すべての編集ボタンを取得
const buttons = document.querySelectorAll('.edit-btn');
// 各ボタンについて処理を登録
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
// 対象行(li)を取得
const li = btn.closest('.todo-item');
// タイトル表示用のspan
const titleSpan = li.querySelector('.todo-title');
// 編集用のinput
const input = li.querySelector('.edit-input');
// data-id からIDを取得
const id = btn.dataset.id;
// まだ編集モードでない場合(=「編集」を押した瞬間)
if (!li.classList.contains('editing')) {
// liにeditingクラスを付けて背景色を変える
li.classList.add('editing');
// inputに現在のタイトルをセット
input.value = titleSpan.textContent;
// 表示用のspanを非表示にする
titleSpan.style.display = 'none';
// 編集用inputを表示する
input.style.display = 'inline-block';
// ボタンのラベルを「保存」に変える
btn.textContent = '保存';
// フォーカスをinputに当てる
input.focus();
// カーソルを文字の末尾に移動
const len = input.value.length;
input.setSelectionRange(len, len);
return;
}
// ここに来るのは editingクラスが付いているとき(=「保存」ボタンとして押されたとき)
// inputの値を取得し、前後の空白を削除
const newTitle = input.value.trim();
const length = newTitle.length;
// 空白のみは禁止
if (length === 0) {
showMsg('タイトルは必須です(空白のみは不可)', 'error');
return;
}
// 100文字以上は禁止
if (length > 100) {
showMsg('タイトルは100文字以内で入力してください', 'error');
return;
}
// 保存中はボタンを無効化
btn.disabled = true;
// GAS側の updateTodoTitle(id, newTitle) を呼び出す
google.script.run
.withSuccessHandler(() => {
// 成功メッセージを表示
showMsg('更新しました', 'ok');
// 一覧を再読み込み(新しいタイトルが反映される)
load();
})
.withFailureHandler((err) => {
// エラーメッセージを表示
showMsg(err && err.message ? err.message : '更新に失敗しました', 'error');
// ボタンを再度有効にする
btn.disabled = false;
})
.updateTodoTitle(id, newTitle);
});
});
}
// ページ読み込み時に一覧を表示
load();
</script>
</body>
</html>
完成イメージ
📝 挙動の確認ポイント
-
通常時
- タイトルは
<span class="todo-title">で表示 -
<input class="edit-input">はdisplay: noneで隠れている - 「編集」「削除」ボタンが右側に並ぶ
- タイトルは
-
編集ボタンを押したとき
-
<li>に.editingクラスが付く(背景色が変わる) -
span.todo-titleが非表示になる -
input.edit-inputが表示される - 「編集」ボタンの文字が「保存」に変わる
-
-
「保存」を押したとき
- 空白チェック&100文字チェック
- OKなら
google.script.run.updateTodoTitle(id, newTitle)を呼ぶ - 成功後
load()で一覧再読み込み
✍️ 執筆情報
執筆:HPSサークル顧問 権藤俊
本記事は北海道情報専門学校 HPSサークルの教材として作成しました。
記事の内容は執筆時点の仕様に基づきます。
学習や授業での利用は歓迎です。Qiitaへのリンク共有もご自由にどうぞ。
