カスタムビュー

にメンテナンス済み

kintone のカスタムビュー(カスタマイズビュー) は、レコード一覧画面の表示を HTML と JavaScript で完全にカスタマイズできる機能です。標準の一覧表示やカレンダービューでは実現できないレイアウトやインターフェースを構築できます。

この記事では、カスタムビューの基本的な設定方法から、実践的な UI パターンの実装までを解説します。

カスタムビューとは

通常の一覧ビューでは、kintone が自動生成するテーブル形式でレコードが表示されます。カスタムビューでは、HTML テンプレートを定義し、JavaScript でレコードデータを自由にレンダリングできます。

項目標準ビューカスタムビュー
レイアウトテーブル形式(固定)HTML / CSS で完全自由
JavaScript一覧のイベントで制御可能DOM をフルコントロール
ページングkintone 標準自前で実装
対応イベントapp.record.index.showapp.record.index.show
設定場所アプリの設定 → 一覧アプリの設定 → 一覧
チェック

カスタムビューでも app.record.index.show イベントは発火しますが、event.records にはカスタムビューの設定で指定した「ソート」と「フィルター」に基づくレコードが含まれます。

設定手順

  1. アプリの設定画面を開く
  2. 「一覧」タブを選択
  3. 「+」ボタンで新しいビューを作成
  4. 一覧の表示形式で「カスタマイズ」を選択
  5. HTML エリアにテンプレートを記述
  6. 必要に応じてフィルターとソートを設定
  7. 「保存」→「アプリを更新」

HTML テンプレートの例

カスタムビューの HTML エリアには、JavaScript からレコードを描画するためのコンテナ要素を配置します。

<div id="custom-view-container"></div>
チェック

HTML エリアには静的な HTML のみ記述し、動的なレンダリングは JavaScript ファイルで行うのがベストプラクティスです。スクリプトタグは HTML エリアに書かず、アプリの「JavaScript/CSS でカスタマイズ」でファイルを登録してください。

カード型レイアウトの実装

レコードをカード形式で表示する、最もよく使われるカスタムビューのパターンです。

custom-view-cards.js
(() => {
  '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, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  };
})();
XSS 対策

カスタムビューで innerHTML を使用する場合は、必ず HTML エスケープを行ってください。ユーザーが入力した値をそのまま innerHTML に渡すと、XSS(クロスサイトスクリプティング)の脆弱性になります。

集計ダッシュボードの実装

レコードを集計してステータス別の件数やグラフを表示するダッシュボードビューです。

custom-view-dashboard.js
(() => {
  '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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  };
})();
event.records とカスタムビュー

event.records にはカスタムビューのフィルターとソートに基づくレコードが含まれますが、ページ分のレコードのみです。集計など全レコードが必要な場合は、REST API で別途取得してください。

検索機能の追加

カスタムビューにインクリメンタルサーチ(リアルタイム検索)を追加する例です。

custom-view-search.js
(() => {
  '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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  };
})();

カスタムビュー開発の注意点

ビューの識別

複数のカスタムビューが存在する場合は、event.viewIdevent.viewName で表示中のビューを識別します。

view-identify.js
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 で識別して処理を分岐する

練習問題

カスタムビューの app.record.index.show イベントで、現在のビューがカスタムビューかどうかを判定するためのプロパティはどれですか?

カスタムビューで innerHTML を使ってレコードデータを表示する場合、セキュリティ上必ず行うべき処理はどれですか?
#kintone #JavaScript #TypeScript