Garoon のポートレットに kintone の一覧を表示する

経緯

ことの発端は自分が過去に書いた "Garoonでkintoneのデータを表形式で表示してみよう! | キントマニア | kintone活用ブログ" なのですが、最近でも時折掘り返されてるようだったので、ちょっとだけモダンに書き換えました。

"Garoonにkintoneアプリを表示する方法を比較しました(HTML vs iframe)" という記事にもサンプルコードあるのですが、これも 2018 年製で jQuery が使われていて、これも先の記事と五十歩百歩のようでした 😅 また、この記事では iframe を用いた kintone の一覧を表示する方法も紹介されてますが、ヘッダ等を除いて一覧だけ表示させたいという方もいらっしゃることでしょうし。やっていきましょう。

アプローチ

自分は普段は React/TypeScript 使いなのですが、今回はビルドなしでとりあえずすぐ試せるように、Lodash の HTML テンプレートエンジン機能を使いつつ、JavaScript の書き方だけモダンにした感じです。 あとは、kintone-rest-api-client も当時はなかったので、kintone の API 操作は今回これに任せました。

コーディング

出来上がりイメージ

カレンダーの下に見えている kintone の一覧画面っぽい HTML ポートレットを今回やっていきます。

JavaScript (customize.js)

(async () => {
  const APP_ID = 547; // アプリ ID
  const ORDERBY_FIELD = '$id'; // ソートに使うフィールド
  const AVAILABLE_FIELD_CODES = [ // 表示するフィールドのフィールドコード
    '会社名',
    '部署名',
    '担当者名',
    '住所'
  ];

  const client = new KintoneRestAPIClient({});
  
  // レコードとフィールド情報を取得して、表示用データを作成
  const buildData = async () => {
    const orderBy = `${ORDERBY_FIELD} desc`;
    // kintone の API 操作は rest client を利用 https://github.com/kintone/js-sdk/tree/master/packages/rest-api-client#readme
    const [records, {properties}] = await Promise.all([
      client.record.getAllRecordsWithOffset({ app: APP_ID, orderBy }),
      client.app.getFormFields({ app: APP_ID })
    ]);
    // console.log(records, properties);

    // table のタイトルを作成
    const titles = AVAILABLE_FIELD_CODES
      .map(code => {
        // フィールド情報を使って、フィールドのコードからラベルを抽出
        const prop = Object.entries(properties).find(([_code]) => code === _code);
        if (prop) {
          const [_, {label}] = prop;
          return label;
        }
        return undefined;
      })
      .filter(label => label !== undefined);
    // table のセルデータを作成
    const rows = records.map(record => {
      // 行(レコード)毎に対象フィールドの値を抽出
      const row = AVAILABLE_FIELD_CODES
        .map(code => {
          const field = Object.entries(record).find(([_code]) => code === _code);
          if (field) {
            const [_, {type, value}] = field;
            let newValue;
            // フィールドタイプによって値の表示方法を切り替えるようにする https://cybozu.dev/ja/kintone/docs/overview/field-types/
            // 対応するフィールドタイプを増やすためにはここを調整
            switch (type) {
              default:
                newValue = value;
            };
            return newValue;
          }
          return undefined;
        })
        .filter(value => value !== undefined);
      return {row, id: record.$id.value};
    });
    // console.log(titles);
    // console.log(rows);
    return {titles, rows};
  };

  // Lodash のテンプレートエンジン機能を使って HTML を作成
  const createTableHTML = ({titles, rows}) => {
    // https://lodash.com/docs/4.17.15#template
    const templateString = `
    <table class="kintone-table">
      <thead class="kintone-table-thead">
        <tr>
          <th class="kintone-table-th"><div></div></th>
          <% titles.forEach((title) => { %>
            <th class="kintone-table-th"><div><%- title %></div></th>
          <% }); %>
        </tr>
      </thead>
      <tbody class="kintone-table-tbody">
        <% rows.forEach(({row, id}) => { %>
          <tr class="kintone-table-tr">
            <th class="kintone-table-th">
              <a href="${`/k/${APP_ID}/show#record=<%= id %>`}">
                <span class="kintone-table-record-detail-icon"></span>
              </a>
            </th>
            <% row.forEach((value) => { %>
              <td class="kintone-table-td">
                <div>
                  <%= value %>
                </div>
              </td>
            <% }); %>
          </tr>
        <% }); %>
      </tbody>
    </table>`;
    return _.template(templateString)({titles, rows});
  };

  // レンダリング
  const renderTable = async () => {
    const rootElement = document.querySelector('#root');
    const data = await buildData();
    console.log(data);
    rootElement.innerHTML = createTableHTML(data); // insert HTML to containerElement (div#root)
  };

  // レンダリングを実行
  await renderTable();

})();

CSS (customize.css)

.kintone-table {
  margin: 0;
  max-height: 500px;
  font-size: 14px;
  line-height: 1.5;
  border-spacing: 0;
  border-collapse: separate;
  border-bottom: 1px solid #e3e7e8;
  border-left: 1px solid #e3e7e8;
}

.kintone-table-thead {
  height: 54px;
}

.kintone-table-thead tr:first-of-type th {
  border-top: 1px solid #e3e7e8;
}

.kintone-table-tr {
  height: 54px;
}

.kintone-table-tr:nth-of-type(2n) {
  background-color: #f5f5f5;
}

.kintone-table-tr:nth-of-type(2n+1) td,
.kintone-table-tr:nth-of-type(2n+1) th {
  background-color: #f5f5f5;
}

.kintone-table-tr:nth-of-type(2n) td,
.kintone-table-tr:nth-of-type(2n) th {
  background-color: #fff;
}

.kintone-table-th {
  position: sticky;
  top: 0;
  left: 0;
  max-width: 652px;
  padding: 0 8px;
  font-weight: 400;
  letter-spacing: 0.1em;
  background: #fff;
  border-right: 1px solid #e3e7e8;
  border-bottom: 1px solid #e3e7e8;
}

.kintone-table-th div {
  position: relative;
  padding: 10px 5px 10px 0;
}

.kintone-table-tbody tr:nth-of-type(2n+1) th {
  border-left: 1px solid #e3e7e8;
}

.kintone-table-td {
  max-width: 652px;
  min-height: 50px;
  padding: 5px;
  background: #fff;
  border-right: 1px solid #e3e7e8;
  border-bottom: 1px solid #e3e7e8;
}

.kintone-table-td a {
  position: relative;
  padding: 9px 12px;
}

.kintone-table-tr:nth-child(odd) {
  background-color: #f5f5f5;
}

.kintone-table-record-detail-icon{
  display: inline-block;
  margin-top: 7px;
  width: 12px;
  height: 16px;
  background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAQCAYAAAAiYZ4HAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyNpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNS1jMDIxIDc5LjE1NDkxMSwgMjAxMy8xMC8yOS0xMTo0NzoxNiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUxRkEyQjBBMEJDOTExRTQ5MUVFOTIwNzZCRTVEQkEwIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjI1MzVEOTgyMEJERjExRTQ5MUVFOTIwNzZCRTVEQkEwIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NTFGQTJCMDgwQkM5MTFFNDkxRUU5MjA3NkJFNURCQTAiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NTFGQTJCMDkwQkM5MTFFNDkxRUU5MjA3NkJFNURCQTAiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5St7+KAAAAvUlEQVR42uySMQrCUBBEZ/9PExOxtFEv4QEk2HgHD+FxUoiVJ7DwANYewUovICFGoiS7bhBFJD+Y3oFthn3DwC6N48ORgB6+ZK0gTezSWOz9jmyYcat8o8sjPIG6YSJZgGT7CjI6GVwSZAZ01tSpgmvSdA9NssivZbGzdztgwUSE5k6AS4IfyixJ85W5hLGmaz71PXcb7csSDbtBxOC331ipggqWTyszaKk/8CsQtNgPPD3Lqe69HYdMHgIMACT+N6kpJL2vAAAAAElFTkSuQmCC) no-repeat center center;
}

適用方法

JavaScript 側は kintone-rest-api-client と Lodash の minimized ファイルを事前に読み込んで、今回の customize.js をセットします。CSS 側はテーブルのスタイリングをそれっぽく行うための customize.css を同じくセットします。

所感

  • HTML テンプレートと JSX 近いけど、慣れもあってか JSX の方が書きやすいw
  • 頑張りポイントはロジックよりスタイル調整

免責

紹介したコードの内容・利用に関する責任は一切負いません。 また、こちらは個人の意見で、所属する企業や団体は関係ありません。

おまけ

ChatGPT に今回の JavaScriptCSS を TypeScript ベースの React 1 ファイルに書き換えてもらったら、こうなりました。スタイルとかだいぶ削られた感じですが、粗方それっぽい感じで、悪くないかなぁと思います。

import { useState, useEffect } from 'react';
import { css } from '@emotion/react';
import { KintoneRestAPIClient } from '@kintone/rest-api-client';

const client = new KintoneRestAPIClient({});

type FieldCode = '会社名' | '部署名' | '担当者名' | '住所';

type RecordData = {
  id: number;
  row: (string | undefined)[];
};

type TableData = {
  titles: string[];
  rows: RecordData[];
};

const AVAILABLE_FIELD_CODES: FieldCode[] = ['会社名', '部署名', '担当者名', '住所'];

const Table = () => {
  const [tableData, setTableData] = useState<TableData>({ titles: [], rows: [] });

  useEffect(() => {
    const fetchData = async () => {
      const orderBy = '$id desc';
      const [records, { properties }] = await Promise.all([
        client.record.getAllRecordsWithOffset({ app: 547, orderBy }),
        client.app.getFormFields({ app: 547 }),
      ]);

      const titles = AVAILABLE_FIELD_CODES.map((code) => {
        const prop = Object.entries(properties).find(([fieldCode]) => fieldCode === code);
        return prop ? prop[1].label : undefined;
      }).filter(Boolean);

      const rows = records.map((record) => {
        const row = AVAILABLE_FIELD_CODES.map((code) => {
          const field = Object.entries(record).find(([fieldCode]) => fieldCode === code);
          return field ? field[1].value : undefined;
        }).filter(Boolean);

        return { id: record.$id.value, row };
      });

      setTableData({ titles, rows });
    };

    fetchData();
  }, []);

  return (
    <table css={tableStyle}>
      <thead>
        <tr>
          <th></th>
          {tableData.titles.map((title) => (
            <th key={title}>{title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {tableData.rows.map(({ id, row }) => (
          <tr key={id}>
            <td>
              <a href={`/k/547/show#record=${id}`}>
                <span css={recordDetailIconStyle} />
              </a>
            </td>
            {row.map((value, index) => (
              <td key={index}>{value}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
};

const tableStyle = css`
  margin: 0;
  max-height: 500px;
  font-size: 14px;
  line-height: 1.5;
  border-spacing: 0;
  border-collapse: separate;
  border-bottom: 1px solid #e3e7e8;
  border-left: 1px solid #e3e7e8;
`;

const recordDetailIconStyle = css`
  display: inline-block;
  width: 16px;
  height: 16px;
  margin: 0;
  padding: 0;
  background: url('/k/static/resources/records_show.png') no-repeat center;
  vertical-align: middle;
`;