カスタムビュー
kintone のカスタムビュー(カスタマイズビュー) は、レコード一覧画面の表示を HTML と JavaScript で完全にカスタマイズできる機能です。標準の一覧表示やカレンダービューでは実現できないレイアウトやインターフェースを構築できます。
この記事では、カスタムビューの基本的な設定方法から、実践的な UI パターンの実装までを解説します。
カスタムビューとは
通常の一覧ビューでは、kintone が自動生成するテーブル形式でレコードが表示されます。カスタムビューでは、HTML テンプレートを定義し、JavaScript でレコードデータを自由にレンダリングできます。
| 項目 | 標準ビュー | カスタムビュー |
|---|---|---|
| レイアウト | テーブル形式(固定) | HTML / CSS で完全自由 |
| JavaScript | 一覧のイベントで制御可能 | DOM をフルコントロール |
| ページング | kintone 標準 | 自前で実装 |
| 対応イベント | app.record.index.show | app.record.index.show |
| 設定場所 | アプリの設定 → 一覧 | アプリの設定 → 一覧 |
カスタムビューでも app.record.index.show イベントは発火しますが、event.records
にはカスタムビューの設定で指定した「ソート」と「フィルター」に基づくレコードが含まれます。
設定手順
- アプリの設定画面を開く
- 「一覧」タブを選択
- 「+」ボタンで新しいビューを作成
- 一覧の表示形式で「カスタマイズ」を選択
- HTML エリアにテンプレートを記述
- 必要に応じてフィルターとソートを設定
- 「保存」→「アプリを更新」
HTML テンプレートの例
カスタムビューの HTML エリアには、JavaScript からレコードを描画するためのコンテナ要素を配置します。
<div id="custom-view-container"></div>
HTML エリアには静的な HTML のみ記述し、動的なレンダリングは JavaScript ファイルで行うのがベストプラクティスです。スクリプトタグは HTML エリアに書かず、アプリの「JavaScript/CSS でカスタマイズ」でファイルを登録してください。
カード型レイアウトの実装
レコードをカード形式で表示する、最もよく使われるカスタムビューのパターンです。
(() => {
'use strict';
kintone.events.on(['app.record.index.show'], (event) => {
// カスタムビューでない場合は処理しない
if (event.viewType !== 'custom') return event;
const container = document.getElementById('custom-view-container');
if (!container) return event;
// コンテナをクリア
container.innerHTML = '';
// スタイルの設定
container.style.cssText = `
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
padding: 16px;
`;
const records = event.records;
if (records.length === 0) {
container.innerHTML = '<p style="text-align: center; color: #888;">レコードがありません</p>';
return event;
}
records.forEach((record) => {
const card = document.createElement('div');
card.style.cssText = `
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: box-shadow 0.2s;
`;
card.addEventListener('mouseenter', () => {
card.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.1)';
});
card.addEventListener('mouseleave', () => {
card.style.boxShadow = 'none';
});
// ステータスバッジの色を決定
const status = record['ステータス'].value;
const statusColors = {
'未着手': '#9e9e9e',
'進行中': '#2196f3',
'完了': '#4caf50',
'保留': '#ff9800',
};
const badgeColor = statusColors[status] || '#9e9e9e';
card.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-weight: bold; font-size: 16px;">${escapeHtml(record['案件名'].value)}</span>
<span style="background: ${badgeColor}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">${escapeHtml(status)}</span>
</div>
<div style="color: #666; font-size: 14px; margin-bottom: 4px;">
担当: ${escapeHtml(record['担当者'].value?.[0]?.name || '未設定')}
</div>
<div style="color: #666; font-size: 14px; margin-bottom: 4px;">
期限: ${record['期限'].value || '未設定'}
</div>
<div style="color: #666; font-size: 14px;">
金額: ${Number(record['金額'].value || 0).toLocaleString()} 円
</div>
`;
// カードクリックでレコード詳細に遷移
const recordId = record['$id'].value;
const appId = kintone.app.getId();
card.addEventListener('click', () => {
location.href = `/k/${appId}/show#record=${recordId}`;
});
container.appendChild(card);
});
return event;
});
/**
* HTML エスケープ処理
* @param { string } str - エスケープする文字列
* @returns { string }
*/
const escapeHtml = (str) => {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
})();
カスタムビューで innerHTML を使用する場合は、必ず HTML
エスケープを行ってください。ユーザーが入力した値をそのまま innerHTML
に渡すと、XSS(クロスサイトスクリプティング)の脆弱性になります。
集計ダッシュボードの実装
レコードを集計してステータス別の件数やグラフを表示するダッシュボードビューです。
(() => {
'use strict';
kintone.events.on(['app.record.index.show'], async (event) => {
if (event.viewType !== 'custom') return event;
const container = document.getElementById('custom-view-container');
if (!container) return event;
// 全レコードを取得(event.records はページ分のみ)
const allRecords = await getAllRecords({ app: kintone.app.getId() });
container.innerHTML = '';
container.style.cssText = 'padding: 24px;';
// ステータス別の集計
const statusCount = {};
let totalAmount = 0;
allRecords.forEach((record) => {
const status = record['ステータス'].value;
statusCount[status] = (statusCount[status] || 0) + 1;
totalAmount += Number(record['金額'].value) || 0;
});
// サマリーカード
const summaryHtml = `
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px;">
<div style="background: #e3f2fd; border-radius: 8px; padding: 16px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #1565c0;">${allRecords.length}</div>
<div style="color: #666;">総レコード数</div>
</div>
<div style="background: #e8f5e9; border-radius: 8px; padding: 16px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #2e7d32;">${totalAmount.toLocaleString()}</div>
<div style="color: #666;">合計金額(円)</div>
</div>
${Object.entries(statusCount)
.map(
([status, count]) => `
<div style="background: #fff3e0; border-radius: 8px; padding: 16px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #e65100;">${count}</div>
<div style="color: #666;">${escapeHtml(status)}</div>
</div>
`
)
.join('')}
</div>
`;
// ステータス別バーチャート(CSS のみ)
const maxCount = Math.max(...Object.values(statusCount));
const chartHtml = `
<h3 style="margin-bottom: 16px;">ステータス別件数</h3>
<div style="display: flex; flex-direction: column; gap: 8px;">
${Object.entries(statusCount)
.map(
([status, count]) => `
<div style="display: flex; align-items: center; gap: 12px;">
<span style="min-width: 80px; text-align: right; font-size: 14px;">${escapeHtml(status)}</span>
<div style="flex: 1; background: #e0e0e0; border-radius: 4px; height: 24px;">
<div style="width: ${(count / maxCount) * 100}%; background: #1976d2; border-radius: 4px; height: 100%; display: flex; align-items: center; padding-left: 8px;">
<span style="color: white; font-size: 12px; font-weight: bold;">${count}</span>
</div>
</div>
</div>
`
)
.join('')}
</div>
`;
container.innerHTML = summaryHtml + chartHtml;
return event;
});
/**
* 全レコードを取得する(ページネーション対応)
* @param { Object } params
* @param { number } params.app - アプリID
* @returns { Promise<Object[]> }
*/
const getAllRecords = async (params) => {
const { app } = params;
const allRecords = [];
let lastId = 0;
while (true) {
const query = `$id > ${lastId} order by $id asc limit 500`;
const response = await kintone.api(kintone.api.url('/k/v1/records.json', true), 'GET', {
app,
query,
});
allRecords.push(...response.records);
if (response.records.length < 500) break;
lastId = Number(response.records[response.records.length - 1]['$id'].value);
}
return allRecords;
};
const escapeHtml = (str) => {
if (!str) return '';
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
};
})();
event.records
にはカスタムビューのフィルターとソートに基づくレコードが含まれますが、ページ分のレコードのみです。集計など全レコードが必要な場合は、REST
API で別途取得してください。
検索機能の追加
カスタムビューにインクリメンタルサーチ(リアルタイム検索)を追加する例です。
(() => {
'use strict';
kintone.events.on(['app.record.index.show'], (event) => {
if (event.viewType !== 'custom') return event;
const container = document.getElementById('custom-view-container');
if (!container) return event;
container.innerHTML = '';
// 検索ボックス
const searchBox = document.createElement('input');
searchBox.type = 'text';
searchBox.placeholder = 'レコードを検索...';
searchBox.style.cssText = `
width: 100%;
max-width: 400px;
padding: 8px 16px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
margin-bottom: 16px;
box-sizing: border-box;
`;
// レコード一覧のコンテナ
const listContainer = document.createElement('div');
container.appendChild(searchBox);
container.appendChild(listContainer);
const records = event.records;
/**
* レコードをレンダリングする
* @param { Object[] } filteredRecords - 表示するレコード
*/
const renderRecords = (filteredRecords) => {
listContainer.innerHTML = '';
if (filteredRecords.length === 0) {
listContainer.innerHTML = '<p style="color: #888;">該当するレコードがありません</p>';
return;
}
const table = document.createElement('table');
table.style.cssText = 'width: 100%; border-collapse: collapse;';
const thead = document.createElement('thead');
thead.innerHTML = `
<tr>
<th style="border-bottom: 2px solid #ddd; padding: 8px; text-align: left;">案件名</th>
<th style="border-bottom: 2px solid #ddd; padding: 8px; text-align: left;">担当者</th>
<th style="border-bottom: 2px solid #ddd; padding: 8px; text-align: left;">ステータス</th>
<th style="border-bottom: 2px solid #ddd; padding: 8px; text-align: right;">金額</th>
</tr>
`;
table.appendChild(thead);
const tbody = document.createElement('tbody');
filteredRecords.forEach((record) => {
const row = document.createElement('tr');
row.style.cursor = 'pointer';
row.addEventListener('mouseenter', () => { row.style.background = '#f5f5f5'; });
row.addEventListener('mouseleave', () => { row.style.background = ''; });
row.addEventListener('click', () => {
location.href = `/k/${kintone.app.getId()}/show#record=${record['$id'].value}`;
});
row.innerHTML = `
<td style="border-bottom: 1px solid #eee; padding: 8px;">${escapeHtml(record['案件名'].value)}</td>
<td style="border-bottom: 1px solid #eee; padding: 8px;">${escapeHtml(record['担当者'].value?.[0]?.name || '')}</td>
<td style="border-bottom: 1px solid #eee; padding: 8px;">${escapeHtml(record['ステータス'].value)}</td>
<td style="border-bottom: 1px solid #eee; padding: 8px; text-align: right;">${Number(record['金額'].value || 0).toLocaleString()}</td>
`;
tbody.appendChild(row);
});
table.appendChild(tbody);
listContainer.appendChild(table);
};
// 初期表示
renderRecords(records);
// 検索時のフィルタリング
searchBox.addEventListener('input', () => {
const keyword = searchBox.value.toLowerCase();
const filtered = records.filter((record) => {
const name = (record['案件名'].value || '').toLowerCase();
const assignee = (record['担当者'].value?.[0]?.name || '').toLowerCase();
const status = (record['ステータス'].value || '').toLowerCase();
return name.includes(keyword) || assignee.includes(keyword) || status.includes(keyword);
});
renderRecords(filtered);
});
return event;
});
const escapeHtml = (str) => {
if (!str) return '';
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
};
})();
カスタムビュー開発の注意点
ビューの識別
複数のカスタムビューが存在する場合は、event.viewId や event.viewName で表示中のビューを識別します。
kintone.events.on(['app.record.index.show'], (event) => {
if (event.viewType !== 'custom') return event;
// ビュー名で処理を分岐
switch (event.viewName) {
case 'ダッシュボード':
renderDashboard(event);
break;
case 'カード一覧':
renderCards(event);
break;
default:
break;
}
return event;
});
パフォーマンスの考慮
- 大量の DOM 要素の生成を避け、仮想スクロールなどの手法を検討する
- 全レコードの取得が必要な場合は、ローディングインジケーターを表示する
event.recordsで十分な場合は REST API の追加呼び出しを避ける
カスタムビューの HTML エリアにはスクリプトタグを記述できますが、保守性の観点から JavaScript ファイルとして分離することを推奨します。HTML エリアにはコンテナ要素のみ記述してください。
まとめ
- カスタムビューを使うと、レコード一覧を HTML / CSS / JavaScript で完全にカスタマイズできる
app.record.index.showイベントでevent.viewType === 'custom'を確認してカスタムビューの処理を実行する- カード型レイアウト、ダッシュボード、検索機能付き一覧など、標準ビューでは実現できない UI を構築できる
innerHTMLを使用する場合は必ず HTML エスケープを行い、XSS を防止する- 複数のカスタムビューは
event.viewNameで識別して処理を分岐する