前回の記事では、スプレッドシートと連携するサーバーサイド(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などの重いライブラリを読み込む必要がなくなり、動作が非常に軽量になっています。