1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【GAS】相互評価システムを作る (2) フロントエンド編:フレームワーク不要のSPA構築

1
Last updated at Posted at 2025-12-05

前回の記事では、スプレッドシートと連携するサーバーサイド(GAS)の処理を作成しました。 今回は、ユーザーが操作する画面(フロントエンド)となる index.html を解説します。

🎨 画面設計のコンセプト

GASのWebアプリは、ReactやVueを使うこともできますが、ビルド環境の構築(WebpackやVite)が手間になりがちです。 今回は 「コピペですぐ動く」 を重視し、Vanilla JS(素のJavaScript) だけで、以下の機能を持つモダンなUIを構築しました。

  • SPA (Single Page Application): ページ遷移せずに画面が切り替わる。
  • 動的フォーム生成: サーバーからの設定データに基づいて入力欄を自動生成。
  • 可視化: Google Charts を使って評価結果をグラフ表示。
  • 標準モーダル: タグを使った軽量なポップアップ。

💻 コード解説 (index.html)

1. 画面構成(HTML構造)

HTMLは大きく分けて4つのセクションで構成されており、CSSクラス is-hidden を付け替えることで画面遷移を表現しています。

<main>
  <section id="headerSection">...</section>
  <section id="targetSelectionSection">...</section>
  <section id="evaluationFormSection" class="is-hidden">...</section>
  <section id="resultsDisplaySection" class="is-hidden">...</section>
</main>

<dialog id="loadingModal">...</dialog>

2. Google Charts の読み込み

集計結果をグラフで表示するために、Google ChartsのライブラリをCDNから読み込んでいます。

<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<script>
  google.charts.load('current', { packages: ['corechart'] });
</script>

3. JavaScript:状態管理と画面遷移

appState というオブジェクトで、現在誰を評価しているかなどの状態を管理しています。 switchView 関数でセクションの表示/非表示を切り替えます。

// 状態管理
let appState = {
  userEmail: "",
  userGroup: "",
  currentTargetName: "", // 現在評価中の対象
  summaryDataCache: null
};
// 画面遷移ロジック(SPA)
function switchView(activeSectionId) {
  Object.entries(dom.sections).forEach(([id, el]) => {
    if (id === activeSectionId) { 
      el.classList.remove('is-hidden'); // 表示
    } else {
      el.classList.add('is-hidden');    // 非表示
    }
  });
  // 画面トップへスクロール
  document.querySelector('main').scrollTo(0, 0);
}

4. 動的フォーム生成 (renderEvaluationForm)

このアプリの最大の特長です。サーバーから送られてくる JSON データ(質問項目とタイプ)をループ処理し、HTML要素(ラジオボタンやテキストエリア)を動的に生成しています。

function renderEvaluationForm(json) {
  const data = JSON.parse(json);
  // ...
  (data.questionList || []).forEach((qText, idx) => {
    const type = data.questionTypes[idx]; // 'R' (Radio) or 'T' (Text)
    
    if (type === 'R') {
      // ラジオボタンの生成ロジック
    } else {
      // テキストエリアの生成ロジック
    }
  });
  // ...
}

これにより、HTMLを修正することなく、スプレッドシート側で質問を増減させるだけでフォームが反映されます。

5. Google Charts での描画

集計データを受け取り、積み上げ棒グラフを描画します。スマホでも見やすいようにレスポンシブ対応(リサイズイベント検知)も入れています。

function renderChart(summaryData) {
  // ...
  const options = {
    isStacked: 'percent', // 100%積み上げ
    colors: APP_CONSTANTS.CHART_COLORS, // Googleライクな色使い
    // ...
  };
  const chart = new google.visualization.BarChart(container);
  chart.draw(dataTable, options);
}

📝 ソースコード全文 (index.html)

このファイル1つに、HTML構造とJavaScriptのロジックが集約されています。 ファイル名は必ず index.html としてください。
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
  <script>
    google.charts.load('current', { packages: ['corechart'] });
  </script>

  <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?>
</head>

<body>
  <main>
    <section id="headerSection" class="header-block">
      <div class="header-inner">
        <h1 id="pageTitle">Loading...</h1>
        <div class="header-right">
          <div id="userInfoDisplay" class="user-info"></div>
          <div id="resultButtonContainer" class="result-link-wrapper">
            <button id="showResultsButton" class="link-style-button is-hidden">受けた評価を見る</button>
          </div>
        </div>
      </div>
    </section>

    <section id="targetSelectionSection" class="content-block">
      <h2>評価対象の選択</h2>
      <div id="targetListContainer" class="target-list-container">データ取得中...</div>
    </section>

    <section id="evaluationFormSection" class="content-block is-hidden">
      <h2 id="formTitle">評価の入力</h2>
      <div class="flex-space-between">
        <button id="backToTargetsButtonFromForm">一覧に戻る</button>
        <button id="submitEvaluationButton" class="primary-button">回答を送信</button>
      </div>
      <div id="formContainer" class="form-container"></div>
    </section>

    <section id="resultsDisplaySection" class="content-block is-hidden">
      <h2 id="resultsTitle">受けた評価</h2>
      <div class="action-bar">
        <button id="backToTargetsButtonFromResults">一覧に戻る</button>
      </div>
      
      <div class="result-section-divider">
        <h3>選択肢による評価</h3>
      </div>
      <div id="chartContainer" class="chart-wrapper">集計データなし</div>

      <div class="result-section-divider">
        <h3>文章による評価</h3>
      </div>
      <div id="commentsContainer" class="comments-container"></div>
    </section>
  </main>

  <dialog id="loadingModal" class="loading-dialog">
    <div class="loading-content">
      <div class="spinner"></div>
      <p id="loadingText">処理中...</p>
    </div>
  </dialog>

  <div id="common-dialog-overlay" class="dialog-overlay" style="display: none;">
    <div id="common-dialog-box" class="dialog-box">
      <div class="dialog-header">
        <span id="dialog-icon" class="dialog-icon"></span>
        <h3 id="dialog-title"></h3>
      </div>
      <div class="dialog-content">
        <p id="dialog-message"></p>
      </div>
      <div class="dialog-footer">
        <button onclick="closeDialog()" id="dialog-btn">OK</button>
      </div>
    </div>
  </div>

  <script>
    const APP_CONSTANTS = {
      INPUT_ID_PREFIX: "q_",
      CHART_COLORS: ['#4285F4', '#8AB4F8', '#F4B400', '#E0E0E0']
    };

    let appState = {
      userEmail: "",
      userName: "",
      userGroup: "",
      currentTargetName: "",
      summaryDataCache: null
    };

    let dom = {};

    window.addEventListener('load', initializeApp);

    function initializeApp() {
      cacheDomElements();
      setupEventListeners();
      fetchInitialData();
    }

    function cacheDomElements() {
      dom = {
        pageTitle: document.getElementById("pageTitle"),
        userInfoDisplay: document.getElementById("userInfoDisplay"),
        showResultsButton: document.getElementById("showResultsButton"),
        targetListContainer: document.getElementById("targetListContainer"),
        formTitle: document.getElementById("formTitle"),
        formContainer: document.getElementById("formContainer"),
        resultsTitle: document.getElementById("resultsTitle"),
        chartContainer: document.getElementById("chartContainer"),
        commentsContainer: document.getElementById("commentsContainer"),
        submitEvaluationButton: document.getElementById("submitEvaluationButton"),
        backButtonForm: document.getElementById("backToTargetsButtonFromForm"),
        backButtonResults: document.getElementById("backToTargetsButtonFromResults"),
        sections: {
          targetSelectionSection: document.getElementById('targetSelectionSection'),
          evaluationFormSection: document.getElementById('evaluationFormSection'),
          resultsDisplaySection: document.getElementById('resultsDisplaySection'),
        },
        loadingModal: {
          dialog: document.getElementById("loadingModal"),
          text: document.getElementById("loadingText")
        },
        // 統合ダイアログ用の要素キャッシュ
        commonDialog: {
          overlay: document.getElementById('common-dialog-overlay'),
          box: document.getElementById('common-dialog-box'),
          icon: document.getElementById('dialog-icon'),
          title: document.getElementById('dialog-title'),
          message: document.getElementById('dialog-message')
        }
      };
    }

    function setupEventListeners() {
      dom.targetListContainer.addEventListener('click', function(event) {
        const tr = event.target.closest('tr');
        if (tr && tr.classList.contains('clickable-row')) {
          const targetId = tr.dataset.target;
          if (targetId) {
             fetchEvaluationForm(targetId);
          }
        }
      });

      dom.submitEvaluationButton.addEventListener('click', submitEvaluation);
      dom.showResultsButton.addEventListener('click', fetchFeedbackResults);

      const goBack = () => fetchInitialData();
      dom.backButtonForm.addEventListener('click', goBack);
      dom.backButtonResults.addEventListener('click', goBack);

      let resizeTimer;
      window.addEventListener('resize', () => {
        clearTimeout(resizeTimer);
        resizeTimer = setTimeout(() => {
          if (!dom.sections.resultsDisplaySection.classList.contains('is-hidden')) {
            renderChart(appState.summaryDataCache);
          }
        }, 200);
      });
    }
    
    function switchView(activeSectionId) {
      Object.entries(dom.sections).forEach(([id, el]) => {
        if (id === activeSectionId) { 
          el.classList.remove('is-hidden');
        } else {
          el.classList.add('is-hidden'); 
        }
      });
      document.querySelector('main').scrollTo(0, 0);
    }

    function fetchInitialData(loadingText = "グループ一覧作成中...") {
      switchView('targetSelectionSection');
      toggleLoadingModal(true, loadingText);
      google.script.run
        .withSuccessHandler(renderTargetList)
        .withFailureHandler(handleAppError)
        .fetchUserAndGroupData();
    }

    function renderTargetList(json) {
      toggleLoadingModal(false);
      try {
        const data = JSON.parse(json);
        if (data.error) {
          dom.targetListContainer.innerHTML = `<p class="error-alert">${data.error}</p>`;
          return;
        }

        appState.userEmail = data.userEmail;
        appState.userName = data.userName;
        appState.userGroup = data.userGroup;
        
        dom.pageTitle.textContent = data.systemConfig?.pageTitle || "相互評価";
        let groupName = appState.userGroup ? appState.userGroup : "所属グループなし"
        dom.userInfoDisplay.textContent = `${groupName}${appState.userName}`;
        
        if (data.systemConfig?.isOutputEnabled === 'YES' && appState.userGroup) {
          dom.showResultsButton.classList.remove('is-hidden');
        } else {
          dom.showResultsButton.classList.add('is-hidden');
        }

        dom.targetListContainer.innerHTML = ''; 
        const table = document.createElement("table");
        table.innerHTML = `<thead><tr><th class="column-title">評価対象</th><th>入力日時</th></tr></thead>`;
        const tbody = document.createElement("tbody");

        (data.groupList || []).forEach(row => {
          const [targetName, timestamp] = row;
          const tr = document.createElement("tr");
          const tdName = document.createElement("td");
          
          if (targetName === appState.userGroup) {
            tdName.textContent = `${targetName} (自分)`;
            tr.classList.add("row-self");
          } else if (data.systemConfig?.isInputEnabled !== 'YES') {
             tdName.textContent = targetName;
          } else {
            tr.dataset.target = targetName;
            tr.classList.add("clickable-row");
            const span = document.createElement("span");
            span.textContent = targetName;
            span.className = "link-style-text";
            
            if (timestamp) {
               span.classList.add("status-done");
               tr.classList.add("status-done-row");
            }
            tdName.appendChild(span);
          }
          tr.appendChild(tdName);

          const tdTime = document.createElement("td");
          if (targetName === appState.userGroup) {
            tdTime.textContent = "---";
          } else {
            if (timestamp) {
              const d = new Date(timestamp);
              tdTime.textContent = d.toLocaleString('ja-JP', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
            } else {
              tdTime.textContent = "未入力";
              tdTime.classList.add("text-alert");
            }
          }
          tr.appendChild(tdTime);
          tbody.appendChild(tr);
        });
        table.appendChild(tbody);
        dom.targetListContainer.appendChild(table);
      } catch (e) {
        handleAppError(e);
      }
    }

    function fetchEvaluationForm(targetName) {
      toggleLoadingModal(true, `【${targetName}】の入力画面作成中...`);
      google.script.run
        .withSuccessHandler(renderEvaluationForm)
        .withFailureHandler(handleAppError)
        .fetchEvaluationForm(targetName);
    }

    function renderEvaluationForm(json) {
      toggleLoadingModal(false);
      switchView('evaluationFormSection');
      try {
        const data = JSON.parse(json);
        appState.currentTargetName = data.targetName;
        
        dom.formTitle.textContent = `【${data.targetName}】への評価`;
        dom.formContainer.innerHTML = '';

        const table = document.createElement("table");
        table.className = "form-table";
        const thead = document.createElement("thead");
        const trHead = document.createElement("tr");
        const thQ = document.createElement("th");
        thQ.textContent = "質問項目";
        thQ.className = "column-question";
        trHead.appendChild(thQ);
        
        (data.ratingOptions || []).forEach(option => {
          const th = document.createElement("th");
          const longItemSpan = document.createElement("span");
          longItemSpan.textContent = option;
          longItemSpan.className = "long-item";
          
          const shortItemSpan = document.createElement("span");
          shortItemSpan.className = "short-item";
          shortItemSpan.textContent = option.split(':')[0];
          
          th.append(longItemSpan, shortItemSpan);
          th.title = option;
          th.className = "column-option";
          trHead.appendChild(th);
        });
        thead.appendChild(trHead);
        table.appendChild(thead);

        const tbody = document.createElement("tbody");
        (data.questionList || []).forEach((qText, idx) => {
          const type = data.questionTypes[idx];
          const savedAns = data.answerList[idx];
          const tr = document.createElement("tr");
          const tdQ = document.createElement("td");
          tdQ.textContent = qText;
          tdQ.className = "td-question-text";
          tr.appendChild(tdQ);

          if (type === 'R') {
            (data.ratingOptions || []).forEach((option) => {
              const td = document.createElement("td");
              td.className = "cell-center td-radio";
              const label = document.createElement("label");
              label.className = "radio-label";
              const radio = document.createElement("input");
              radio.type = "radio";
              radio.name = `${APP_CONSTANTS.INPUT_ID_PREFIX}${idx}`;
              radio.value = option;
              if (savedAns === option) radio.checked = true;

              const span = document.createElement("span");
              span.textContent = option;
              span.className = "mobile-label";

              label.appendChild(radio);
              label.appendChild(span);
              td.appendChild(label);
              tr.appendChild(td);
            });
          } else {
            const td = document.createElement("td");
            td.colSpan = (data.ratingOptions || []).length;
            td.className = "td-textarea";
            const area = document.createElement("textarea");
            area.name = `${APP_CONSTANTS.INPUT_ID_PREFIX}${idx}`;
            area.value = savedAns || "";
            area.placeholder = "コメントを入力...";
            td.appendChild(area);
            tr.appendChild(td);
          }
          tbody.appendChild(tr);
        });
        table.appendChild(tbody);
        dom.formContainer.appendChild(table);
      } catch (e) {
        handleAppError(e);
      }
    }

    function submitEvaluation() {
      const inputs = [];
      const rows = dom.formContainer.querySelectorAll("tbody tr");
      let isComplete = true;

      for (let idx = 0; idx < rows.length; idx++) {
        const tr = rows[idx];
        const radios = tr.querySelectorAll(`input[name="${APP_CONSTANTS.INPUT_ID_PREFIX}${idx}"]`);
        
        if (radios.length > 0) {
          const checked = tr.querySelector(`input[name="${APP_CONSTANTS.INPUT_ID_PREFIX}${idx}"]:checked`);
          if (checked) {
            inputs.push(checked.value);
            tr.classList.remove("error-row");
          } else {
            inputs.push("");
            isComplete = false;
            tr.classList.add("error-row");
          }
        } else {
          const area = tr.querySelector(`textarea[name="${APP_CONSTANTS.INPUT_ID_PREFIX}${idx}"]`);
          inputs.push(area ? area.value : "");
          tr.classList.remove("error-row");
        }
      }

      if (!isComplete) {
        showDialog('error', "未回答の項目があります。\n赤くハイライトされた項目を確認してください。", "未回答あり");
        const firstError = dom.formContainer.querySelector(".error-row");
        if (firstError) {
          firstError.scrollIntoView({ behavior: "smooth", block: "center" });
        }
        return;
      }

      dom.submitEvaluationButton.disabled = true;
      dom.submitEvaluationButton.textContent = "送信中...";

      const payload = {
        targetName: appState.currentTargetName,
        answerList: inputs
      };

      toggleLoadingModal(true, "入力結果を送信中...");
      google.script.run
        .withSuccessHandler(() => {
          toggleLoadingModal(false);
          dom.submitEvaluationButton.disabled = false;
          dom.submitEvaluationButton.textContent = "回答を送信";
          // 完了通知をinfoで表示
          showDialog('info', "データ送信が完了しました。", "送信完了");
          // ダイアログを閉じた後に一覧に戻るため少し待機してもよいが、ここでは即時実行
          fetchInitialData("グループ一覧に戻ります");
        })
        .withFailureHandler((e) => {
          dom.submitEvaluationButton.disabled = false;
          dom.submitEvaluationButton.textContent = "回答を送信";
          handleAppError(e);
        })
        .saveEvaluationData(JSON.stringify(payload));
    }

    function fetchFeedbackResults() {
      toggleLoadingModal(true, `【${appState.userGroup}】の評価結果を取得中...`);
      google.script.run
        .withSuccessHandler(renderFeedbackResults)
        .withFailureHandler(handleAppError)
        .fetchFeedbackResults(appState.userGroup);
    }

    function renderFeedbackResults(json) {
      toggleLoadingModal(false);
      switchView('resultsDisplaySection');
      dom.resultsTitle.textContent = `【${appState.userGroup}】が受けた評価`;

      try {
        const data = JSON.parse(json);
        appState.summaryDataCache = data.summaryData;
        renderCommentList(data.commentList);
        google.charts.setOnLoadCallback(() => {
          renderChart(data.summaryData);
        });
      } catch (e) {
        handleAppError(e);
      }
    }

    function renderCommentList(commentList) {
      dom.commentsContainer.innerHTML = '';
      if (!commentList || commentList.length === 0) {
        dom.commentsContainer.innerHTML = '<p class="no-data-text">コメントはありません</p>';
        return;
      }
      const fragment = document.createDocumentFragment();
      commentList.forEach(group => {
        const questionText = group.shift();
        const wrapper = document.createElement("div");
        wrapper.className = "comment-wrapper";
        const h3 = document.createElement("h3");
        h3.textContent = questionText;
        wrapper.appendChild(h3);
        if (group.length > 0) {
          const ul = document.createElement("ul");
          group.forEach(comment => {
            if (comment) {
              const li = document.createElement("li");
              li.textContent = comment;
              ul.appendChild(li);
            }
          });
          wrapper.appendChild(ul);
        } else {
          const p = document.createElement("p");
          p.textContent = "(回答なし)";
          p.className = "no-answer-text";
          wrapper.appendChild(p);
        }
        fragment.appendChild(wrapper);
      });
      dom.commentsContainer.appendChild(fragment);
    }

    function renderChart(summaryData) {
      const container = dom.chartContainer;
      if (!summaryData || summaryData.length < 2) {
        container.innerHTML = '<p class="no-data-text">集計データがありません</p>';
        container.style.height = "auto";
        return;
      }
      const chartHeight = (summaryData.length - 1) * 60 + 50;
      container.style.height = chartHeight + "px";
      try {
        const dataTable = google.visualization.arrayToDataTable(summaryData);
        const options = {
          isStacked: 'percent',
          width: '100%',
          height: chartHeight,
          chartArea: { left: '35%', top: 30, width: '60%', height: chartHeight - 50 },
          legend: { position: 'top', maxLines: 2 },
          colors: APP_CONSTANTS.CHART_COLORS,
          hAxis: { minValue: 0, maxValue: 1, format: '#%' },
          vAxis: { textStyle: { fontSize: 12 } }
        };
        const chart = new google.visualization.BarChart(container);
        chart.draw(dataTable, options);
      } catch (e) {
        console.error("Chart draw error", e);
        container.textContent = "グラフ描画エラー";
      }
    }

    function toggleLoadingModal(show, loadingText = "通信中...お待ちください") {
      const dlg = dom.loadingModal.dialog;
      if (show) {
        dom.loadingModal.text.textContent = loadingText;
        if (dlg && !dlg.open) dlg.showModal();
      } else {
        if (dlg) dlg.close();
      }
    }

    function handleAppError(error) {
      toggleLoadingModal(false);
      showDialog('error', error.message || error);
      console.error(error);
    }

    /**
     * 共通ダイアログを表示する関数
     * @param {string} type - 'info', 'warning', 'error' のいずれか
     * @param {string} message - 表示するメッセージ
     * @param {string} [title] - (任意) タイトル
     */
    function showDialog(type, message, title) {
      const { overlay, box, icon, title: titleEl, message: msgEl } = dom.commonDialog;
      
      const config = {
        info:    { class: 'type-info',    icon: 'ℹ️', defaultTitle: 'お知らせ' },
        warning: { class: 'type-warning', icon: '⚠️', defaultTitle: '注意' },
        error:   { class: 'type-error',   icon: '', defaultTitle: 'エラー' },
        default: { class: 'type-info',    icon: '',   defaultTitle: 'メッセージ' }
      };

      const currentConfig = config[type] || config.default;

      // クラスのリセットと適用
      box.className = 'dialog-box'; 
      box.classList.add(currentConfig.class);

      // コンテンツセット
      icon.textContent = currentConfig.icon;
      titleEl.textContent = title || currentConfig.defaultTitle;
      msgEl.innerHTML = message.replace(/\n/g, '<br>'); // 改行コードをbrタグに変換

      // 表示
      overlay.style.display = 'flex';
    }

    function closeDialog() {
      document.getElementById('common-dialog-overlay').style.display = 'none';
    }
  </script>
</body>
</html>

💡 技術的なこだわり: タグ

ローディング表示やエラー表示には、モダンブラウザで標準サポートされた タグを使用しています。

function toggleLoadingModal(show, loadingText) {
  const dlg = dom.loadingModal.dialog;
  if (show) {
    dom.loadingModal.text.textContent = loadingText;
    if (!dlg.open) dlg.showModal(); // 最前面に表示&背景固定
  } else {
    dlg.close();
  }
}

これにより、BootstrapやjQuery UIなどの重いライブラリを読み込む必要がなくなり、動作が非常に軽量になっています。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?