0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

✏️【第⑥回】TODOアプリ レベル3:タイトルを編集できるようにしよう!

Posted at

🏫 はじめに

こんにちは。
HPSサークル顧問の権藤俊です。

ここまでで、TODOアプリはこんな機能ができています。

  • レベル1:タスクの登録一覧表示
  • レベル2:タスクの削除(論理削除:deletedフラグ)

今回はいよいよ、現実的なTODOアプリに必須の機能…

タスクのタイトルを編集する機能

を追加していきます。

しかも UI はちゃんと、

各行に「編集」ボタン → クリックするとその行だけ入力モード →
「保存」ボタンに変わって、更新完了後に一覧リロード

という、実務でもよくある形にします 💡


🧾 本記事の前提・注意事項

⚠️ 対象
北海道情報専門学校 HPSサークルの技術強化チーム向け教材です。
高校生・初学者・企業の方にも読めるように、やさしめに書いています。

⚠️ 技術前提

  • レベル2までのTODOアプリ(登録+削除+論理削除)が完成していること
  • スプレッドシートの構造が
    id | title | done | deleted | createdAt | updatedAt
    になっていること

⚠️ 動作保証

  • 記事の内容は執筆時点の仕様を前提としています。
  • 将来の仕様変更等により動かなくなる可能性があります。

🎯 レベル3のゴール

レベル3の完成イメージはこんな感じです。

  • 各タスクの右側に 「編集」ボタン がついている

  • 「編集」を押すと、その行だけ

    • タイトルがテキストボックスに変わる
    • 「編集」ボタンが「保存」ボタンに変わる
  • 「保存」を押すと

    • 入力チェック(空白禁止 / 100文字以内)を通過
    • スプレッドシートの titleupdatedAt を更新
    • load() で一覧を再読み込み
  • 削除済みフラグ(deleted)はそのまま維持

🧠 実装の全体像

今回の追加は大きく分けて2つです。

  1. Code.gs:編集用の関数 updateTodoTitle を追加

    • id をキーに対象行を探す
    • titleupdatedAt だけ書き換える
    • レベル2と同じバリデーション(空白NG / 100文字以内)
  2. 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>

完成イメージ

スクリーンショット 2025-12-09 16.12.03.png

📝 挙動の確認ポイント

  1. 通常時

    • タイトルは <span class="todo-title"> で表示
    • <input class="edit-input">display: none で隠れている
    • 「編集」「削除」ボタンが右側に並ぶ
  2. 編集ボタンを押したとき

    • <li>.editing クラスが付く(背景色が変わる)
    • span.todo-title が非表示になる
    • input.edit-input が表示される
    • 「編集」ボタンの文字が「保存」に変わる
  3. 「保存」を押したとき

    • 空白チェック&100文字チェック
    • OKなら google.script.run.updateTodoTitle(id, newTitle) を呼ぶ
    • 成功後 load() で一覧再読み込み

✍️ 執筆情報

執筆:HPSサークル顧問 権藤俊

本記事は北海道情報専門学校 HPSサークルの教材として作成しました。
記事の内容は執筆時点の仕様に基づきます。
学習や授業での利用は歓迎です。Qiitaへのリンク共有もご自由にどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?