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?

✍️【第③回】HTMLフォームからスプレッドシートへ「保存」してみましょう!

Posted at

🏫 はじめに

こんにちは!HPSサークル顧問の権藤です。
第②回ではスプレッドシートの値をWebに表示しました。第③回では HTMLフォームで入力 → スプレッドシートに追記(保存) するところまで進めます。
GAS特有の google.script.run(クライアント→GAS関数呼び出し)SpreadsheetApp での書き込み を体験します。


🧰 使用環境と注意事項

⚙️ 前提環境

  • Googleアカウント/ブラウザは Google Chrome 推奨
  • Googleドライブ上で作業します

⚠️ 注意事項

  • 本記事は 北海道情報専門学校 HPSサークル向け教材 を一般公開したものです。
  • 内容は 記事執筆時点の動作 に基づきます。将来の仕様変更で挙動が変わる可能性があります。

🎯 この回のゴール

  • ブラウザのフォームから タイトル を入力し、1行追記 できるようにします。
  • 入力時に最低限の バリデーション(未入力禁止・最大長) を行います。
  • 追加後に 最新10件表示 も更新します(第②回を簡約した表示付き)。

🗂️ 事前に用意するシート

①スプレッドシート(例:sample_db)に以下のヘッダを1行目に揃えてください。

id | title | done | createdAt | updatedAt

スクリーンショット 2025-11-11 19.47.59.png

②「拡張機能 → Apps Script」からGoogle Apps Scriptを作成する
スクリーンショット 2025-11-11 19.49.35.png

📎 設定のスクリプトプロパティ に以下を設定します(後述コードで参照します)。

  • SHEET_ID : 対象スプレッドシートのID
  • SHEET_NAME : 対象シート名(未設定なら シート1

🧠 GAS独自ポイント(今回のキモ)

  • SpreadsheetApp:サーバー側でシートを開き、appendRow() などで 追記 します

🧑‍💻 実装(完成コード)

同一プロジェクトに Code.gsview.html を作成して貼り付けてください。
スクリプトプロパティに SHEET_ID を設定後、デプロイ(ウェブアプリ)します。

Code.gs

/**
 * 第③回 最小構成:入力→保存(簡潔版)
 * 前提:シート1行目ヘッダは
 *   id | title | done | createdAt | updatedAt
 * スクリプトプロパティ:
 *   SHEET_ID(必須), SHEET_NAME(任意。未設定なら先頭シート)
 */
const SP = PropertiesService.getScriptProperties();

function doGet() {
  return HtmlService.createTemplateFromFile('view').evaluate()
    .setTitle('GAS: Input → Sheet Save (Simple)');
}

function sheet() {
  const id = SP.getProperty('SHEET_ID');
  if (!id) {
    throw new Error('SHEET_ID をスクリプトプロパティに設定してください');
  }
  const name = SP.getProperty('SHEET_NAME');
  const ss = SpreadsheetApp.openById(id);
  return name ? ss.getSheetByName(name) : ss.getSheets()[0];
}

// 最新 n 件を updatedAt 降順で返す(ヘッダ + 行データ)
function fetchLatest(n) {
  const sh = sheet();
  const values = sh.getDataRange().getValues(); // [ [header...], [row...], ... ]
  if (values.length < 2) {
    return { header: values[0] || [], rows: [], total: 0 };
  }

  const header = values[0];
  const rows = values.slice(1).filter(r => String(r[0] || '').trim() !== ''); // idが空を除外
  // updatedAt は 4列目(0始まりで index 4)
  rows.sort((a, b) => new Date(b[4]).getTime() - new Date(a[4]).getTime());

  const limit = Math.max(1, Number(n) || 10);
  return { header, rows: rows.slice(0, limit), total: rows.length };
}

// 1行追記(titleのみ入力、他は自動設定)
function addItem(title) {
  const t = String(title || '').trim();
  if (!t) {
    throw new Error('タイトルは必須です');
  }
  if (t.length > 100) {
    throw new Error('タイトルは100文字以内にしてください');
  }

  const now = new Date().toISOString();
  const sh = sheet();
  sh.appendRow([Utilities.getUuid(), t, false, now, now]);
  return { ok: true };
}

view.html

<html>
<head>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
  <title>GAS: Input → Sheet Save (Simple)</title>
  <style>
    body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Noto Sans JP,sans-serif;max-width:900px;margin:24px auto;padding:0 12px}
    h1{font-size:1.4rem;margin:0 0 12px}
    .row{display:flex;gap:8px;align-items:center}
    input[type=text]{flex:1;padding:10px 12px;border:1px solid #ccc;border-radius:10px}
    button{padding:10px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer}
    button[disabled]{opacity:.5;cursor:not-allowed}
    .msg{margin-top:8px}
    .ok{color:#1a8917}.error{color:#b00020}.muted{color:#666}
    table{border-collapse:collapse;width:100%;margin-top:16px}
    th,td{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top}
    thead{background:#f8f9fa}
    .toolbar{display:flex;gap:12px;align-items:center;margin:12px 0}
    .chip{display:inline-block;padding:2px 8px;border:1px solid #ddd;border-radius:999px;font-size:12px;color:#555}
  </style>
</head>
<body>
  <h1>📝 フォームからスプレッドシートへ保存(簡潔版)</h1>

  <div class="row">
    <input id="title" type="text" placeholder="タイトル(例:牛乳を買う)" maxlength="100"/>
    <button id="add">追加</button>
  </div>
  <div id="msg" class="msg muted"></div>

  <div class="toolbar">
    <span id="count" class="chip">0 件</span>
    <span class="muted">(最新10件表示)</span>
    <button id="reload">再読み込み</button>
  </div>

  <div id="root"></div>

  <script>
    const $ = (s) => document.querySelector(s);

    // 表示だけ日本語化
    const JP_HEADER = ['ID','タイトル','完了','作成日時','更新日時'];

    // TODO:エスケープ処理(XSS対策)

    function showMsg(text, cls='muted'){
      const el = $('#msg');
      el.className = 'msg ' + cls;
      el.textContent = text || '';
      if(text) {
        setTimeout(()=>{ el.textContent=''; el.className='msg muted'; }, 2000);
      }
    }

    // TODOテーブル表示関数
    function renderTable({ header, rows, total }){
      $('#count').textContent = `${rows.length} / ${total} 件`;
      if(!header || header.length === 0){
        $('#root').innerHTML = '<p class="muted">データがありません。</p>'; return;
      }
      const thead = `<thead><tr>${JP_HEADER.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead>`;
      const tbody = `<tbody>${
        rows.map(r => `<tr>${r.map(c => `<td>${esc(c)}</td>`).join('')}</tr>`).join('')
      }</tbody>`;
      $('#root').innerHTML = `<table>${thead}${tbody}</table>`;
    }

    function reload(){
      $('#reload').disabled = true;
      google.script.run
        .withSuccessHandler(d => { 
          renderTable(d);
          $('#reload').disabled = false;
        })
        .withFailureHandler(e => {
           showMsg(e?.message || String(e), 'error');
          $('#reload').disabled = false;
        })
        .fetchLatest(10); // ここでGASコードを呼び出す
    }

    function add(){

      // 入力チェック
      const v = $('#title').value.trim();
      if(!v){
        showMsg('タイトルは必須です','error'); return; 
      }
      if(v.length > 100){ 
        showMsg('タイトルは100文字以内です','error'); return;
      }

      // TODO:エスケープ処理(XSS対策)

      // TODO:重複タイトルチェック

      // 追加ボタン無効化
      $('#add').disabled = true;

      google.script.run
        .withSuccessHandler(res => {
          if(res?.ok) {
            $('#title').value=''; showMsg('追加しました','ok'); reload(); 
          } else {
            showMsg(res?.message || '追加に失敗しました','error'); 
          }
          $('#add').disabled = false;
        })
        .withFailureHandler(e => { 
          showMsg(e?.message || String(e), 'error');
          $('#add').disabled = false; 
        })
        .addItem(v); // ここでGASコードを呼び出す
    }

    // 追加ボタン押下時
    $('#add').addEventListener('click', add);

    // 再読み込みボタン押下時
    $('#reload').addEventListener('click', reload);

    // Enterキー押下時
    $('#title').addEventListener('keydown', e => { if(e.key === 'Enter') add(); });

    reload();
  </script>
</body>
</html>

スクリーンショット 2025-11-12 1.07.47.png

🧪 動作確認のポイント

  1. スクリプトプロパティに SHEET_ID(必須)と必要に応じて SHEET_NAME を設定します。
  2. デプロイ(ウェブアプリ)→ URLへアクセスしてフォームからタイトルを追加します。
  3. 追加後、下の一覧の 最新10件 に反映されることを確認します。
  4. シート側にも1行追記されているかを確認します。

🚀 発展課題

  • 入力チェック強化:重複タイトルの禁止、禁止文字のチェックなど。
  • 多項目フォームpriority(優先度)を追加。
  • サニタイズ:入力値のサーバ側エスケープ/正規表現検証を追加。

ヒント
view.htmlのコード内にある「TODO:〇〇〜」を対応すること

🔭 次回予告

第④回:動きのあるHTMLに変更(JavaScript埋め込み) 🎨
フォームの入力状態に応じたUI制御や、リストのインタラクション(簡易検索・強調表示)を追加して、見た目・操作感 を磨いていきます。

✍️ 執筆者情報

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

本記事は北海道情報専門学校 HPSサークルの教材として作成しました。

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?