# scripting — Form.io 拡張点、button の自動注入変数、Chart.js、カスタムバリデータ

**Scope:** フォーム内 JS を書く全経路 — `customConditional` / `customDefaultValue` / `calculateValue` / `validate.custom` / `logic[]` の拡張点と、button (action:"custom") の `custom` JS スコープ、Chart.js v2 利用、`validate.custom` の multi-select の型バリエーション。REST 呼出のサンプル ($.ajax / restViewJson 等) は [server-data.md](/docs/server-data.md)、参照モード disabled の JS hack は [layout-styling.md](/docs/layout-styling.md) を参照。

## 1. JavaScript ロジックは `<script>` 直書きせず Form.io 拡張点を使う

`htmlelement` や `content` 型コンポーネントの `content` に `<script>...</script>` を書いても **Form.io のサニタイズで実行されない**。動的なロジックは Form.io 公式の拡張点を使う:

| 用途 | 使う拡張点 | 例 |
|---|---|---|
| 他フィールドから値を算出 | `calculateValue` | `value = (data.price \|\| 0) * (data.qty \|\| 0);` |
| 初期値を JS で決める | `customDefaultValue` | `value = new Date().toISOString().slice(0,10);` |
| 条件付き表示 | `customConditional` | `show = data.status === "draft";` |
| カスタム検証 | `validate.custom` | `valid = input > 0 ? true : "ゼロより大きい値を入力";` |
| イベント駆動の複雑な処理 | `logic[]` | クリックで他フィールド更新等 |
| Form.io 内部イベント (initialized / change / blur / focus / render / submit) の購読 | `logic[]` `trigger.type:"event"` + イベント名を手入力 | 対象はフォーム全体。入力系コンポーネントに載せる (§13) |

`logic[]` の例:
```json
"logic": [{
  "name": "onClickInit",
  "trigger": { "type": "event", "event": "myEvent" },
  "actions": [
    { "name": "setVal", "type": "value", "value": "value = 100;" }
  ]
}]
```

これらすべての JS 式で **cfAPI を呼ぶときは isNewNote ガードが必須** ([server-data.md](/docs/server-data.md) 参照)。

## 2. `button` (action:"custom") の JS スコープに自動注入される変数

Form.io の `button` 型コンポーネントで `action: "custom"` を設定し `custom` フィールドに JS を書くと、その JS スコープには以下の変数が **無宣言で利用可能** な状態で注入される。`var` 宣言不要。

| 変数 | 内容 | 典型用途 |
|---|---|---|
| `instance` | このボタンの Component インスタンス (Form.io 内部オブジェクト) | `instance.parent` で親 Component に遡る、`instance.element` で DOM ノード取得 |
| `component` | このボタンの component 定義オブジェクト (JSON そのもの) | `component.key` 等の静的属性参照 |
| `form` | ルートフォーム (Webform インスタンス) | **`form.getComponent(key)` で全フィールドにアクセス**、`form.data` で全データ参照 |
| `data` | ルートフォームの `data` オブジェクト | `data.<key>` で値読み (※フラット配置時のみ。container 配下は `data.<container>.<key>`) |
| `row` | datagrid / editgrid 行内の場合は **その行のデータ** | `row.<key>` で同一行の値読み |
| `event` | クリックイベント | `event.preventDefault()` 等 |

### よく使うパターン

```javascript
// パターン 1: 別フィールドの値を読む / 書く
var src = data.searchKey;                      // フラット配置
var src = form.getComponent('searchKey').dataValue;  // 同上を Component 経由で
form.getComponent('targetField').setValue('hello');

// パターン 2: datagrid 行内
var picked = row.codeSelect;                   // 同一行の Select の値
form.getComponent(['gridKey', rowIndex, 'name']).setValue('...');  // 特定行を path で

// パターン 3: 親 Component を遡る (modalEdit Panel 内のボタンから panel 自身を取得)
var panel = instance.parent;
while (panel && panel.type !== 'panel') panel = panel.parent;

// パターン 4: DOM 要素を取得
var dlg = instance.element ? instance.element.closest('.formio-dialog') : null;
```

### 注意点

- **`data.<key>` は配置依存** — トップレベル / panel / columns / fieldset 配下なら素直に `data.<key>` で読めるが、`container` + `tree:true` 配下なら `data.<container>.<key>` にネストされる ([layout-styling.md](/docs/layout-styling.md) 参照)。配置に依存したくないなら `form.getComponent(key).dataValue` を使う
- **`row` は editgrid/datagrid 内でのみ定義される** — それ以外の文脈で `row` を参照すると `data` と同じ値が入る場合と undefined になる場合があり、Form.io バージョンに依存する。**外側からは `data` を使う** のが安全
- **`async` 操作 (`$.ajax` 等) と組み合わせるときは `XSP.allowSubmit()` を関数頭で呼ぶ** ([server-data.md](/docs/server-data.md) 参照)
- **戻り値 (`return`) は無視される** — ボタンは「処理を実行する」だけのもので、`return` で値を返してもどこにも渡らない。値を残したいなら `form.getComponent(...).setValue(...)` で別フィールドに書く

## 3. Chart.js v2 が全ページに組込済

consentFlow は `Chart.min.js` (v2 系) + `chartjs-plugin-labels` + `chartjs-plugin-colorschemes` を自動ロードしているため、**フォーム内でそのまま `Chart` グローバル**が使える (CDN から別途読む不要)。

### チャート表示の定石

1. `htmlelement` で `<canvas id="myChart"></canvas>` を置く
2. 別の `calculateValue` または `logic` の action 内で `new Chart(...)` を初期化

```javascript
// 例: logic の action value 内で
var ctx = document.getElementById('myChart').getContext('2d');
new Chart(ctx, {
  type: 'bar',  // v2 系: 'bar'|'line'|'pie'|'doughnut'|'radar'|'polarArea'|'bubble'|'scatter'
  data: { labels: ['Q1','Q2','Q3','Q4'], datasets: [{ label:'売上', data:[10,20,15,30] }] },
  options: { responsive: true }
});
```

Chart.js **v2 系** の API を前提にすること (v3/v4 の breaking change は非対応)。

## 4. multi-select の `validate.custom` で input は string|array の両方が来る

Form.io の `select` (multiple:true) で `validate.custom` を書くとき、`input.length` を直接判定すると **選択値が 1 個** のケースで input が配列ではなく単一文字列で渡され、その文字列長を見て誤判定するケースがある。

例: value="p_kanri_all" (11文字) を 1 個だけ選んだ状態で `input.length > 5` を判定するとエラー発火。

**Why:** Form.io は internal lifecycle のタイミングや widget 種別 (choicesjs / native select 等) によって、`multiple:true` 設定下でも一時的に value を string で渡してくることがある。validate は configuration phase だけでなく redraw / change 時にも走るため再現する。

**How to apply:** `multi-select` / `tags` / `selectMultiple` 系の `validate.custom` コードでは、冒頭で必ず配列に正規化してから length を見る:

```js
var arr = Array.isArray(input) ? input : (input ? [input] : []);
valid = (arr.length > 5) ? 'メッセージ' : true;
```

`input && input.length > N` パターンは将来また再発するので避ける。

## 5. `calculateValue` + `allowCalculateOverride:true` はソース変更時に再計算を止める

`calculateValue` を持つフィールドに **`allowCalculateOverride:true`** を付けると、「ユーザーが手動で上書きした値は計算で潰さない」という意図の設定だが、**文書ロード時に submission から入った値も“上書き済み”とみなされ、依存ソースが変わっても再計算されなくなる**。

典型症状: Select で人物を選び詳細を隠しフィールドに `calculateValue` で展開する設計 (例: `UkeNameSelect` → `UkeName`/`UkeNameUnid`/`UkeNameSection`)。**新規作成時は計算が走って保存されるが、文書を開き直して選択を変更しても隠しフィールドが書き換わらない**。原因は `allowCalculateOverride:true`。

**Why:** Form.io は `allowCalculateOverride:true` のとき「現在値 ≠ 計算が生む値」を検知すると以後その計算をスキップする。新規時は現在値が空なので計算が走るが、ロード済み文書では現在値が確定済み扱いになり、ソース変更による再計算がブロックされる。

**How to apply:** 派生・連動させたい計算フィールドは **`allowCalculateOverride:false`** (既定値) にする。これでソース変更のたびに必ず再計算される。

- 再オープン時に「ソースがまだ空 → 計算が走って値が消える」のを防ぎたい場合は、`calculateValue` 側でガードする (代入しなければ `value` は現在値=ロード値のまま保持される):
  ```js
  // ✅ ソース(オブジェクト Select)が有効なときだけ代入。空ロード時は既存値を保持
  if (data.UkeNameSelect && typeof data.UkeNameSelect === 'object' && data.UkeNameSelect['@unid']) {
    value = data.UkeNameSelect.UserID || '';
  }
  ```
- この設計では `persistent:false` のオブジェクト Select を再オープン時に表示復元するために、Select 側 `customDefaultValue` で保存済みスカラーからオブジェクトを再構築する (`idPath:"@unid"` で照合) のと組み合わせる。既定のアプリ設定では `persistent:false` の Select 自体も Notes に保存されるため再構築は冗長だが、設定「persistent:false / Client-only の項目を保存しない」が はい の環境ではこの再構築が唯一の復元経路になる ([data-binding.md](/docs/data-binding.md) §1)。
- 既知のトレードオフ: 上記ガードは「Select を空クリア」では既存値を消さない (空ロードと区別できないため保持側に倒す)。クリアも反映したいなら明示クリアボタンを足す。

## 6. `logic[]` トリガーの空判定 `dataValue !== ""` は非文字列 `defaultValue` にすり抜かれる

Select 等のコンポーネントで `defaultValue` を **空オブジェクト `{}`** にすると、`logic[].trigger` の JS で「未選択なら発火しない」ガードとして書いた `instance.dataValue !== ""` が **常に true** になり、ロード時に何も選択していないのにアクションが発火する。そのアクションが `XSP.partialRefreshPost(mainPanel)` 等の**再描画を伴う**と、再描画 → `defaultValue` が再び `{}` → トリガー再発火、という**リロードループ**になる。

典型症状: `dataType:"string"` の Select に onChange 系 `logic` を付け、新規申請を開いた瞬間からページが延々と partial refresh を繰り返す。`String(instance.dataValue)` が `"[object Object]"` になるため、その文字列での値照合 (workflow 引き当て等) も常に失敗する。

実例: ある `app` Select が `"defaultValue":{}` で無限リロード。正常な姉妹フォームは同一ロジックで `"defaultValue":""` だったため発火せず。差分は `defaultValue` の 1 プロパティのみだった。

**Why:** `{} !== ""` はオブジェクトと文字列の厳密不等価なので **必ず true**、`{} != null` も true。`window.__lastValues` のような重複ガードを噛ませても、mainPanel の partial refresh がフォーム全体を作り直し `String({})` が毎回同じ `"[object Object]"` を返すため、状況によりループが止まらない。空文字列 `""` なら `"" !== ""` が false になりガードが正しく効く。

**How to apply:**
- `dataType:"string"` の Select / textfield 等スカラー値コンポーネントの `defaultValue` は必ず **`""`**（またはその型に応じた空値）にする。`{}` を入れない。Builder が値未設定時にまれに `{}` を書き込むことがあるので JSON を目視確認する。
- 一括チェック: JSON 内で `"defaultValue":{}` を検索する
- ガード自体を頑健化するなら型も見る: `var v = instance.dataValue; return (typeof v === "string" && v !== "" && cfGlobal.isNewNote && cfGlobal.editable);`。ただし**正常動作フォームと実装を揃えるのが最優先** — 動いている姉妹フォームが `""` で回避しているなら、まず `""` に合わせる最小差分を採る。

## 7. `logic[]` customAction で Select 選択肢を動的注入すると fieldLogic の巻き戻しで消える

`logic[].actions` の customAction から REST で取得した選択肢を `instance.component.data.values = options` と**非同期に注入するだけ**だと、初回表示は成功するのに、その後の任意の値変更 (checkData) やリロード後に**選択肢が空に戻る**。

**Why:** Form.io の `Component.fieldLogic()` は checkData のたびに `fastCloneDeep(this.originalComponent)` (= スキーマ原本のクローン) から再スタートし、トリガー true の logic の actions だけをクローンへ適用した後、`!isEqual(this.component, クローン)` なら **`this.component = クローン` で丸ごと置換 + redraw** する。非同期コールバックで `this.component` に注入した選択肢は原本クローンに存在しないため、次の fieldLogic パスで巻き戻されて消える。「一度実行したら早期 return」型の一発ガード (`instance._loaded = true` を fetch **前**に立てる等) と組み合わせると、巻き戻し後に再注入も再取得も走らず恒久的に空になる。

**How to apply (キャッシュ + 毎パス同期適用パターン):**
- トリガーは一発ガードにせず、キー値がある限り**常時 true** にする: `result = !!data.keiyaku_num;`
- customAction 内では eval コンテキストの **`component` 変数 (= fieldLogic が今パスで作った原本クローン)** に対し、キャッシュ済み選択肢を**同期的に**書き込む: `component.data.values = cache.options;`。これが毎パス適用されるので isEqual が安定し巻き戻らない
- REST 取得は初回のみ実行し `instance` 上にキャッシュ (`instance._optCache = {key, options}`)。in-flight ガード (`_loadingKey`) と失敗上限カウンタを併設し、`.done` でキャッシュ格納後に `instance.triggerRedraw()` → 次パスの同期適用で描画反映
- 「結果 0 件」は空配列をキャッシュして再取得ループを防ぐ (キー値が変われば cacheKey が変わり自然に再取得される)。通信エラーのみ再試行を許す
- 注意: この Form.io バンドルの customAction は評価結果の `value` 変数を `setValue()` に使う。customAction 内で **`value` という変数名を宣言・代入しない** (勝手に setValue される)
- 実例: 連動する「計画」select。旧実装は fetch 前 `_planLoaded=true` + 非同期注入のみで「初回だけ表示され、リロードや他フィールド入力で消える」症状だった

## 8. `validate.custom` は承認 (approve) 時も参照モードで再実行される — 時間/状態依存の検証には未申請ガード必須

承認者が文書を開くと Form.io は参照モードで描画されるが、**承認ボタンはフォーム全体の検証 (`checkValidity`) を無条件に実行する** (コア実装。免除は sendback / pullback / deny のみ)。検証失敗時は「入力エラーが発生しました。編集モードに変更し無効なフィールドを確認してください」が出て**承認がブロックされる**。承認者が編集不可 (editableForm:false) のステップでは復旧手段がなく詰む。

**Why:** consentFlow の参照モードは Form.io の `options.readOnly` を使わず、コンポーネント個別 `disabled=true` + CSS の擬似実装。Form.io の `shouldSkipValidation()` は `options.readOnly` しか見ないため、参照モードでも `validate.custom` / `required` / `minDate` がすべて生きる。

**典型事故:** 「申請日から N 日以降」のような**時間相対の日付チェック**。申請時は通るが、日付が過ぎた後の承認ステップで検証が再発火して全承認者がブロックされる (実例: 納品希望日フィールド)。永続化される隠しエラーフラグ (`data.xxxChk === 'error'` 型) を見る検証も、フラグが保存された文書で同じ症状になる。

**How to apply:** 申請時のみ強制したい `validate.custom` は、冒頭で**未申請 (下書き) 判定ガード**を入れる。判定は ApplicationNo + StatusNo 併用が正解 (採番は apply 時のみ / 差戻しでも番号維持 / 差戻し先 StatusNo はルート依存のため StatusNo 単独判定は不可):

```js
var isDraft = (typeof cfGlobal === 'undefined') ? true : (cfGlobal.applicationNo === '' && (cfGlobal.statusNo === '' || cfGlobal.statusNo === '0'));
if (!isDraft || !input) {
  valid = true;
} else {
  var minD = moment().startOf('day').add(10, 'days');
  valid = moment(input).startOf('day').isSameOrAfter(minD) ? true : '納品希望日は申請日より10営業日以降の日付を指定してください';
}
```

- `typeof cfGlobal === 'undefined'` → ビルダープレビューでは検証側に倒す
- 既知の例外: 改訂コピー機能は ApplicationNo 継承 + StatusNo="" の未申請文書を作るためガードで検証がスキップされる。Excel 一括インポートも任意組合せになり得る
- **`datePicker.minDate` / `widget.minDate` / `disableFunction` は Form.io がバリデーションにも使い、ガードを差し込めない**。時間相対の制約は minDate では実装せず、必ずガード付き `validate.custom` に一本化する (`moment().add(...)` を minDate に書くと上記事故が確実に起こる)
- `validate.required` もガード不可だが、値は保存済みなので承認時に落ちることは通常ない (保存後にフィールドを required 化した場合を除く)

## 9. 「文書を開いたとき 1 回だけ」実行する — 予約キー `scriptBlock` + `cfAPI.form` (フォームスクリプト)

Form.io 標準には JSON から書ける「フォームロード時フック」が無い (`<script>` はサニタイズ、`customDefaultValue` は公開時の評価値が FieldJson に焼き込まれ新規文書で再評価されない → [json-conventions.md](/docs/json-conventions.md) §2.1)。現行の consentFlow は **キーが `scriptBlock` のコンポーネント**の `content` を JavaScript (フォームスクリプト) として評価し、`cfAPI.form.*` に登録したコールバックを**保存データの適用後に 1 回だけ**呼ぶ。ガード・`setTimeout` 遅延・値の復元はコアが面倒を見るので、開発者は**コールバックの中身だけ**を書く。

```json
{ "type": "htmlelement", "tag": "div", "key": "scriptBlock",
  "input": false, "hidden": true, "hideLabel": true, "tableView": false,
  "content": "cfAPI.form.onOpen(function (ctx) { ... });   // ← 下の表の API を登録する JavaScript" }
```

| API | いつ 1 回 | 用途 |
|---|---|---|
| `cfAPI.form.onOpen(fn)` | ページ表示ごと (参照モードでも)。部分更新で Form.io が作り直されても再実行しない | 開くたびの初期化・マスタの 1 回読み (REST)・URL パラメータの読み取り |
| `cfAPI.form.onNew(fn)` | 新規作成時 (`cfGlobal.isNewNote`) | 作成時刻・作成者・作成時点の所属スナップショット |
| `cfAPI.form.migrate(ver, fn)` | 未実施の既存文書を編集モードで開いたとき (番号の昇順)。新規文書は実行せず最新番号を刻む | 既存文書のデータ移行・正規化 (バージョン管理) |
| `cfAPI.form.onReady(fn)` | Form.io が (再) 生成されるたび (部分更新ごと) | DOM へのイベント束縛・共有ヘルパの公開 (`cfAPI.form.lib`) |

```javascript
// scriptBlock の content (JSON 文字列なので改行はエスケープして書く)。評価時のスコープ: cfAPI / cfGlobal / form (Webform) / $ / jQuery
cfAPI.form.onNew(function (ctx) {
  ctx.set('createdSection', cfGlobal.sectionCode);              // 保存先は persistent:true の hidden
});
cfAPI.form.migrate(1, function (ctx) {                           // 移行内容を変えたら migrate(2, …) を「追加」する (1 は書き換えない)
  ctx.set('legacy', String(ctx.get('legacy') || '').trim());
});
cfAPI.form.onOpen(function (ctx) {
  if (ctx.isNewNote) return;                                     // REST は保存済み文書だけ (新規時の ctx.docid は仮 UNID)
  return $.ajax({ url: './restDocJson.xsp/json?srv=masterdb&db=masterdb&unid=' + encodeURIComponent(ctx.docid), timeout: 30000 })
    .then(function (doc) { ctx.set('masterInfo', doc.SomeItem || ''); },
          function () { ctx.set('masterInfo', '(取得失敗)'); });  // Promise を返すとコアが完了を待つ
});
```

- `ctx` = `{ form, data, cfGlobal, cfAPI, lib, isNewNote, editable, docid, get(key), set(key, value), component(key) }`。他項目の読み書きは `ctx.get` / `ctx.set` (`ctx.set` は未保存警告 (dirty) を立てない。calculateValue と同じ扱い)
- 呼び出し順は **onNew → migrate → onOpen → onReady**。前のフェーズの Promise が settle してから次へ進む。Promise を返すとコアは完了を待ち、その後に自動保存の基準ハッシュを取る (全体 10 秒で打ち切り)
- 例外は握り潰されない: `console.error('[cfAPI.form] onOpen#0 failed: …')` + `cfAPI.form.errors` に残る。`migrate` は失敗した番号で止まり、印はその手前まで
- `migrate` の実施済み番号はコアが自動注入する hidden **`cfMigrationVersion`** (Notes では `<prefix>cfMigrationVersion`) に保存される。フォームに置く必要はない。参照モードでは走らない (保存されないため)。リビジョン保存 (`X@N`) で固定された既存文書は旧 JSON を開くため新しい `migrate` は届かない (通常保存は全文書に届く)
- `onNew` はコピー・子文書 (PendingDraft) では発火しない (`isNewNote=false`。従来の `isNewNote && !value` と同じ条件)
- `key` は厳密に `scriptBlock`、1 フォームに 1 つ、**`hidden: true` を付ける** (旧バージョンでは無害な no-op になる)。ビルダー / 公開プレビューでは評価されない (`cfGlobal` 未定義) ので FieldJson に値は焼き込まれない。本文に `{{` を書かない (§10)
- **単一項目の既定値** (今日の日付など) は従来どおりその項目の `calculateValue` に `if (typeof cfGlobal !== 'undefined' && cfGlobal.isNewNote && !value) { value = …; }` と書いてよい。scriptBlock は複数項目の初期化・副作用 (REST)・移行向け
- **トップレベルは登録だけ**にする。本文のトップレベルは `form.submission` の適用**前**に評価されるので、そこで `setValue` した値は保存データの適用で消える。値の書き込みはコールバックの中 (`ctx.set`) で行う
- 本文の先頭が `<p>` / `<div>` 等のブロック要素で始まるとき (WYSIWYG ビルダーの出力) だけ HTML を解除して評価する。それ以外は**生の JavaScript として一切変換しない**ので、文字列中の `"<br>"` や `"&amp;"`、正規表現 `/<p>/` はそのまま使える。逆に WYSIWYG で書いた本文では `<br>` が改行に戻されるので、HTML を組み立てるスクリプトは生で書く (htmlelement の ACE エディタ / JSON 直編集)
- DataGrid / EditGrid の子は行付きのパスで指定する: `ctx.get('grid[0].child')` / `ctx.set('grid[0].child', v)`。`'child'` 単独は最後の行のインスタンス、`'grid.child'` は行の配列が返り `ctx.set` は `grid[i].child` を案内するエラーになる
- 実装例: 公開サンプル cfKitchenSink の ⚙️ ロジック・計算タブ「文書を開いたとき 1 回だけ実行する」パネル

### 9.1 フォールバック (旧バージョンの consentFlow): `type:"hidden"` + `calculateValue` + ガード

`scriptBlock` を解釈しない旧バージョンの consentFlow では、`type:"hidden"` (`input:true`) の項目の `calculateValue` にガード付きで書く。`calculateValue` はロード直後に必ず評価され、その後も入力のたびに再評価されるので、**ガードの置き場所で「1 回」のスコープが決まる**:

| スコープ | ガード | persistent |
|---|---|---|
| ページ表示ごとに 1 回 | 副作用: `if (!window.__xDone) { window.__xDone = true; window.__xAt = …; setTimeout(副作用, 0); }` ／ 値: `if (window.__xAt) value = window.__xAt;` | false |
| 新規作成時に 1 回だけ | `if (cfGlobal.isNewNote && !value) { value = … }` | **true** |
| 未実施の文書を開いたとき 1 回 | `var CURRENT = '2'; if (cfGlobal.editable && value !== CURRENT) { …移行…; value = CURRENT }` | **true** |

鉄則 4 つ: (a) 副作用のガードは **ページ変数 `window.__xxxDone`** (`instance._flag` は redraw で消える) (b) 他コンポーネントへの `setValue` / REST は `setTimeout(fn, 0)` で次のパスへ遅延 (初回パスでは他コンポーネントが未初期化) (c) 全体を `try/catch` — **calculateValue が例外を投げると Form.io は計算結果を黙って捨てる** (d) **結果はページ変数に持ち、`value` と結果欄は calculateValue でそこから毎回復元する** — コアは `createForm` 後に `form.submission` を再セットし、Form.io の `setNestedValue` は submission に無いキーを `defaultValue` にリセットする (自動保存リカバリでも同じ)。
`calculateValue` 内の `form` はフォーム定義 JSON (button の `custom` の `form` = Webform インスタンスとは別物) なので他コンポーネントは `instance.root.getComponent('key')` で取る。代入しなければ `value` は現在値を保持する。ガード無しで `value` を毎回変えると change → 再評価のループになる。ビルダープレビューは `cfGlobal` 未定義。サーバー側で「保存時に 1 回」やりたい処理は JS ではなく サーバー側のカスタマイズ (プロコード) の領域。

## 10. `htmlelement` / `content` の本文中の `{{ … }}` はテンプレート式として評価される — 説明文にプレースホルダー例を書くなら HTML 実体参照

Form.io は `htmlelement` の `content` と `content` コンポーネントの本文を **`{{ 式 }}` テンプレート (lodash template 互換) として評価してから描画する**。`{{ data.name }}` のような動的表示のための機能だが、副作用として「説明文の中にプレースホルダーの書き方を例示する」と壊れる。

**症状**: 管理者向けメモに Bot 連携の SQL 例 `INSERT ... VALUES ('{{$AppNo}}', ...)` や requestMapJson 例 `{{$CF_name2}}` を書いたところ、その htmlelement の**本文全体が青い帯の `$AppNo is not defined` に置き換わった**。`$AppNo` が JavaScript 変数として評価され ReferenceError になり、Form.io がエラー文を本文として出力する。`{{$...}}` (Bot の requestMapJson / SQLQuery / メールマスタ) や `{{CF_xxx}}` (divCondition / メールテンプレート) のように、consentFlow 側に別の `{{ }}` 記法が複数あるため、フォーム内ドキュメントで例示したくなる場面が多く踏みやすい。

**How to apply:**
- 説明文に書く波括弧は HTML 実体参照にする: `{{` → `&#123;&#123;`、`}}` → `&#125;&#125;`。テンプレート評価は生文字列に対して行われるので実体参照はマッチせず、ブラウザは `{{` として表示する。単一の `{` `}` (JSON 例の `"bots": {`) はそのままでよい
- `label` / `description` / `tooltip` / `placeholder` も同じ interpolate を通るため、`{{` を含めない (必要なら同じ実体参照)
- 動的に値を出したい場合だけ `{{ data.<key> }}` / `{{ row.<key> }}` を使う (これが本来の用途)
- 編集後は `{{` を検索して htmlelement/content の `content` に生の `{{` が残っていないか確認する (フィールドの `calculateValue` 等 JS 文字列内の `{{` は無関係なので、対象は説明文だけ)
- 実例: Bot テスト用フォームの管理者向け設定メモ

## 11. `htmlelement` に JS で書き込んだ中身は再描画で初期 `content` に戻る — 描き直しを仕組みにする

`htmlelement` の DOM を scriptBlock 等から `innerHTML` で書き換える表示 (Markdown プレビュー・集計表・バナー) は、**参照モードの `form.redraw()`、パネルの表示切替、`refreshOnChange` の再描画のたびに、スキーマの `content` (初期文言) に戻る**。`change` イベントだけで描いていると「参照モードでは出ない」「編集モードでも本文を触るまで出ない」になる (実例: Markdown プレビュー)。

**How to apply** (どちらか):
- **値だけで描けるなら Form.io に描かせる**: `content` に `{{ data.<key> }}` を書き `"refreshOnChange": true`。再描画のたびに評価されるので消えない。URL をリンクにするなら式の中で http(s) だけを通し、属性値をエスケープする。Form.io のサニタイズは `target` を残すので `target="_blank" rel="noopener noreferrer"` で新しいタブで開ける (値は `type:"hidden"` で別に保持)
- **ライブラリが要る描画 (marked 等) は描き直しを監視する**: `cfAPI.form.onReady` で 1 回描いたあと、`ctx.form.element` を `MutationObserver({childList:true, subtree:true})` で見張り、描いた要素に付けた印 (例 `el.__done = 入力値`) が無い要素 = 再描画された要素を見つけたら描き直す。印と入力値が同じなら何もしないのでループしない。observer はページ変数に持ち、onReady が再度走ったら古いものを `disconnect()` する。本文の変更は従来どおり `change` イベント (デバウンス) で描く

## 12. 別の項目の値を JS でまとめて書き換えるときの Form.io の癖 (変更通知の合体・計算の取りこぼし・条件の 1 回遅れ)

ある項目の変更をきっかけに、EditGrid の行などをスクリプトでまとめて書き換える (例: 公開日時を変えたら投稿日時を同じだけずらす) とき、次の 3 つに当たる (Form.io 4.x の実測)。

- **`form.on('change', e => …)` の `e.changed.component` は、変えた項目とは限らない。** 連続した変更は 1 回の通知にまとめられ、`e.changed` には最後に計算された項目 (calculateValue の隠し項目など) が入ることがある。「どの項目が変わったか」で分岐せず、見張りたい項目の値を前回の値と比べて判定する (前回の値は `cfAPI.form.onReady` で控える)
- **行を `setValue` で差し替えても、行から計算する隠し項目の `calculateValue` が走らないことがある。** 差し替えた直後に `form.getComponent(key).calculateComponentValue(form.data, {}, form.data)` を明示的に呼ぶ
- **`customConditional` は計算より先に評価され、計算結果を条件に使うと表示が 1 回遅れる。** 警告の表示条件や文面 (`{{ }}`) は、計算後の隠し項目ではなく元の行 (`data.grid`) から直接求める

また consentFlow では、編集モードで開いたときに「自動保存データの復元」ダイアログが出ていると、`cfAPI.form` のフック (onOpen / onReady) は**ダイアログを閉じるまで走らない**。スクリプトの動作確認で値を変えたまま閉じると次回このダイアログが出るので、確認のたびに「破棄する」を選ぶか、値を元に戻して閉じる。

## 13. `logic[]` の `trigger.event` で Form.io 内部イベント (initialized / change / blur / focus / render / error / submit) を購読する — 対象は常に「フォーム全体」

ビルダーのロジック → トリガー「タイプ」で **イベント名** (`"type": "event"`) を選ぶと、その下に「イベント名」の**自由入力欄**が出る (候補リストは出ない)。ここに Form.io が内部で emit するイベント名を**小文字のまま**手入力すると、button `action:"event"` のカスタムイベントと同じ仕組みで購読できる (Form.io 4.14 で実測)。大文字小文字は区別され `Initialized` では発火しない。

**対象は常にフォーム全体**: `Component.on()` はフォーム共有の emitter (`root.events`) に `formio.<イベント名>` で登録されるので、`blur` を購読した項目は**どの項目**がフォーカスを失っても発火する。「この項目の blur」ではない。自分に絞るには customAction でイベント引数 `result` (配列。`result[0]` が第 1 引数) を見る。

| trigger.event | 発火タイミング | `result[0]` |
|---|---|---|
| `initialized` | 保存データ適用後、最初の `change` 直後に **Form.io インスタンスごとに 1 回**。consentFlow では部分更新で Form.io が作り直されるたびに再発火 (「開いたとき 1 回だけ」は §9 の `scriptBlock` + `cfAPI.form.onOpen` に書く) | なし |
| `change` | どの項目でも値が変わるたび (ロード時の submission 適用でも 1 回。連続した変更は 1 回に合体 §12) | `{ data, changed: { component, instance, value, flags } }`。変わった項目は `result[0].changed.component.key`、ロード由来は `flags.fromSubmission` |
| `componentChange` | 項目単位の値変更 (`change` の直前) | `{ instance, component, value, flags }` |
| `blur` / `focus` | 入力欄のフォーカス移動 | 対象項目のインスタンス。`result[0] === instance` で自分だけに絞る |
| `render` | 描画 (attach) 完了。**`initialized` より前** | 描画された要素 |
| `error` | `form.submit()` の検証失敗。**consentFlow の保存 / 申請 / 承認ボタンは先に `checkValidity()` で止めて自前のエラー一覧を出すため発火しない** (差戻し / 取戻し / 否認は検証なしで `submit()` を呼ぶ)。入力エラーの検知は `validate.custom` で | エラー配列 (`result[0][i].message`) |
| `submit` | `form.submit()` が検証に通って完了したとき (非同期、呼び出しの数十 ms 後)。**コアの保存 / 申請 / 承認ボタンは `form.submit()` を呼んだ直後に同期で XPages 送信を始めるため、通常は間に合わず発火しない** (実測: 下書保存で logic は走らず、開発者ツールから `myFormio.form.submit()` を直接呼ぶと発火)。「保存直前に 1 回」の用途には使えない。保存後の処理はサーバー側のカスタマイズ | submission |

実測の発火順 (新規文書を開いたとき): `render` → `change`(submission 適用、changed なし) → `initialized` → (scriptBlock の `ctx.set` 由来の `change` …)。

**鉄則:**
- **logic は textfield / `type:"hidden"` など入力系コンポーネントに載せる。htmlelement / content に載せない。** `content` / `html` を持つコンポーネントの `interpolate()` は文字列を **i18n 翻訳してから**返すため、`trigger.event` が辞書語と衝突すると翻訳後の名前で購読してしまう。日本語 UI では `error` → 「処理前に以下のエラーを修正してください。」、`submit` → 「フォームの送信」になり**永久に発火しない** (`blur` / `change` / `focus` / `render` / `initialized` は辞書に無いので偶然動く)。実例: キッチンシンクでログ表示用 htmlelement に 7 つ載せたら `error` / `submit` だけ `events.listeners('formio.error')` に現れなかった
- **`change` を購読した customAction で `value` を書き換えない。** 評価後の `value` は自分の値として `setValue()` されるので change → 再発火のループになる。表示は DOM へ直書き (ページ変数のバッファ + `document.getElementById`) か、`blur` など change 以外のイベントで別項目へ `instance.root.getComponent(key).setValue()` する
- `initialized` に「1 回だけ」の処理を書かない。部分更新のたびに新しい Form.io インスタンスが `initialized` を再発火する (§9 の `cfAPI.form.onOpen` はコアが 1 回に抑える)
- 実装例: キッチンシンク ⚙️ ロジック・計算タブ「🔔 ライフサイクルイベント」パネル (`lcEventTap` hidden に 7 イベントの logic、`lcFieldA` の `result[0] === instance` による自分限定 blur、`lcAnyBlurCount` の `action:value`)

## Red Flags

- ❌ `logic[]` の `trigger.event` に `blur` / `change` を書いて「この項目の blur / change」のつもりでいる → 対象はフォーム全体。`result[0] === instance` / `result[0].changed.component.key` で絞る (§13)
- ❌ `trigger.event` の logic を htmlelement / content に載せる → イベント名が i18n 翻訳され `error` / `submit` が永久に発火しない。textfield / `type:"hidden"` に載せる (§13)
- ❌ `trigger.event: "error"` で consentFlow の保存 / 申請 / 承認の入力エラーを拾おうとする → コアは `checkValidity()` で先に止めるので `form.submit()` に到達せず発火しない。`validate.custom` を使う (§13)
- ❌ `<script>...</script>` を htmlelement / content の `content` に書く → Form.io サニタイズで実行されない。`customConditional` / `calculateValue` / `logic[]` を使え (§1)
- ❌ htmlelement の中身を `change` イベントのときだけ JS で書き換える → 参照モードの redraw やパネル再描画で初期文言に戻り、参照モードでは表示されない。`{{ data.x }}` + `refreshOnChange` か、MutationObserver で描き直す (§11)
- ❌ 連動させたい `calculateValue` フィールドに `allowCalculateOverride:true` を付ける → 文書ロード後はソース変更で再計算されない。`false` (既定) にし、空ロード時クリアはガードで防ぐ (§5)
- ❌ button JS の async 操作前に `XSP.allowSubmit()` を呼ばない → XPages サブミットガードに引っ掛かる (§2 / [server-data.md](/docs/server-data.md))
- ❌ multi-select の `validate.custom` で `input.length` を `Array.isArray` ガードなしに参照 → 1 個選択時に文字列長で誤判定 (§4)
- ❌ Chart.js v3/v4 API (`Chart.register(...)` 等) を呼ぶ → 全ページに自動ロードされるのは v2 系のみ。v2 の API のみ使え (§3)
- ❌ datagrid 外の文脈で `row` を参照する → `data` と同じになる場合と undefined になる場合があり Form.io バージョン依存。datagrid/editgrid 外では `data` を使う (§2)
- ❌ `dataType:"string"` の Select の `defaultValue` に空オブジェクト `{}` を入れる → `logic` トリガーの `dataValue !== ""` ガードをすり抜けてロード時に発火。partial refresh を伴うと無限リロード。`""` にする (§6)
- ❌ logic customAction で REST 取得した選択肢を `instance.component` へ非同期注入するだけ → 次の checkData で fieldLogic が原本クローンに巻き戻し選択肢が消える。トリガー常時 true + `component.data.values` への毎パス同期適用 + instance キャッシュにする (§7)
- ❌ customAction 内で `value` という名前のローカル変数を宣言 → このバンドルは評価後の `value` を `setValue()` に渡すため、意図せずコンポーネント値が書き換わる (§7)
- ❌ 時間相対の日付チェック (`moment().add(...)` 比較) を未申請ガードなしの `validate.custom` や `datePicker.minDate` に書く → 日付経過後の承認で全承認者がブロックされる。ガード付き `validate.custom` に一本化 (§8)
- ❌ 「開いたとき 1 回」の処理を `customDefaultValue` に書く / ガード無しの `calculateValue` に書く / `window.__xxxDone` + `setTimeout` を `calculateValue` に手書きする → 前者は FieldJson 焼き込みで新規文書で走らず、ガード無しは入力のたびに多重実行。予約キー `scriptBlock` + `cfAPI.form.onOpen` / `onNew` / `migrate` に書く (§9)。手書き版は旧バージョンの consentFlow 向けのフォールバックのみ (§9.1)
- ❌ `calculateValue` 内で `form.getComponent(...)` を呼ぶ → そこでの `form` は定義 JSON。`instance.root.getComponent(...)` を使う (§9.1)。scriptBlock / button の `custom` の `form` は Webform インスタンスなので `form.getComponent` でよい
- ❌ htmlelement / content の説明文に `{{$AppNo}}` のようなプレースホルダー例を生で書く → テンプレート評価で `$AppNo is not defined` になり本文全体がエラー文に置き換わる。`&#123;&#123;` `&#125;&#125;` の実体参照にする (§10)
- ❌ `form.on('change')` で `e.changed.component.key` を見て「この項目が変わったら」と分岐する → 連続した変更は 1 回の通知にまとめられ、計算された別の項目が載るので取りこぼす。値を前回と比べて判定する (§12)
