PR

【GAS×bolt】スプレッドシートが空の状態から項目自動作成&データ入力できるフォームの作り方

GAS
記事内に広告が含まれています。

スプレッドシートで入力フォームを作ったのはいいけれど、毎回『ヘッダー項目』を自分で作るのって面倒ですよね。
今回は、AIツール『bolt』と『Google Apps Script (GAS)』を使って、スプレッドシートが空の状態から、項目を作ってデータ入力まで完結するフォームを作成した経緯を記事にしました。

前回は既存のシートを読み込むフォームを作りましたが、今回は一歩進んで初期状態の扱いを強化しました。
具体的には、『スプレッドシートに項目が一つもない状態』で、フォームを使ってA列に項目を追加してから、その項目に基づいたデータ入力・修正ができるようにするという仕様です。

boltで作成した際の指示文

まずはboltにスプレッドシートのアドレスを渡して、構造を認識させます。

やりたいことを伝えます。

この機能で今回はスプレッドシートでの項目行に合わせて新規追加フォーム・内容修正フォームを作成してます。

機能の変更で、スプレッドシート初期状態値が入ってない状態でA列に項目を挿入するフォームを作成してその内容に沿って、データの新規追加・内容修正を行えるように修正できますか?

boltが生成してくれたコードを実際にGASへ貼り付けて試します。
『ここの動作が上手くいかない』『修正ボタンを押した時に値が反映されない』といった具体的なフィードバックをboltに戻し、コードを再生成してもらう……この『対話』を繰り返すことで、徐々に精度を高めていきました。

GASに記入したコード

完成しました!空のスプレッドシートが、フォームからの操作で項目が作成され、フォームを使ってデータの修正・削除が可能になりました。

Code.gs

/**
 * 動的テーブル管理アプリ — サーバー側(GAS)
 * ============================================================
 * スプレッドシートをデータベース代わりに使い、
 * 項目(列)を自由に定義 → その項目に合わせてデータの新規追加・修正ができる。
 *
 * シート構成:
 *   1. 「項目定義」シート — 定義された項目一覧(A列: 項目名、B列: 表示順)
 *   2. 「データ」シート   — 登録されたデータ(1行目が項目名のヘッダー、2行目以降がデータ)
 */

// シート名の定義
var COLUMNS_SHEET = '項目定義';
var DATA_SHEET = 'データ';

/**
 * スプレッドシートを開いたときに自動実行される(シンプルトリガー)
 * 毎回メニューを追加するので、シートを開き直してもボタンが消えない
 */
function onOpen() {
  addMenuToSpreadsheet();
}

/**
 * アドオンインストール時にも実行される(念のため)
 */
function onInstall() {
  onOpen();
}

/**
 * メニューをスプレッドシートに追加する(共通処理)
 */
function addMenuToSpreadsheet() {
  SpreadsheetApp.getActiveSpreadsheet().addMenu('データ管理アプリ', [
    { name: 'アプリを開く', functionName: 'showApp' },
    { name: '初期セットアップ', functionName: 'setup' }
  ]);
}

/**
 * インストール可能なトリガーを作成する(一度実行すれば以降ずっと有効)
 * Apps Scriptエディタで createTrigger を実行する
 */
function createTrigger() {
  // 既存のonOpenトリガーを削除(重複を防ぐ)
  var triggers = ScriptApp.getProjectTriggers();
  for (var i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === 'onOpen') {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
  // 新しいインストール可能トリガーを作成
  ScriptApp.newTrigger('onOpen')
    .forSpreadsheet(SpreadsheetApp.getActiveSpreadsheet())
    .onOpen()
    .create();
  SpreadsheetApp.getUi().alert('トリガーを作成しました。\n以降、スプレッドシートを開くだけで自動的にメニューが表示されます。');
}

/**
 * 初回セットアップ — シートを2つ作成し、初期状態にする
 * メニューの「データ管理アプリ」→「初期セットアップ」から実行する
 */
function setup() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();

  // 項目定義シートを作成(なければ)
  var colsSheet = ss.getSheetByName(COLUMNS_SHEET);
  if (!colsSheet) {
    colsSheet = ss.insertSheet(COLUMNS_SHEET);
  }
  // ヘッダー行をセット(A列: 項目名、B列: 表示順)
  if (colsSheet.getLastRow() === 0) {
    colsSheet.getRange(1, 1, 1, 2).setValues([['項目名', '表示順']]);
    colsSheet.getRange(1, 1, 1, 2).setFontWeight('bold');
    colsSheet.getRange(1, 1, 1, 2).setBackground('#dbeafe');
    colsSheet.setColumnWidth(1, 200);
    colsSheet.setColumnWidth(2, 100);
  }

  // データシートを作成(なければ)
  var dataSheet = ss.getSheetByName(DATA_SHEET);
  if (!dataSheet) {
    dataSheet = ss.insertSheet(DATA_SHEET);
  }

  // メニューは onOpen で毎回自動追加されるので、ここでは不要

  // スプレッドシートを開いたときに自動でメニューが表示されるよう
  // インストール可能なトリガーを作成する
  createTrigger();

  // 今回すぐメニューを表示する
  addMenuToSpreadsheet();

  SpreadsheetApp.getUi().alert('セットアップが完了しました。\n以降、スプレッドシートを開くだけでメニューの「データ管理アプリ」が表示されます。');
}

/**
 * HTML画面を表示する(メニューから呼ばれる)
 */
function showApp() {
  var html = HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('データ管理アプリ')
    .setWidth(900)
    .setHeight(700)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
  SpreadsheetApp.getUi().showModalDialog(html, 'データ管理アプリ');
}

/**
 * 画面表示用の初期データを取得する
 * @return {{ columns: string[], rows: Object[] }}
 */
function getInitialData() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var colsSheet = ss.getSheetByName(COLUMNS_SHEET);
  var dataSheet = ss.getSheetByName(DATA_SHEET);

  var columns = [];
  var rows = [];

  // 項目定義を取得(2行目以降、A列)
  if (colsSheet && colsSheet.getLastRow() > 1) {
    var colData = colsSheet.getRange(2, 1, colsSheet.getLastRow() - 1, 1).getValues();
    for (var i = 0; i < colData.length; i++) {
      if (colData[i][0]) columns.push(String(colData[i][0]));
    }
  }

  // データを取得(1行目=ヘッダー、2行目以降=データ行)
  if (dataSheet && dataSheet.getLastRow() > 1 && columns.length > 0) {
    var lastCol = Math.max(columns.length, dataSheet.getLastColumn());
    var rangeData = dataSheet.getRange(2, 1, dataSheet.getLastRow() - 1, lastCol).getValues();
    for (var j = 0; j < rangeData.length; j++) {
      var values = rangeData[j].map(function(v) { return v === '' ? '' : String(v); });
      rows.push({ id: j + 2, values: values });  // id = スプレッドシートの行番号
    }
  }

  return { columns: columns, rows: rows };
}

/**
 * 新しい項目(列)を追加する
 * @param {string} name 項目名
 * @return {{ success: boolean, error?: string, columns?: string[] }}
 */
function addColumn(name) {
  if (!name || !name.trim()) {
    return { success: false, error: '項目名を入力してください' };
  }
  name = name.trim();

  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var colsSheet = ss.getSheetByName(COLUMNS_SHEET) || ss.insertSheet(COLUMNS_SHEET);
  var dataSheet = ss.getSheetByName(DATA_SHEET) || ss.insertSheet(DATA_SHEET);

  // 既存の項目一覧を取得
  var existing = getColumnsList(colsSheet);
  if (existing.indexOf(name) !== -1) {
    return { success: false, error: '同じ名前の項目がすでにあります' };
  }

  // 項目定義シートに追加(A列: 項目名、B列: 表示順)
  var nextRow = colsSheet.getLastRow() + 1;
  var nextOrder = existing.length + 1;
  colsSheet.getRange(nextRow, 1, 1, 2).setValues([[name, nextOrder]]);

  // データシートのヘッダー行に項目名を追加
  var headerCol = existing.length + 1;  // 追加位置は既存項目数+1列目
  dataSheet.getRange(1, headerCol).setValue(name);
  dataSheet.getRange(1, 1, 1, headerCol).setFontWeight('bold');
  dataSheet.getRange(1, 1, 1, headerCol).setBackground('#dbeafe');
  dataSheet.setColumnWidth(headerCol, 150);

  var columns = getColumnsList(colsSheet);
  return { success: true, columns: columns };
}

/**
 * 項目(列)を削除する
 * @param {string} name 項目名
 * @return {{ success: boolean, error?: string, columns?: string[] }}
 */
function deleteColumn(name) {
  if (!name) return { success: false, error: '項目名が指定されていません' };

  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var colsSheet = ss.getSheetByName(COLUMNS_SHEET);
  var dataSheet = ss.getSheetByName(DATA_SHEET);

  if (!colsSheet) return { success: false, error: '項目定義シートが見つかりません' };

  // 項目定義シートから該当行を探して削除
  var lastRow = colsSheet.getLastRow();
  if (lastRow < 2) return { success: false, error: '削除できる項目がありません' };
  var colData = colsSheet.getRange(2, 1, lastRow - 1, 1).getValues();
  var targetRow = -1;
  for (var i = 0; i < colData.length; i++) {
    if (String(colData[i][0]) === name) {
      targetRow = i + 2;  // シート上の行番号(ヘッダー+1ずらし)
      break;
    }
  }
  if (targetRow === -1) return { success: false, error: '該当する項目が見つかりません' };

  // 項目定義シートから行を削除
  colsSheet.deleteRow(targetRow);

  // データシートのヘッダーから該当列を探して削除
  if (dataSheet && dataSheet.getLastColumn() > 0) {
    var headerRow = dataSheet.getRange(1, 1, 1, dataSheet.getLastColumn()).getValues()[0];
    for (var c = 0; c < headerRow.length; c++) {
      if (String(headerRow[c]) === name) {
        dataSheet.deleteColumn(c + 1);
        break;
      }
    }
  }

  var columns = getColumnsList(colsSheet);
  return { success: true, columns: columns };
}

/**
 * 新しいデータ行を追加する
 * @param {Object} values 項目名→値 のオブジェクト
 * @return {{ success: boolean, error?: string }}
 */
function addRow(values) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var colsSheet = ss.getSheetByName(COLUMNS_SHEET);
  var dataSheet = ss.getSheetByName(DATA_SHEET) || ss.insertSheet(DATA_SHEET);

  if (!colsSheet) return { success: false, error: '項目定義シートが見つかりません' };

  var columns = getColumnsList(colsSheet);
  if (columns.length === 0) {
    return { success: false, error: '項目が定義されていません。先に項目を追加してください。' };
  }

  // ヘッダー行がまだ無ければセット
  if (dataSheet.getLastColumn() < columns.length) {
    dataSheet.getRange(1, 1, 1, columns.length).setValues([columns]);
    dataSheet.getRange(1, 1, 1, columns.length).setFontWeight('bold');
    dataSheet.getRange(1, 1, 1, columns.length).setBackground('#dbeafe');
    for (var c = 0; c < columns.length; c++) {
      dataSheet.setColumnWidth(c + 1, 150);
    }
  }

  // valuesのオブジェクトを配列に変換(項目の順序に合わせる)
  var rowValues = columns.map(function(col) { return values[col] || ''; });

  // データシートの最終行+1に行を追加
  var targetRow = dataSheet.getLastRow() + 1;
  dataSheet.getRange(targetRow, 1, 1, columns.length).setValues([rowValues]);

  return { success: true };
}

/**
 * 既存のデータ行を更新(修正)する
 * @param {number} rowId スプレッドシートの行番号
 * @param {Object} values 項目名→値 のオブジェクト
 * @return {{ success: boolean, error?: string }}
 */
function updateRow(rowId, values) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var colsSheet = ss.getSheetByName(COLUMNS_SHEET);
  var dataSheet = ss.getSheetByName(DATA_SHEET);

  if (!colsSheet || !dataSheet) {
    return { success: false, error: 'シートが見つかりません' };
  }

  var columns = getColumnsList(colsSheet);
  if (columns.length === 0) {
    return { success: false, error: '項目が定義されていません' };
  }

  // valuesのオブジェクトを配列に変換
  var rowValues = columns.map(function(col) { return values[col] || ''; });

  // 指定行を更新
  dataSheet.getRange(rowId, 1, 1, columns.length).setValues([rowValues]);

  return { success: true };
}

/**
 * データ行を削除する
 * @param {number} rowId スプレッドシートの行番号
 * @return {{ success: boolean, error?: string }}
 */
function deleteRow(rowId) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var dataSheet = ss.getSheetByName(DATA_SHEET);

  if (!dataSheet) return { success: false, error: 'データシートが見つかりません' };
  if (rowId < 2) return { success: false, error: '削除対象の行が不正です' };

  dataSheet.deleteRow(rowId);
  return { success: true };
}

/**
 * 項目定義シートから項目名一覧を取得する(ヘルパー関数)
 * @param {GoogleAppsScript.Spreadsheet.Sheet} colsSheet
 * @return {string[]}
 */
function getColumnsList(colsSheet) {
  var lastRow = colsSheet.getLastRow();
  if (lastRow < 2) return [];
  var data = colsSheet.getRange(2, 1, lastRow - 1, 1).getValues();
  var list = [];
  for (var i = 0; i < data.length; i++) {
    if (data[i][0]) list.push(String(data[i][0]));
  }
  return list;
}

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <base target="_top">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>データ管理アプリ</title>
  <style>
    /* ===== 全体リセット ===== */
    * { margin: 0; padding: 0; box-sizing: border-box; }

    :root {
      --primary: #2563eb;
      --primary-hover: #1d4ed8;
      --primary-light: #dbeafe;
      --success: #16a34a;
      --success-hover: #15803d;
      --error: #dc2626;
      --error-hover: #b91c1c;
      --neutral-50: #f9fafb;
      --neutral-100: #f3f4f6;
      --neutral-200: #e5e7eb;
      --neutral-300: #d1d5db;
      --neutral-400: #9ca3af;
      --neutral-500: #6b7280;
      --neutral-600: #4b5563;
      --neutral-700: #374151;
      --neutral-800: #1f2937;
      --radius: 10px;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans JP', sans-serif;
      background: var(--neutral-50);
      color: var(--neutral-800);
      line-height: 1.6;
      padding: 16px;
    }

    /* ===== ヘッダー ===== */
    .app-header {
      background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
      color: white;
      padding: 20px;
      border-radius: var(--radius);
      margin-bottom: 16px;
    }
    .app-header h1 { font-size: 1.3rem; font-weight: 700; }
    .app-header p { font-size: 0.8rem; opacity: 0.85; margin-top: 2px; }

    /* ===== タブ ===== */
    .tabs {
      display: flex;
      gap: 4px;
      margin-bottom: 16px;
      border-bottom: 2px solid var(--neutral-200);
    }
    .tab {
      padding: 8px 16px;
      border: none;
      background: none;
      cursor: pointer;
      font-size: 0.875rem;
      font-weight: 500;
      color: var(--neutral-500);
      border-bottom: 3px solid transparent;
      margin-bottom: -2px;
      font-family: inherit;
    }
    .tab:hover { color: var(--neutral-700); }
    .tab.active { color: var(--primary); border-bottom-color: var(--primary); }

    /* ===== カード ===== */
    .card {
      background: white;
      border: 1px solid var(--neutral-200);
      border-radius: var(--radius);
      padding: 16px;
      margin-bottom: 16px;
    }
    .card-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin-bottom: 12px;
    }
    .card-header h2 { font-size: 1rem; font-weight: 600; }
    .badge {
      font-size: 0.72rem;
      padding: 3px 8px;
      border-radius: 999px;
      background: var(--primary-light);
      color: var(--primary);
      font-weight: 500;
    }

    /* ===== ボタン ===== */
    .btn {
      display: inline-flex;
      align-items: center;
      gap: 4px;
      padding: 7px 14px;
      border: none;
      border-radius: 7px;
      font-size: 0.82rem;
      font-weight: 500;
      cursor: pointer;
      transition: background 0.15s;
      font-family: inherit;
    }
    .btn-primary { background: var(--primary); color: white; }
    .btn-primary:hover { background: var(--primary-hover); }
    .btn-success { background: var(--success); color: white; }
    .btn-success:hover { background: var(--success-hover); }
    .btn-danger { background: var(--error); color: white; }
    .btn-danger:hover { background: var(--error-hover); }
    .btn-secondary {
      background: var(--neutral-100);
      color: var(--neutral-700);
      border: 1px solid var(--neutral-300);
    }
    .btn-secondary:hover { background: var(--neutral-200); }
    .btn-sm { padding: 4px 9px; font-size: 0.76rem; }
    .btn:disabled { opacity: 0.5; cursor: not-allowed; }

    /* ===== フォーム ===== */
    .form-row {
      display: flex;
      gap: 10px;
      align-items: flex-end;
      flex-wrap: wrap;
    }
    .form-group {
      display: flex;
      flex-direction: column;
      gap: 3px;
      flex: 1;
      min-width: 150px;
    }
    .form-group label {
      font-size: 0.78rem;
      font-weight: 500;
      color: var(--neutral-600);
    }
    .form-group input {
      padding: 7px 11px;
      border: 1px solid var(--neutral-300);
      border-radius: 7px;
      font-size: 0.82rem;
      font-family: inherit;
      background: white;
      color: var(--neutral-800);
    }
    .form-group input:focus {
      outline: none;
      border-color: var(--primary);
      box-shadow: 0 0 0 3px var(--primary-light);
    }

    /* ===== チップ ===== */
    .column-chips {
      display: flex;
      flex-wrap: wrap;
      gap: 7px;
      margin-top: 12px;
    }
    .chip {
      display: inline-flex;
      align-items: center;
      gap: 5px;
      padding: 5px 11px;
      background: var(--primary-light);
      color: var(--primary);
      border-radius: 999px;
      font-size: 0.78rem;
      font-weight: 500;
    }
    .chip-delete {
      cursor: pointer;
      opacity: 0.6;
      font-size: 1rem;
      line-height: 1;
      border: none;
      background: none;
      padding: 0;
      color: inherit;
    }
    .chip-delete:hover { opacity: 1; }

    /* ===== テーブル ===== */
    .table-wrapper {
      overflow-x: auto;
      margin-top: 10px;
      border: 1px solid var(--neutral-200);
      border-radius: 8px;
    }
    .data-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 0.82rem;
    }
    .data-table th {
      background: var(--neutral-100);
      padding: 8px 10px;
      text-align: left;
      font-weight: 600;
      color: var(--neutral-600);
      white-space: nowrap;
      border-bottom: 2px solid var(--neutral-200);
    }
    .data-table th.action-col { text-align: center; width: 130px; }
    .data-table td {
      padding: 8px 10px;
      border-bottom: 1px solid var(--neutral-100);
      color: var(--neutral-700);
    }
    .data-table td.action-col { text-align: center; white-space: nowrap; }
    .data-table tbody tr:hover { background: var(--neutral-50); }
    .data-table tbody tr:last-child td { border-bottom: none; }

    /* ===== 空状態 ===== */
    .empty-state {
      text-align: center;
      padding: 28px 16px;
      color: var(--neutral-400);
    }
    .empty-state p { margin-bottom: 8px; }

    /* ===== モーダル ===== */
    .modal-overlay {
      position: fixed;
      top: 0; left: 0; right: 0; bottom: 0;
      background: rgba(0, 0, 0, 0.45);
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 1000;
    }
    .modal-content {
      background: white;
      border-radius: var(--radius);
      padding: 20px;
      width: 90%;
      max-width: 480px;
      max-height: 80vh;
      overflow-y: auto;
    }
    .modal-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin-bottom: 16px;
    }
    .modal-header h3 { font-size: 1.1rem; font-weight: 600; }
    .modal-close {
      background: none;
      border: none;
      font-size: 1.4rem;
      cursor: pointer;
      color: var(--neutral-400);
      line-height: 1;
    }
    .modal-close:hover { color: var(--neutral-600); }
    .modal-body { display: flex; flex-direction: column; gap: 12px; }
    .modal-footer {
      display: flex;
      justify-content: flex-end;
      gap: 8px;
      margin-top: 16px;
    }

    /* ===== 通知 ===== */
    .notice {
      padding: 8px 12px;
      border-radius: 7px;
      font-size: 0.8rem;
      margin-bottom: 10px;
    }
    .notice-success { background: #d1fae5; color: #065f46; }
    .notice-error { background: #fee2e2; color: #991b1b; }

    /* ===== ローディング ===== */
    .loading {
      text-align: center;
      padding: 40px;
      color: var(--neutral-400);
    }
  </style>
</head>
<body>

  <!-- ヘッダー -->
  <div class="app-header">
    <h1>データ管理アプリ</h1>
    <p>項目(列)を自由に定義して、データの新規追加・修正ができます</p>
  </div>

  <!-- 通知エリア -->
  <div id="notice-area"></div>

  <!-- タブ -->
  <div class="tabs">
    <button class="tab active" id="tab-data" onclick="switchTab('data')">データ管理</button>
    <button class="tab" id="tab-columns" onclick="switchTab('columns')">項目設定</button>
  </div>

  <!-- ローディング表示 -->
  <div id="loading" class="loading">読み込み中...</div>

  <!-- メインコンテンツ(ロード後に表示) -->
  <div id="main-content" style="display:none;">

    <!-- ===== タブ1: データ管理 ===== -->
    <div id="tab-content-data">
      <!-- 新規追加フォーム -->
      <div class="card">
        <div class="card-header">
          <h2>新規追加</h2>
          <span class="badge" id="data-col-count"></span>
        </div>
        <div id="add-form-area"></div>
      </div>

      <!-- データ一覧 -->
      <div class="card">
        <div class="card-header">
          <h2>データ一覧</h2>
          <span class="badge" id="data-row-count"></span>
        </div>
        <div id="table-area"></div>
      </div>
    </div>

    <!-- ===== タブ2: 項目設定 ===== -->
    <div id="tab-content-columns" style="display:none;">
      <div class="card">
        <div class="card-header">
          <h2>項目設定</h2>
          <span class="badge" id="col-count"></span>
        </div>

        <!-- 項目追加フォーム -->
        <div class="form-row">
          <div class="form-group">
            <label>新しい項目名</label>
            <input type="text" id="new-col-name" placeholder="例: ID、アドレス、パスワード など"
              onkeydown="if(event.key==='Enter') addColumn()">
          </div>
          <button class="btn btn-primary" onclick="addColumn()">項目を追加</button>
        </div>

        <!-- チップ表示 -->
        <div id="chips-area"></div>
      </div>
    </div>

  </div>

  <!-- 編集モーダル -->
  <div id="edit-modal" style="display:none;">
    <div class="modal-overlay" onclick="closeEditModal()">
      <div class="modal-content" onclick="event.stopPropagation()">
        <div class="modal-header">
          <h3>データを修正</h3>
          <button class="modal-close" onclick="closeEditModal()">×</button>
        </div>
        <div class="modal-body" id="edit-form-area"></div>
        <div class="modal-footer">
          <button class="btn btn-secondary" onclick="closeEditModal()">キャンセル</button>
          <button class="btn btn-primary" onclick="saveEditRow()">保存する</button>
        </div>
      </div>
    </div>
  </div>

  <script>
    // ===== アプリ全体で使うデータ =====
    var columns = [];   // 項目名の配列
    var rows = [];      // データ行の配列
    var editingRowId = null;  // 編集中の行番号

    // ===== 通知を表示(3秒後に消える) =====
    function showNotice(type, message) {
      var area = document.getElementById('notice-area');
      area.innerHTML = '<div class="notice notice-' + type + '">' + escapeHtml(message) + '</div>';
      setTimeout(function() { area.innerHTML = ''; }, 3000);
    }

    // ===== HTMLエスケープ(XSS対策) =====
    function escapeHtml(str) {
      if (!str) return '';
      return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    }

    // ===== タブ切り替え =====
    function switchTab(tabName) {
      var tabData = document.getElementById('tab-data');
      var tabCols = document.getElementById('tab-columns');
      var contentData = document.getElementById('tab-content-data');
      var contentCols = document.getElementById('tab-content-columns');

      if (tabName === 'data') {
        tabData.className = 'tab active';
        tabCols.className = 'tab';
        contentData.style.display = '';
        contentCols.style.display = 'none';
      } else {
        tabData.className = 'tab';
        tabCols.className = 'tab active';
        contentData.style.display = 'none';
        contentCols.style.display = '';
      }
    }

    // ===== 初期データを読み込む =====
    function loadInitialData() {
      google.script.run
        .withSuccessHandler(function(result) {
          columns = result.columns || [];
          rows = result.rows || [];
          document.getElementById('loading').style.display = 'none';
          document.getElementById('main-content').style.display = '';
          renderAll();
        })
        .withFailureHandler(function(err) {
          document.getElementById('loading').innerHTML = 'データの読み込みに失敗しました: ' + escapeHtml(err.message);
        })
        .getInitialData();
    }

    // ===== 画面をすべて描画し直す =====
    function renderAll() {
      renderAddForm();
      renderTable();
      renderChips();
      updateBadges();
    }

    // ===== バッジ(件数表示)を更新 =====
    function updateBadges() {
      document.getElementById('data-col-count').textContent = columns.length + '項目';
      document.getElementById('data-row-count').textContent = rows.length + '件';
      document.getElementById('col-count').textContent = columns.length + '項目';
    }

    // ===== 新規追加フォームを描画 =====
    function renderAddForm() {
      var area = document.getElementById('add-form-area');
      if (columns.length === 0) {
        area.innerHTML = '<div class="empty-state">' +
          '<p>まず「項目設定」タブで項目(列)を追加してください。</p>' +
          '<button class="btn btn-primary" onclick="switchTab(\'columns\')">項目設定へ移動</button>' +
          '</div>';
        return;
      }

      var html = '<div class="form-row">';
      for (var i = 0; i < columns.length; i++) {
        html += '<div class="form-group">' +
          '<label>' + escapeHtml(columns[i]) + '</label>' +
          '<input type="text" id="new-' + i + '" placeholder="' + escapeHtml(columns[i]) + 'を入力">' +
          '</div>';
      }
      html += '</div>';
      html += '<div style="margin-top:12px;"><button class="btn btn-success" onclick="addRow()">追加する</button></div>';
      area.innerHTML = html;
    }

    // ===== データ一覧テーブルを描画 =====
    function renderTable() {
      var area = document.getElementById('table-area');
      if (columns.length === 0) {
        area.innerHTML = '<div class="empty-state"><p>項目が定義されていません。</p></div>';
        return;
      }
      if (rows.length === 0) {
        area.innerHTML = '<div class="empty-state"><p>データがまだありません。上のフォームから追加してください。</p></div>';
        return;
      }

      var html = '<div class="table-wrapper"><table class="data-table"><thead><tr>';
      for (var c = 0; c < columns.length; c++) {
        html += '<th>' + escapeHtml(columns[c]) + '</th>';
      }
      html += '<th class="action-col">操作</th></tr></thead><tbody>';

      for (var r = 0; r < rows.length; r++) {
        html += '<tr>';
        var values = rows[r].values || [];
        for (var c = 0; c < columns.length; c++) {
          var val = values[c] !== undefined ? values[c] : '';
          html += '<td>' + escapeHtml(val) + '</td>';
        }
        html += '<td class="action-col">' +
          '<button class="btn btn-secondary btn-sm" style="margin-right:5px;" onclick="openEditModal(' + r + ')">修正</button>' +
          '<button class="btn btn-danger btn-sm" onclick="deleteRow(' + r + ')">削除</button>' +
          '</td></tr>';
      }
      html += '</tbody></table></div>';
      area.innerHTML = html;
    }

    // ===== 項目チップを描画 =====
    function renderChips() {
      var area = document.getElementById('chips-area');
      if (columns.length === 0) {
        area.innerHTML = '<div class="empty-state" style="margin-top:14px;">' +
          '<p>項目がまだありません。上のフォームから追加してください。</p>' +
          '<p style="font-size:0.78rem;">追加した項目が「データ管理」タブのフォームに反映されます。</p>' +
          '</div>';
        return;
      }

      var html = '<div class="column-chips">';
      for (var i = 0; i < columns.length; i++) {
        html += '<span class="chip">' + escapeHtml(columns[i]) +
          '<button class="chip-delete" onclick="deleteColumn(' + i + ')" title="削除">×</button></span>';
      }
      html += '</div>';
      html += '<div style="margin-top:16px;padding:10px 12px;background:var(--neutral-50);border-radius:7px;font-size:0.78rem;color:var(--neutral-500);">' +
        'ヒント: 項目を追加すると、自動的に「データ管理」タブのフォームに入力欄が追加されます。</div>';
      area.innerHTML = html;
    }

    // ===== 項目を追加する =====
    function addColumn() {
      var input = document.getElementById('new-col-name');
      var name = input.value.trim();
      if (!name) {
        showNotice('error', '項目名を入力してください');
        return;
      }

      google.script.run
        .withSuccessHandler(function(result) {
          if (result.success) {
            columns = result.columns || [];
            input.value = '';
            renderAll();
            showNotice('success', '項目「' + name + '」を追加しました');
          } else {
            showNotice('error', result.error || '項目の追加に失敗しました');
          }
        })
        .withFailureHandler(function(err) {
          showNotice('error', 'エラー: ' + err.message);
        })
        .addColumn(name);
    }

    // ===== 項目を削除する =====
    function deleteColumn(index) {
      var name = columns[index];
      if (!confirm('項目「' + name + '」を削除しますか?\nデータシートの該当列も削除されます。')) return;

      google.script.run
        .withSuccessHandler(function(result) {
          if (result.success) {
            columns = result.columns || [];
            renderAll();
            showNotice('success', '項目「' + name + '」を削除しました');
          } else {
            showNotice('error', result.error || '項目の削除に失敗しました');
          }
        })
        .withFailureHandler(function(err) {
          showNotice('error', 'エラー: ' + err.message);
        })
        .deleteColumn(name);
    }

    // ===== 新規データ行を追加する =====
    function addRow() {
      if (columns.length === 0) {
        showNotice('error', '項目が定義されていません');
        return;
      }

      var values = {};
      for (var i = 0; i < columns.length; i++) {
        var input = document.getElementById('new-' + i);
        values[columns[i]] = input ? input.value.trim() : '';
      }

      google.script.run
        .withSuccessHandler(function(result) {
          if (result.success) {
            // フォームをクリア
            for (var i = 0; i < columns.length; i++) {
              var input = document.getElementById('new-' + i);
              if (input) input.value = '';
            }
            // データを再読み込み
            reloadData();
            showNotice('success', 'データを追加しました');
          } else {
            showNotice('error', result.error || 'データの追加に失敗しました');
          }
        })
        .withFailureHandler(function(err) {
          showNotice('error', 'エラー: ' + err.message);
        })
        .addRow(values);
    }

    // ===== 編集モーダルを開く =====
    function openEditModal(rowIndex) {
      var row = rows[rowIndex];
      if (!row) return;
      editingRowId = row.id;

      var area = document.getElementById('edit-form-area');
      var html = '';
      for (var i = 0; i < columns.length; i++) {
        var val = (row.values && row.values[i] !== undefined) ? row.values[i] : '';
        html += '<div class="form-group">' +
          '<label>' + escapeHtml(columns[i]) + '</label>' +
          '<input type="text" id="edit-' + i + '" value="' + escapeHtml(val) + '">' +
          '</div>';
      }
      area.innerHTML = html;
      document.getElementById('edit-modal').style.display = '';
    }

    // ===== 編集モーダルを閉じる =====
    function closeEditModal() {
      document.getElementById('edit-modal').style.display = 'none';
      editingRowId = null;
    }

    // ===== 編集したデータを保存する =====
    function saveEditRow() {
      if (!editingRowId) return;

      var values = {};
      for (var i = 0; i < columns.length; i++) {
        var input = document.getElementById('edit-' + i);
        values[columns[i]] = input ? input.value.trim() : '';
      }

      google.script.run
        .withSuccessHandler(function(result) {
          if (result.success) {
            closeEditModal();
            reloadData();
            showNotice('success', 'データを更新しました');
          } else {
            showNotice('error', result.error || 'データの更新に失敗しました');
          }
        })
        .withFailureHandler(function(err) {
          showNotice('error', 'エラー: ' + err.message);
        })
        .updateRow(editingRowId, values);
    }

    // ===== データ行を削除する =====
    function deleteRow(rowIndex) {
      var row = rows[rowIndex];
      if (!row) return;
      if (!confirm('このデータを削除しますか?')) return;

      google.script.run
        .withSuccessHandler(function(result) {
          if (result.success) {
            reloadData();
            showNotice('success', 'データを削除しました');
          } else {
            showNotice('error', result.error || 'データの削除に失敗しました');
          }
        })
        .withFailureHandler(function(err) {
          showNotice('error', 'エラー: ' + err.message);
        })
        .deleteRow(row.id);
    }

    // ===== データだけを再読み込み(項目はそのまま) =====
    function reloadData() {
      google.script.run
        .withSuccessHandler(function(result) {
          columns = result.columns || [];
          rows = result.rows || [];
          renderAll();
        })
        .withFailureHandler(function(err) {
          showNotice('error', 'データの再読み込みに失敗しました: ' + err.message);
        })
        .getInitialData();
    }

    // ===== 画面表示時にデータを読み込む =====
    loadInitialData();
  </script>
</body>
</html>

appsscript.json

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "sheets": {
    "macros": []
  }
}

今回のように『AIに明確な前提条件を渡すこと』と『細かな修正をいとわずに繰り返すこと』で、短時間で実用的なツールが作れます。

YouTube動画

コメント

タイトルとURLをコピーしました