1. はじめに
kintoneでは 「サブテーブルそのものを関連レコードで表示する」 ことはできません。
理由は簡単で、
- 関連レコード一覧は “1レコード=1行” の表
- サブテーブルは “1レコードの中の明細行集合”
という構造の違いがあるからです。
しかし、実務ではこんなニーズが本当に多いです。
「アプリBから関連レコードでアプリAを参照するとき、
アプリAのサブテーブルもまとめて一覧に表示したい」
「1行の中に“サブテーブル全体”を綺麗に埋め込みたい」
標準機能では不可能ですが、
“サブテーブルをHTMLテーブルに変換してリッチフィールド化しておく” という方法を使うと、
(無理やり)関連レコード一覧にサブテーブル“ごと”表示することが可能になります。
2. 実現イメージ
●登場するアプリ
- アプリA:撮影案件
- サブテーブル「撮影明細」あり
- アプリB:ダッシュボード/管理アプリ
- アプリAを関連レコードで参照
●やりたいこと
- アプリBの関連レコード一覧の1行ごとに、
アプリAのサブテーブル「撮影明細」全体を表形式で表示したい。
標準ではできません。
●今回の裏技で実現できること
-
アプリAの保存時に
サブテーブル → HTMLテーブルに変換 -
そのHTMLを
「サブテーブルサマリー」リッチフィールドに保存 -
アプリBの関連レコードの表示項目に
「サブテーブルサマリー」を追加 -
結果:
アプリBの関連レコード一覧の中に、サブテーブル全体がそのまま表示される!
3. 実装の全体フロー(2アプリ構成)
アプリA(サブテーブルあり)
↓ 保存時にサブテーブルをHTML化
フィールド「撮影明細サマリー」に格納
↓
アプリB[関連レコード]
アプリAを参照
表示項目に「撮影明細サマリー」を含める
↓
サブテーブルごと関連レコード一覧に表示される!
4. コード全文(サブテーブル → HTML変換)
※この記事の主役はアプリA側です。
※このコードで生成されたHTMLが「関連レコードで持ち出せるようになる」仕組みです。
コード全文
// Subtable_RichTextTranscription.js
(function () {
'use strict';
const CONFIG = {
SUBTABLE_CODE: '撮影明細',
TARGET_RICHTEXT_CODE: '撮影明細サマリー',
COLUMNS: [
{ label: '氏名', code: '氏名' },
{ label: '種別', code: 'SATUKB' },
{ label: '続柄', code: '続柄' },
{ label: '年齢', code: '年齢' },
],
// ← 文字列のみ:行内の「顧客レコード番号」で appId=6 をリンク
LINK_SPECS: [
{ textCode: '氏名', idCode: '顧客レコード番号', appId: 6 },
],
// ← 非表示にしたいフィールド(LINK_SPECSとは独立)
HIDE_FIELD_CODES: [
'KOKNO',
'顧客レコード番号',
'AGECALC_BIRTHD',
],
NUMERIC_COLS: new Set(['年齢', 'KOKCD']),
skipIf: (_row) => false,
};
const esc = (s) => String(s ?? '')
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
const toNum = (v) => {
const raw = (typeof v === 'object' && v && 'value' in v) ? v.value : v;
const n = Number(String(raw ?? '').replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
};
const recordUrl = (appId, recId) => `${location.origin}/k/${appId}/show#record=${recId}`;
const linkSpecByTextCode = Object.fromEntries(
(CONFIG.LINK_SPECS || []).map(s => [s.textCode, s])
);
function cellText(row, code) {
const f = row?.[code];
if (!f) return '';
const v = f.value;
if (Array.isArray(v)) return v.map(e => e?.name ?? e?.code ?? e).join(', ');
if (typeof v === 'number') return String(v);
return String(v ?? '');
}
function renderCellHtml(row, code) {
const text = cellText(row, code);
const spec = linkSpecByTextCode[code];
if (spec?.idCode) {
const recId = toNum(row?.[spec.idCode]?.value);
if (recId > 0) {
const href = recordUrl(spec.appId, recId);
return `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer">${esc(text || `#${recId}`)}</a>`;
}
}
return esc(text);
}
function buildHtmlTable(record) {
const st = record[CONFIG.SUBTABLE_CODE]?.value || [];
const rows = [];
for (const r of st) {
const row = r?.value || {};
if (CONFIG.skipIf(row)) continue;
const tds = CONFIG.COLUMNS.map(({ code }) => {
const valHtml = renderCellHtml(row, code);
const right = CONFIG.NUMERIC_COLS.has(code) ? 'text-align:right;' : '';
return `<td style="padding:6px 10px; ${right}">${valHtml}</td>`;
});
rows.push(`<tr>${tds.join('')}</tr>`);
}
if (!rows.length) return '';
const thead = `
<thead>
<tr>
${CONFIG.COLUMNS.map(({ label }) =>
`<th style="text-align:left; padding:8px 10px; border-bottom:1px solid #ddd;">${esc(label)}</th>`
).join('')}
</tr>
</thead>`.trim();
const tbody = `<tbody>${rows.join('')}</tbody>`;
return `
<div>
<table style="border-collapse:collapse; width:100%; max-width:100%;">
${thead}
${tbody}
</table>
</div>
`.trim();
}
// ===== 画面表示時:任意フィールドの非表示 & リッチテキスト編集不可 =====
kintone.events.on([
'app.record.create.show',
'app.record.edit.show',
'app.record.detail.show',
'mobile.app.record.create.show',
'mobile.app.record.edit.show',
'mobile.app.record.detail.show'
], (event) => {
const rec = event.record;
if (rec[CONFIG.TARGET_RICHTEXT_CODE]) {
rec[CONFIG.TARGET_RICHTEXT_CODE].disabled = true;
}
const toHide = Array.from(new Set(CONFIG.HIDE_FIELD_CODES || []));
toHide.forEach(code => {
try { kintone.app.record.setFieldShown(code, false); } catch {}
});
return event;
});
// ===== 保存時:サマリーHTMLを書き込み =====
kintone.events.on([
'app.record.create.submit',
'app.record.edit.submit',
'mobile.app.record.create.submit',
'mobile.app.record.edit.submit'
], (event) => {
const rec = event.record;
const html = buildHtmlTable(rec);
if (rec[CONFIG.TARGET_RICHTEXT_CODE]) {
rec[CONFIG.TARGET_RICHTEXT_CODE].value = html;
rec[CONFIG.TARGET_RICHTEXT_CODE].disabled = true;
}
return event;
});
})();
5. コードのポイントと役割
✔ ①表示したい列だけを自由に選べる
COLUMNS: [
{ label: '氏名', code: '氏名' },
{ label: '種別', code: 'SATUKB' },
]
→ 関連レコード側でもそのまま綺麗に出る。
✔ ②サブテーブルの行ごとにHTML <tr> を生成
→ 見栄えも安定し、PDF・モバイルでも強い。
✔ ③リンク付きセルも作れる
LINK_SPECS: [
{ textCode: '氏名', idCode: '顧客レコード番号', appId: 6 },
]
- 氏名 → 顧客詳細画面へジャンプ
- 関連レコード一覧の中でもリンクが生きる
= まるで「サブテーブルつき関連レコード」
✔ ④リッチテキストは編集不可&元フィールドは非表示
- 誤編集を防ぐ
- 詳細画面もスッキリ
6. 関連レコード側(アプリB)の設定
アプリB側ではほんの1ステップでOK:
関連レコードの「表示するフィールド」に
アプリAの「撮影明細サマリー」(HTMLフィールド)を追加するだけ。
すると……
関連レコード一覧の1行ごとにサブテーブル全体が表示されます。
これは実質的に:
「サブテーブルごと関連レコードに持ち出す」
ことに成功しています。
7. 応用例(さらに強化できます)
- サブテーブルの中でさらに色付けや強調表示を追加
- 金額だけ右寄せ
- アイコンを入れる
- 合計行を最下部に生成
- skipIf() で明細のフィルタリング
- PDF/帳票用のサマリーとしても利用可能
- Process管理のステータスに応じて表示内容切替
汎用性が高く、どの業務にも応用可能です。
8. 制作背景(実務でのニーズ)
実務の現場では:
- 管理アプリから明細までまとめて見たい
- ダッシュボードアプリでサブテーブルごと全件把握したい
- 組織横断の管理画面で「1行=1案件」にまとめたい
という要望が本当に多いです。
しかし標準では、
サブテーブルを直接関連レコードに持ち出すことはできません。
今回の方法は、その制約を
「サブテーブルをHTMLとして1フィールドに“圧縮”する」
ことで無理やり乗り越えるものです。
9. まとめ
- kintone標準では「サブテーブルごと関連レコード表示」は不可
- しかしサブテーブルをHTMLに変換し1つのフィールドにまとめることで、
関連レコード一覧にサブテーブル全体を持ち出せる - 実用性が高く、汎用性も高い
- 他アプリのダッシュボード設計で非常に役立つ
