編集画面のカスタマイズ
編集画面(および関連する一覧画面の操作)を使いやすくするカスタマイズをまとめます。いずれも本体を改修せず、スタイル・サーバースクリプト・拡張機能(拡張スタイル、拡張スクリプト、拡張サーバースクリプト、拡張 SQL)で実装できます。
| カスタマイズ | 使う機能 |
|---|---|
| 項目の物理名を表示する(開発者向けのトグル表示を含む) | スタイル / 拡張スタイル(トグルは + 拡張スクリプト) |
| 自動ポストバックが有効な項目を示す | 拡張スタイル |
| リンクの作成ボタンを未保存時に制御する | 拡張スクリプト |
| リンク一覧を別タブで開く(一覧ごと / レコードごと) | 拡張スクリプト + 拡張スタイル |
| 誤って閉じたページの入力データを復旧する | 拡張スクリプト + 拡張スタイル |
| 変更箇所を可視化する | 拡張スクリプト + 拡張スタイル |
| コメント欄を見やすくする | スタイル / 拡張スタイル |
| ラジオボタンとチェックボックスをボタングループにする | 拡張スタイル |
| ラジオボタンを読取専用で使う | サーバースクリプト |
| 日付項目に和暦入力を追加する | 拡張スクリプト + 拡張スタイル |
| 選択したレコードを一括復元する | 拡張スクリプト + 拡張サーバースクリプト + 拡張 SQL |
項目の物理名を表示する
スクリプトやサーバースクリプトで必要になる項目の物理名(ClassA など)を、編集画面のラベルの後ろに表示します。開発者ツールで要素を調べなくても、画面を見るだけで物理名が分かります。
仕組み
編集画面の各項目は #(テーブル種別)_(物理名)Field という ID の要素(例: Results_TitleField)で囲まれ、その中の p.field-label label が項目名です。この外側の ID を CSS の attr() で取り出し、疑似要素 ::after で表示します。
TIP
ラベルの for 属性(attr(for))を使う方法もありますが、for を持たない項目(添付ファイルなど)でも疑似要素が出てしまい、読み取り専用項目では表示されません。外側の ID を使う次の方法なら、すべての項目に表示できます。
設定
スタイル(または拡張スタイル)に次の CSS を設定します。
[id$="Field"] {
--field-name: attr(id);
p.field-label label::after {
content: ' (' var(--field-name) ')';
color: gray
}
}
表示するユーザーを限定する
拡張スタイルなどにそのまま入れると全ユーザーに表示されます。$p.userId の値が入っている #UserId 要素の値で条件を付けると、特定のユーザー(例ではユーザー ID 3)がログインしているときだけ表示できます。
:root:has(#UserId[value="3"]) {
[id$="Field"] {
--field-name: attr(id);
p.field-label label::after {
content: ' (' var(--field-name) ')';
color: gray
}
}
}#UserId を #DeptId に書き換えて value に組織 ID を指定すると、組織(例: IT 部門)単位で表示を制御できます。
開発者だけがトグルで切り替える 1.5.1.0 以降
上の方法は、条件に合うユーザーには常に物理名が表示されます。拡張スタイルと拡張スクリプトを組み合わせると、次のように「開発者が必要なときだけ ON にする」表示にできます。
- 既定は OFF(通常の画面から変化なし)
- 画面右下のボタンで ON / OFF を切り替える。ON にすると編集画面・詳細画面・一覧画面のすべてに物理名が表示される
- ON / OFF の状態はブラウザの
localStorageに保存され、ページをまたいでも維持される - ボタンは指定した開発者ユーザーにだけ表示される
INFO
バージョン 1.5.1.0 以降を対象にしています。
物理名の取り出し方
| 画面 | HTML | 物理名の取り出し方 |
|---|---|---|
| 編集画面・詳細画面 | フィールドラッパーの ID が {サイト種別}_{物理名}Field(例: Results_ClassAField) | スクリプトで Results_ と末尾の Field を除き(ClassA)、ラベルの data-physical-name 属性に設定する |
| 一覧画面 | ヘッダーセルの data-name 属性に物理名が入っている(例: <th data-name="ClassA" class="sortable">) | 加工不要。CSS の attr(data-name) でそのまま取り出す |
表示の切り替えは body の show-field-names クラスの付け外しで行います。スタイルはこのクラスが付いているときだけ適用されるため、JavaScript(クラスの付け外しと属性の付与)と CSS(表示)の役割を分けられます。
図を読み込み中…
設定
次の 2 ファイルを配置します。スクリプトの実行タイミングは「画面表示の後」を選び、適用対象を全サイト共通にすると、どの画面でもボタンが表示されます。
ExtendedStyles/FieldNameToggle.css
/* ===================================================================
項目物理名トグル表示 – 拡張スタイル
=================================================================== */
/* === 編集・詳細画面: ラベルの後ろに物理名を表示 === */
body.show-field-names [data-physical-name]::after {
content: ' (' attr(data-physical-name) ')';
color: gray;
font-size: .9em;
}
/* === 一覧画面: ヘッダーセルに物理名を表示 === */
body.show-field-names th[data-name]::after {
content: ' (' attr(data-name) ')';
color: gray;
font-size: .85em;
}
/* === トグルボタン === */
#fn-toggle {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
background: #9e9e9e;
color: #fff;
border: none;
border-radius: 50%;
width: 42px;
height: 42px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, .35);
transition: background .2s;
}
#fn-toggle.active {
background: #1976d2;
}// ===================================================================
// 項目物理名トグル表示 – 拡張スクリプト
// ===================================================================
// ---- 設定 ----------------------------------------------------------
// 物理名トグルボタンを表示する開発者ユーザーのIDを文字列で列挙します。
// ユーザーIDは管理画面のユーザー設定ページのURLから確認できます。
const FN_DEV_IDS = ['3'];
// -------------------------------------------------------------------
if (FN_DEV_IDS.includes($('#UserId').val())) {
const FN_KEY = 'pleasanter_show_field_names';
const FN_CLASS = 'show-field-names';
// 編集・詳細画面: フィールドラッパーのIDから物理名を取り出してラベルに付与
$('[id$="Field"]').each(function () {
const sep = this.id.indexOf('_');
if (sep < 0) return;
const physicalName = this.id.slice(sep + 1).replace(/Field$/, '');
const $label = $(this).find('p.field-label label').first();
if ($label.length && physicalName) {
$label.attr('data-physical-name', physicalName);
}
});
// 前回の表示状態を復元
if (localStorage.getItem(FN_KEY) === '1') {
$('body').addClass(FN_CLASS);
}
// トグルボタンを追加(重複防止のため既存チェックを行う)
if (!$('#fn-toggle').length) {
$('<button id="fn-toggle" title="物理名表示 ON/OFF">')
.append('<span class="material-symbols-outlined" style="font-size:20px;user-select:none">code</span>')
.toggleClass('active', $('body').hasClass(FN_CLASS))
.on('click', function () {
const isOn = $('body').toggleClass(FN_CLASS).hasClass(FN_CLASS);
localStorage.setItem(FN_KEY, isOn ? '1' : '0');
$(this).toggleClass('active', isOn);
})
.appendTo('body');
}
}WARNING
FN_DEV_IDS には開発チームのメンバーのユーザー ID を列挙します。ユーザー ID は管理画面のユーザー設定ページの URL で確認できます(例: /users/edit/3 なら 3)。
| 状態 | 編集画面 | 一覧画面 |
|---|---|---|
| OFF(既定) | 通常のラベル表示 | 通常のヘッダー表示 |
| ON(ボタンをクリック後) | 担当チーム (ClassA) | 担当チーム (ClassA) |
ON のときボタンは青色(アクティブ)になり、もう一度クリックするとグレーに戻ります。
自動ポストバックが有効な項目を示す
自動ポストバックが有効な項目は、入力すると他の項目が書き換わったり UI がブロックされたりします。それを利用者に予告するため、項目ラベルに ^ マークを表示します。
仕組み
自動ポストバックが有効な項目のコントロールには control-auto-postback クラスが付きます。自動ポストバックが送る内容と、項目連携・画面の項目での絞り込みとの関係は 項目連携・自動ポストバック・画面項目での絞り込みの関係 にまとめています。
<select id="Results_ClassA" name="Results_ClassA" class="control-dropdown control-auto-postback">必須項目の赤いアスタリスクのデザインを流用し、:has() セレクタで対象のフィールドのラベルに疑似要素を追加します。チェックボックスはラベルの構造が異なるため、別のルールにしています。
設定
次の CSS を拡張スタイルとして組み込みます。
.field-wide:has(.control-auto-postback):not(:has(.required)),
.field-normal:has(.control-auto-postback):not(:has(.required)) {
.field-label label:after {
margin-left: 3px;
color: red;
content: "^"
}
}
.field-wide:has(.control-auto-postback):has(.required),
.field-normal:has(.control-auto-postback):has(.required) {
.field-label label:after {
margin-left: 3px;
color: red;
content: "*^"
}
}
.field-wide:has(.control-checkbox.control-auto-postback):not(:has(.required)),
.field-normal:has(.control-checkbox.control-auto-postback):not(:has(.required)) {
label:after {
margin-left: 3px;
color: red;
content: "^"
}
}
.field-wide:has(.control-checkbox.control-auto-postback):has(.required),
.field-normal:has(.control-checkbox.control-auto-postback):has(.required) {
label:after {
margin-left: 3px;
color: red;
content: "*^"
}
}
WARNING
必須項目のアスタリスクも同じ疑似要素(::after)で表示されているため、単純に追加すると自動ポストバックの表示が優先され、アスタリスクが消えます。疑似要素は 1 つしか指定できず、内部で色を分けることもできないため、上の CSS では必須のときは *^ をまとめて表示し、色は赤に統一しています。
リンクの作成ボタンを未保存時に制御する
編集画面の「リンク」セクションにある作成ボタンを押すと、現在の編集画面から離れるため、未保存の入力が失われる可能性があります。拡張スクリプトだけで、次の 4 パターンのいずれかの制御を実装します。
| パターン | 動作 |
|---|---|
| パターン 1 | 未保存のデータがある場合、作成ボタンを無効化し「未保存データあり」を表示する |
| パターン 2 | 未保存のデータがある場合、作成ボタンに「未保存データあり」を表示する(押下時は通常挙動) |
| パターン 3 | 未保存のデータがある場合、作成ボタン押下で自動保存してから遷移する |
| パターン 4 | 未保存のデータがある場合、作成ボタン押下で確認ダイアログを表示する |
INFO
バージョン 1.5 以降を対象にしています。
仕組み
作成ボタンは次のような HTML です(HtmlLinkCreations.cs)。
<fieldset class="enclosed link-creations is-sources">
<legend>リンク</legend>
<div>
<button
class="button button-icon confirm-unload"
onclick="$p.new($(this));"
data-id="{リンクID}"
data-from-site-id="{現在のサイトID}"
data-to-site-id="{リンク先サイトID}"
from-tab-index="{タブインデックス}"
do-not-return-parent="false"
>
リンク先テーブル名
</button>
</div>
</fieldset>利用するフロントエンドの仕組みは次のとおりです。
| 仕組み | 内容 |
|---|---|
$p.formChanged | フォームの変更状態のフラグ。.confirm-unload 内の入力要素が変更されると $p.setFormChanged() が呼ばれ true になる(_form.js) |
$p.new($control) | ボタンのデータ属性からリンク先の新規作成 URL を組み立て、$p.transition() で遷移する(item.js) |
$p.events['ajax_after_done_UpdateCommand'] | $p.events には {イベント名}_{controlId} の命名でイベントを登録でき、これで更新ボタン(#UpdateCommand)の Ajax 完了時だけをフックできる。args.ret に保存の成否(0:成功、-1:エラー)が入る |
$p.syncSend($control) | 同期送信。保存の成否を戻り値で返す(0:成功、-1:エラー)(_form.js) |
$p.confirmReload() | 「前のレコード」「次のレコード」への移動時にも使われる確認ダイアログ。$p.formChanged が true のときだけ confirm() を表示し、結果を返す(confirm.js) |
INFO
確認したソースでは、更新成功時のレスポンスに SetMemory("formChanged", false) が含まれ、本体が $p.formChanged を false に戻します(IssueUtilities.cs、_dispatch.js)。既定では更新後に編集画面全体が描画し直されますが、General.json の UpdateResponseType が 1 のときは画面の一部だけが更新され、「未保存データあり」の表示が残ります(IssueUtilities.cs)。そのため、パターン 1・2 では ajax_after_done_UpdateCommand で保存成功を検知し、フラグのリセットとボタン表示の更新を行っています。
パターンごとの処理の流れは次のとおりです。
図を読み込み中…
設定
次のいずれか 1 つ を拡張スクリプトとして App_Data/Parameters/ExtendedScripts/ に配置します。
$(function () {
var _orig = $p.setFormChanged;
$p.setFormChanged = function ($control) {
_orig.call($p, $control);
updateButtons();
};
$p.events['ajax_after_done_UpdateCommand'] = function (args) {
if (args.ret === 0) {
$p.formChanged = false;
updateButtons();
}
};
function updateButtons() {
$('.link-creations button.confirm-unload').each(function () {
var $btn = $(this);
if (!$btn.data('lcb-original-text')) {
$btn.data('lcb-original-text', $.trim($btn.text()));
}
if ($p.formChanged) {
$btn.prop('disabled', true);
$btn.html(
'<span class="ui-icon ui-icon-alert" style="display:inline-block;vertical-align:middle;margin-right:4px;"></span>' +
'<span style="vertical-align:middle;">' +
$btn.data('lcb-original-text') +
'(未保存データあり)</span>'
);
} else {
$btn.prop('disabled', false);
$btn.text($btn.data('lcb-original-text'));
}
});
}
updateButtons();
});$(function () {
var _orig = $p.setFormChanged;
$p.setFormChanged = function ($control) {
_orig.call($p, $control);
updateButtons();
};
$p.events['ajax_after_done_UpdateCommand'] = function (args) {
if (args.ret === 0) {
$p.formChanged = false;
updateButtons();
}
};
function updateButtons() {
$('.link-creations button.confirm-unload').each(function () {
var $btn = $(this);
if (!$btn.data('lcb-original-text')) {
$btn.data('lcb-original-text', $.trim($btn.text()));
}
if ($p.formChanged) {
$btn.html(
'<span class="ui-icon ui-icon-alert" style="display:inline-block;vertical-align:middle;margin-right:4px;"></span>' +
'<span style="vertical-align:middle;">' +
$btn.data('lcb-original-text') +
'(未保存データあり)</span>'
);
} else {
$btn.text($btn.data('lcb-original-text'));
}
});
}
updateButtons();
});$(function () {
function initButtons() {
$('.link-creations button.confirm-unload').each(function () {
var $btn = $(this);
if ($btn.data('lcb-ready')) return;
$btn.data('lcb-ready', true);
$btn.removeAttr('onclick');
$btn.on('click.lcb', function () {
var $self = $(this);
if (!$p.formChanged) {
$p.new($self);
return;
}
var $updateBtn = $('#UpdateCommand');
if ($updateBtn.length === 0) {
$p.new($self);
return;
}
var error = $p.syncSend($updateBtn);
if (error === 0) {
$p.new($self);
}
});
});
}
initButtons();
$(document).ajaxComplete(initButtons);
});$(function () {
function initButtons() {
$('.link-creations button.confirm-unload').each(function () {
var $btn = $(this);
if ($btn.data('lcb-ready')) return;
$btn.data('lcb-ready', true);
$btn.removeAttr('onclick');
$btn.on('click.lcb', function () {
if (!$p.confirmReload()) return;
$p.new($(this));
});
});
}
initButtons();
$(document).ajaxComplete(initButtons);
});ポイント
- パターン 1・2:
$p.setFormChanged()を元の処理を_origに退避したうえでオーバーライドし、フォームが変更されたタイミングでボタン表示を更新します。パターン 1 では保存失敗時(args.ret === -1)はボタンが無効のままです。 - パターン 3・4:
removeAttr('onclick')でインラインハンドラーを外し、名前空間付きのイベントclick.lcbに置き換えます。ajaxCompleteで再初期化するため、lcb-readyフラグで二重初期化を防いでいます。 - パターン 3:
#UpdateCommandが無い場合(更新権限が無いレコードなど)は保存をスキップして遷移します。バリデーションエラーなどで保存に失敗した場合は遷移しません。 - パターン 4: 表示されるメッセージは「このページを離れますか? 行った変更が保存されない可能性があります。」です。
データ損失を確実に防ぎたい場合はパターン 3、利用者の判断に任せたい場合はパターン 4 が向いています。
リンク一覧を別タブで開く
編集画面のリンクセクションに表示されるリンクテーブルから、現在の編集画面を離れずに別タブで開けるようにします。開く対象によって 2 つの方法があります。
| 方法 | 追加する場所 | 別タブで開くもの | 対象バージョン |
|---|---|---|---|
| 一覧を別タブで開く | テーブルの見出し(caption) | リンクに表示されている対象に絞ったリンク先テーブルの一覧画面 | 1.4 以降 |
| レコードごとに別タブで開く | テーブルの一番左の列(各行) | その行のレコードの編集画面 | 1.5 以降 |
どちらもリンクテーブルを table.grid[data-action="LinkTable"] で特定し、ajaxComplete で再描画に追従する点は共通です。両方を同時に使うこともできます。
一覧を別タブで開く
リンクテーブルは表示件数やカラムが限られています。テーブルの見出しに「別タブで開く」アイコン(Material Symbols の open_in_new)を追加し、リンクに表示されている対象に絞ったリンク先テーブルの一覧画面を別タブで開けるようにします。
INFO
バージョン 1.4 以降を対象にしています。
仕組み
リンクテーブルは table.grid[data-action="LinkTable"] で、data-id 属性にリンク先テーブルのサイト ID が入っています。通常のグリッドとは data-action="LinkTable" で区別できます。
<table class="grid"
data-id="{サイトID}"
data-name="Destination"
data-action="LinkTable">
<caption>
<span class="caption-direction">リンク先 : </span>
<span class="caption-title">フォルダ > テーブル名</span>
<span class="caption-quantity"> - 件数 </span>
<span class="caption-count">5</span>
</caption>
...
</table>リンク先の一覧 URL に、現在のサイト ID を FromSiteId、現在のレコード ID を LinkId として付けると、リンクに表示されている対象に絞った一覧が開きます。
| 要素 | 説明 |
|---|---|
$('#ApplicationPath').val() | プリザンターのベースパス(例: /) |
data-id | リンクテーブルのサイト ID |
$('#SiteId').val() | 現在開いている編集画面のサイト ID(FromSiteId) |
$('#IssueId').val() / $('#ResultId').val() | 現在開いているレコード ID(LinkId) |
図を読み込み中…
設定
次の 2 ファイルを配置します。
.lnt-open {
margin-left: 8px;
cursor: pointer;
vertical-align: middle;
color: inherit;
text-decoration: none;
}
.lnt-open .material-symbols-outlined {
font-size: 16px;
vertical-align: middle;
}
.lnt-open:hover {
opacity: 0.7;
}$(function () {
function addOpenButtons() {
$('table.grid[data-action="LinkTable"]').each(function () {
var $table = $(this);
var siteId = $table.attr('data-id');
if (!siteId) return;
var $caption = $table.find('caption');
if ($caption.length === 0 || $caption.find('.lnt-open').length > 0) return;
var basePath = $('#ApplicationPath').val();
var fromSiteId = $('#SiteId').val();
var currentId =
$('#IssueId').val() ||
$('#ResultId').val() ||
location.pathname.match(/\/items\/(\d+)/)?.[1] ||
'';
if (!fromSiteId || !currentId) return;
var href =
basePath +
'items/' +
siteId +
'/index' +
'?FromSiteId=' +
encodeURIComponent(fromSiteId) +
'&LinkId=' +
encodeURIComponent(currentId);
var $btn = $('<a>', {
href: href,
target: '_blank',
rel: 'noopener noreferrer',
class: 'lnt-open',
title: '一覧を別タブで開く'
}).append(
$('<span>', {
class: 'material-symbols-outlined',
text: 'open_in_new'
})
);
$caption.append($btn);
});
}
addOpenButtons();
$(document).ajaxComplete(function () {
addOpenButtons();
});
});caption内に.lnt-openが既にあればスキップし、二重追加を防ぎます。- リンクテーブルのページネーション操作ではテーブルが Ajax で再描画されてアイコンが消えるため、
ajaxCompleteで再追加します。
レコードごとに別タブで開く
リンクテーブルの行をクリックするとリンク先レコードの編集画面へ遷移しますが、現在のページから離れてしまいます。リンク先のレコードを参照しながら編集したい場合や、複数のリンク先レコードを続けて確認したい場合に備え、テーブルの一番左の列に「別タブで開く」ボタンを追加します。拡張スクリプトだけで動作し、見た目は拡張スタイルで調整します。
INFO
バージョン 1.5 以降を対象にしています。
仕組み
リンクセクションは、サイト設定でリンクが定義されているときに GridTable() メソッドで生成されます(HtmlLinks.cs)。
<fieldset class="enclosed">
<legend>リンク</legend>
<div id="{dataTableName}Wrap" class="grid-wrap">
<table
id="{dataTableName}"
class="grid"
data-id="{リンク先サイトID}"
data-name="{Destination または Source}"
data-value="back"
data-action="LinkTable"
data-method="post"
>
<thead>
<tr class="ui-widget-header">
<th>タイトル</th>
<th>...</th>
</tr>
</thead>
<tbody>
<tr class="grid-row" data-id="{レコードID}">
<td>タイトル値</td>
<td>...</td>
</tr>
...
</tbody>
</table>
</div>
</fieldset>| 要素 | 内容 |
|---|---|
テーブルの data-id | リンク先のサイト ID |
テーブルの data-name | リンクの方向(リンク先: Destination / リンク元: Source) |
テーブルの data-action="LinkTable" | 一覧画面など他のテーブルには付かないため、リンクテーブルだけを絞り込める |
各行(tr.grid-row)の data-id | レコード ID |
行をクリックしたときの遷移は gridevents.js に実装されています。.grid-row 内の td がクリックされると、.grid-check や .select などのチェック要素が含まれていないかを確認し、含まれていなければ次のように遷移します(gridevents.js)。
var dataId = $(this).closest('.grid-row').attr('data-id');
...
$p.transition(
$('#BaseUrl').val() +
dataId +
paramVer +
(params.length ? (paramVer ? '$' : '?') + params.join('&') : '')
);$('#BaseUrl').val() は隠し入力から読み取るベース URL(例: /items/)で、これにレコード ID を付けた /items/{id} がレコードの編集画面の URL です。プリザンターの ID は全レコード共通の連番なので、サイトをまたいでも同じ書き方で開けます。
設定
次の 2 ファイルを配置します。
$(function () {
function addOpenButtons() {
$('table.grid[data-action="LinkTable"]').each(function () {
var $table = $(this);
// ヘッダー行にカラムを追加(重複防止)
$table.find('thead tr').each(function () {
if ($(this).find('th.link-open-th').length === 0) {
$(this).prepend('<th class="link-open-th" data-cell-sticky="1"></th>');
}
});
// 各行にボタンを追加(重複防止)
$table.find('tbody tr.grid-row').each(function () {
var $tr = $(this);
if ($tr.find('td.link-open-td').length > 0) return;
var dataId = $tr.attr('data-id');
var url = $('#BaseUrl').val() + dataId;
var $td = $('<td class="link-open-td"></td>');
var $btn = $(
'<button type="button" class="button button-icon" title="別タブで開く">' +
'<span class="ui-icon ui-icon-extlink"></span>' +
'</button>'
);
$btn.on('click.link-open', function (e) {
e.stopPropagation();
window.open(url, '_blank', 'noopener noreferrer');
});
$td.append($btn);
$tr.prepend($td);
});
});
}
addOpenButtons();
$(document).ajaxComplete(function () {
addOpenButtons();
});
});ExtendedStyles/LinkTableOpenInNewTab.css
/* ボタン列ヘッダー */
th.link-open-th {
width: 40px;
min-width: 40px;
padding: 4px;
}
/* ボタン列セル */
td.link-open-td {
padding: 4px;
text-align: center;
white-space: nowrap;
}
/* アイコンボタン */
td.link-open-td .button {
min-width: 0;
padding: 4px;
}
/* sticky 時にスクロール背後の内容が透けないよう背景色を設定 */
th.link-open-th {
background-color: var(--grid-th-background, #f5f5f5);
}
td.link-open-td {
background-color: var(--grid-td-background, #fff);
}ポイント
| ポイント | 内容 |
|---|---|
| 重複防止 | リンクテーブルはページング・ソートのたびに Ajax で再描画され、addOpenButtons() は Ajax 完了のたびに実行されます。th.link-open-th と td.link-open-td の有無を確認して二重挿入を防ぎます |
| イベントの伝播抑止 | 行内の td のクリックには通常の遷移処理が登録されています。e.stopPropagation() で伝播を止め、ボタンのクリックで通常遷移が発火しないようにします |
| 再描画への追従 | $(document).ajaxComplete() に addOpenButtons() を登録し、ページング・ソート後の再描画でもボタンを追加します |
| 列の固定 | th に data-cell-sticky="1" を付けると、標準の grid-container カスタム要素がこの属性を検出し、テーブルが横スクロール状態のときに position: sticky を適用します |
grid-container は、data-cell-sticky が付いた列の left 位置をヘッダー行の th の幅から順に計算し、次のようなスタイルを当てます(grid-container.ts)。
const stickyIndexes = Array.from(cells)
.map((th, index) => (th.dataset.cellSticky ? index : -1))
.filter(index => index !== -1);
// ...
.${this.hash} > thead > tr:first-child > th:nth-child(${cellIndex + 1}) {
position: sticky;
z-index: 2;
left: ${lefts[index]}px;
}
.${this.hash} > tbody > tr > td:nth-child(${cellIndex + 1}) {
position: sticky;
z-index: 2;
left: ${lefts[index]}px;
}grid-container の init() は connectedCallback から 100ms 遅らせて実行されます(ページ読み込み時は load イベント待ち)。ajaxComplete のコールバックはこの 100ms より先に動くため、addOpenButtons() が data-cell-sticky="1" の th を追加した後に setScrollGridStyle() が走り、ボタン列が sticky 列として正しく認識されます。
INFO
sticky が効くのは、列がコンテナの幅を超えてテーブルが横スクロール状態になっているときだけです。すべての列が表示領域に収まっている場合は、スクロール自体が発生しないため効果は現れません。
誤って閉じたページの入力データを復旧する
編集中にタブを閉じたり別ページへ移動したりして入力内容が失われた場合に備え、ページを離れる際に入力データを localStorage へ自動バックアップし、次にそのページを開いたときに復旧できるようにします。
- ページを離れる際、変更がある場合だけ入力データを
localStorageに保存します。 - 復旧時は、バックアップ時と現在のレコード更新日時を比較し、他のユーザーによる更新を検知します。
- 更新日時が一致していれば、復旧するか破棄するかを画面上部の通知バーで確認します。

仕組み
| 仕組み | 内容 |
|---|---|
$p.data | フォームの入力値を保持するグローバルオブジェクト。フォーム ID をキーに、$p.data.MainForm.Results_Title のようにコントロールの値が入る |
$p.setMustData($('#MainForm'), 'create') | 第 2 引数に 'create' を渡すと、フォーム内の [class*="control-"] に一致する全コントロールから値を $p.data.MainForm に収集する |
| 保存対象の絞り込み | $p.data.MainForm には Token・ControlId などシステム用の値も含まれるため、$p.tableName() + '_' で始まるキー(例: Results_Title、Issues_Body)だけを保存する |
| 更新日時 | $('#' + $p.tableName() + '_UpdatedTime').attr('datetime') で time 要素の datetime 属性から取得する |
$p.set($control, value) | コントロールの種類(チェックボックス、マークダウン、検索型ドロップダウン、複数選択、ラジオボタンなど)に応じて値を設定する標準関数 |
INFO
バージョン番号($p.ver())は、テーブルの管理 → エディタ → 自動バージョンアップの設定によっては更新時に加算されないことがあります。更新日時は設定に関わらず常に更新されるため、競合チェックには更新日時を使います。
| 条件 | 判定 | 動作 |
|---|---|---|
| 保存した更新日時と現在の更新日時が一致 | 未更新 | 復旧するか確認 |
| 保存した更新日時と現在の更新日時が不一致 | 更新済み | 復旧不可を通知 |
バックアップの流れ:
図を読み込み中…
復旧の流れ:
図を読み込み中…
設定
次の 2 ファイルを配置します。
ExtendedStyles/FormBackup.css(通知バーのスタイル)
/* === 通知バー共通 === */
.fb-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 99999;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 24px;
font-size: 14px;
line-height: 1.6;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
animation: fb-slide-down 0.3s ease-out;
}
@keyframes fb-slide-down {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
/* --- 情報タイプ(復旧可能) --- */
.fb-bar.fb-info {
background: #e3f2fd;
border-bottom: 2px solid #1976d2;
color: #0d47a1;
}
/* --- 警告タイプ(復旧不可) --- */
.fb-bar.fb-warn {
background: #fff3e0;
border-bottom: 2px solid #f57c00;
color: #e65100;
}
/* --- アイコン --- */
.fb-bar .material-symbols-outlined {
font-size: 22px;
flex-shrink: 0;
}
/* --- メッセージ --- */
.fb-message {
flex: 1;
}
/* --- ボタン共通 --- */
.fb-bar button {
border: none;
border-radius: 4px;
padding: 6px 20px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
/* 復旧ボタン */
.fb-restore {
background: #1976d2;
color: #fff;
}
.fb-restore:hover {
background: #1565c0;
}
/* 破棄・閉じるボタン */
.fb-discard {
background: transparent;
color: #555;
border: 1px solid #bdbdbd !important;
}
.fb-discard:hover {
background: #f5f5f5;
}$(function () {
// ─── 設定 ───────────────────────────────────────────
var config = {
// localStorage で使用するキーのプレフィックス
storageKey: 'FormBackup',
// バックアップの有効期間(時間単位)。0 で無期限
maxAgeHours: 24
};
// ───────────────────────────────────────────────────
// 編集画面のみ対象
if ($p.action() !== 'edit') return;
if (!$('#MainForm').length) return;
var recordId = $p.id();
var fullKey = config.storageKey + '_' + location.pathname;
// 更新日時の取得ヘルパー
function getUpdatedTime() {
return $('#' + $p.tableName() + '_UpdatedTime').attr('datetime') || '';
}
// ── 復旧チェック ──
checkBackup();
// ── beforeunload でバックアップ ──
$(window).on('beforeunload.formBackup', function () {
if ($p.formChanged) {
saveBackup();
}
});
// ── 更新成功時にバックアップを削除 ──
$(document).on('ajaxComplete.formBackup', function (e, xhr, settings) {
if (xhr.status === 200 && settings.url
&& /\/update(?:$|\?)/.test(settings.url)) {
localStorage.removeItem(fullKey);
}
});
// ────────────────────────────────────────────────────
// バックアップ保存
// ────────────────────────────────────────────────────
function saveBackup() {
try {
// 全コントロールの値を $p.data.MainForm に収集
$p.setMustData($('#MainForm'), 'create');
var allData = $p.data.MainForm || {};
// テーブル名プレフィックスに一致するフィールドのみ抽出
var prefix = $p.tableName() + '_';
var backup = {};
var hasData = false;
Object.keys(allData).forEach(function (key) {
if (key.indexOf(prefix) === 0) {
backup[key] = allData[key];
hasData = true;
}
});
if (!hasData) return;
var entry = {
id: recordId,
updated: getUpdatedTime(),
time: new Date().getTime(),
data: backup
};
localStorage.setItem(fullKey, JSON.stringify(entry));
} catch (e) {
// localStorage が使用できない環境では何もしない
}
}
// ────────────────────────────────────────────────────
// バックアップ確認
// ────────────────────────────────────────────────────
function checkBackup() {
try {
var raw = localStorage.getItem(fullKey);
if (!raw) return;
var entry = JSON.parse(raw);
// レコード ID が一致しない場合は古いバックアップとして削除
if (entry.id !== recordId) {
localStorage.removeItem(fullKey);
return;
}
// 有効期限チェック
if (config.maxAgeHours > 0) {
var age = new Date().getTime() - entry.time;
if (age > config.maxAgeHours * 3600000) {
localStorage.removeItem(fullKey);
return;
}
}
// 更新日時比較
var currentUpdated = getUpdatedTime();
if (entry.updated !== currentUpdated) {
// 他のユーザー(または自分)が更新済み → 復旧不可
showBar(
'warn',
'warning',
'前回の編集後にこのレコードは更新されています。'
+ 'バックアップデータは復旧できません。',
[{ label: '閉じる', css: 'fb-discard', action: 'close' }]
);
} else {
// 更新日時一致 → 復旧可能
showBar(
'info',
'history',
'前回保存されなかった編集データのバックアップがあります。'
+ '復旧しますか?',
[
{ label: '復旧する', css: 'fb-restore', action: 'restore' },
{ label: '破棄する', css: 'fb-discard', action: 'close' }
]
);
}
} catch (e) {
localStorage.removeItem(fullKey);
}
}
// ────────────────────────────────────────────────────
// 復旧処理
// ────────────────────────────────────────────────────
function restoreBackup() {
try {
var raw = localStorage.getItem(fullKey);
if (!raw) return;
var entry = JSON.parse(raw);
var data = entry.data || {};
Object.keys(data).forEach(function (controlId) {
var $control = $('#' + controlId);
if ($control.length) {
$p.set($control, data[controlId]);
}
});
// 復旧後は「変更あり」状態にする
$p.formChanged = true;
} catch (e) {
// 復旧に失敗しても画面操作には影響しない
}
localStorage.removeItem(fullKey);
}
// ────────────────────────────────────────────────────
// 通知バー表示
// ────────────────────────────────────────────────────
function showBar(type, icon, message, buttons) {
var $bar = $('<div>', { class: 'fb-bar fb-' + type });
$bar.append(
$('<span>', { class: 'material-symbols-outlined', text: icon }),
$('<span>', { class: 'fb-message', text: message })
);
buttons.forEach(function (btn) {
var $button = $('<button>', {
class: btn.css,
text: btn.label
});
$button.on('click', function () {
if (btn.action === 'restore') {
restoreBackup();
} else {
localStorage.removeItem(fullKey);
}
$bar.slideUp(200, function () { $bar.remove(); });
});
$bar.append($button);
});
$('body').append($bar);
}
});設定項目とポイント
| 項目 | 型 | 既定値 | 説明 |
|---|---|---|---|
storageKey | string | 'FormBackup' | localStorage で使用するキーのプレフィックス |
maxAgeHours | number | 24 | バックアップの有効期間(時間単位)。0 で無期限 |
- 保存キーにページのパス(
location.pathname)を含めるため、レコードごとに個別にバックアップされます。 - 復旧時はレコード ID・有効期限・更新日時の 3 段階でチェックします。
- 復旧後に
$p.formChanged = trueとするため、ページを離れる際の確認ダイアログが有効になり、復旧内容の保存忘れを防ぎます。 - 更新リクエスト(URL が
/updateで終わる)が成功したら、ajaxCompleteでバックアップを削除します。 - 通知バーのアイコンは、プリザンターに標準で読み込まれている Material Symbols を使います。
特定のサイトだけに適用したい場合は、config の直後に条件分岐を追加します。
// サイト ID 12345 のみに適用する例
if (!location.pathname.match(/\/items\/12345/)) return;注意点
INFO
localStorage はブラウザとドメイン単位で保持されるため、別のブラウザや別の端末で編集した内容は復旧できません。
DANGER
localStorage には容量制限(一般的に 5〜10 MB)があります。多数のレコードでバックアップが蓄積すると容量を圧迫する可能性があるため、maxAgeHours に適切な値を設定してください。
DANGER
入力内容の一時的な復旧手段です。ファイル添付やリンクテーブルの変更など、$p.data で管理されないデータは復旧できません。重要な変更は都度「更新」ボタンで保存してください。
変更箇所を可視化する
編集画面で入力中にページを離れようとすると「ページを離れますか?」という確認ダイアログが出ますが、どの項目を変更したのかは分かりません。初期値から変更された項目をリアルタイムにハイライトし、変更箇所の一覧をワンクリックで確認できるようにします。
- 変更項目のハイライト: 初期値から変更された項目に色付きの左ボーダーを表示します。
- 変更箇所カウンター: ツールバーに変更件数のバッジを常に表示します。
- 変更一覧パネル: バッジをクリックすると、変更された項目名・変更前・変更後を一覧表示します。
- 元に戻す操作: パネルから項目ごとに初期値へ戻せます。
![]()
![]()
INFO
バージョン 1.5.1.0 以降を対象にしています。拡張スタイルと拡張スクリプトだけで実装しており、本体の改変は不要です。
仕組み
フォーム内の input・select・textarea で change が発火すると $p.setFormChanged が呼ばれ、$p.formChanged が true になります(_form.js)。ページを離れる際の確認ダイアログは beforeunload で、このフラグを見て表示されます(confirmevents.js)。
// _form.js
$p.setFormChanged = function ($control) {
if (!$control.hasClass('not-set-form-changed')) {
$p.formChanged = true;
}
};
// confirmevents.js
$(window).bind('beforeunload', function () {
if ($p.formChanged) {
return $p.display('ConfirmUnload');
}
});$p.formChanged は「何かが変更された」というフラグでしかなく、どの項目が変更されたかは記録されません。そこで拡張スクリプトで初期値のスナップショットを持ち、現在の値と比べて変更箇所を特定します。
| 方針 | 内容 |
|---|---|
| 初期値の取得 | ページ読み込み時に全コントロールの値をスナップショットとして保存 |
| 変更の検知 | change / input イベントで現在の値とスナップショットを比較 |
| 変更の表示 | 差分がある項目にハイライト用の CSS クラスを付与 |
| カウンター | 変更項目数をツールバーのバッジに反映 |
| 一覧パネル | 変更項目のラベル名・変更前・変更後を表示 |
図を読み込み中…
追跡の対象は #MainForm 内のコントロールです。レコードの項目は {テーブル名}_{カラム名}(例: Results_Title)の ID を持つため、このプレフィックスで絞り込みます。値はコントロールの種類ごとに文字列にそろえ、単純な文字列比較で初期値と比べます。
| コントロールの種類 | 値の取得方法 |
|---|---|
| テキスト入力・テキストエリア | $control.val() |
| チェックボックス | $control.prop('checked') を 'true' / 'false' の文字列にする |
| ドロップダウン(単一選択) | $control.val() |
| 複数選択 | $control.val() を JSON 文字列化 |
| マークダウンエディタ | .control-markdown の val() |
設定
次の 2 ファイルを配置します。
ExtendedStyles/FieldChangeTracker.css(ハイライト・バッジ・パネルのスタイル)
/* ===================================================================
変更箇所可視化 – 拡張スタイル
=================================================================== */
/* === 変更項目ハイライト === */
.fct-changed {
position: relative;
}
.fct-changed::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #fb8c00;
border-radius: 2px;
}
.fct-changed .field-label {
color: #e65100;
font-weight: bold;
}
/* === ツールバー変更カウンターバッジ === */
.fct-badge-wrap {
display: inline-flex;
align-items: center;
margin-left: 8px;
cursor: pointer;
user-select: none;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s;
}
.fct-badge-wrap:hover {
background: rgba(0, 0, 0, 0.06);
}
.fct-badge-icon {
font-size: 20px;
color: #757575;
transition: color 0.2s;
}
.fct-badge-wrap.has-changes .fct-badge-icon {
color: #fb8c00;
}
.fct-badge-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
margin-left: 4px;
padding: 0 6px;
border-radius: 10px;
font-size: 12px;
font-weight: bold;
line-height: 1;
color: #fff;
background: #bdbdbd;
transition: background 0.2s;
}
.fct-badge-wrap.has-changes .fct-badge-count {
background: #fb8c00;
}
/* === 変更一覧パネル === */
.fct-panel-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 99998;
animation: fct-fade-in 0.15s ease-out;
}
.fct-panel {
position: fixed;
top: 0;
right: 0;
width: 420px;
max-width: 90vw;
height: 100vh;
background: #fff;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
z-index: 99999;
display: flex;
flex-direction: column;
animation: fct-slide-in 0.2s ease-out;
}
@keyframes fct-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fct-slide-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
/* パネルヘッダー */
.fct-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
.fct-panel-title {
font-size: 15px;
font-weight: bold;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
.fct-panel-close {
border: none;
background: transparent;
cursor: pointer;
padding: 4px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.fct-panel-close:hover {
background: #e0e0e0;
}
/* パネルボディ */
.fct-panel-body {
flex: 1;
overflow-y: auto;
padding: 0;
}
/* 変更なしメッセージ */
.fct-empty {
padding: 40px 20px;
text-align: center;
color: #9e9e9e;
font-size: 14px;
}
/* 変更項目カード */
.fct-item {
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.15s;
}
.fct-item:hover {
background: #fafafa;
}
.fct-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.fct-item-label {
font-size: 13px;
font-weight: bold;
color: #e65100;
}
.fct-item-revert {
border: 1px solid #bdbdbd;
background: #fff;
color: #555;
font-size: 12px;
padding: 2px 10px;
border-radius: 3px;
cursor: pointer;
transition: background 0.2s;
}
.fct-item-revert:hover {
background: #f5f5f5;
}
/* 変更前→変更後の表示 */
.fct-diff {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
line-height: 1.6;
}
.fct-diff-arrow {
flex-shrink: 0;
color: #9e9e9e;
padding-top: 2px;
}
.fct-diff-old,
.fct-diff-new {
flex: 1;
min-width: 0;
padding: 4px 8px;
border-radius: 3px;
word-break: break-all;
max-height: 60px;
overflow: hidden;
text-overflow: ellipsis;
}
.fct-diff-old {
background: #ffebee;
color: #c62828;
text-decoration: line-through;
}
.fct-diff-new {
background: #e8f5e9;
color: #2e7d32;
}
.fct-diff-empty {
color: #9e9e9e;
font-style: italic;
}.fct-changed::beforeで変更された項目の左側にオレンジのボーダーを表示します。.fct-badge-wrapはツールバーに置くバッジで、変更があるとオレンジ色になります。.fct-panelは右側からスライドインするサイドパネルです。.fct-diff-old(赤)と.fct-diff-new(緑)で変更前と変更後を区別します。- アイコンはプリザンターに標準で読み込まれている Material Symbols を使うため、追加の読み込みは不要です。
ExtendedScripts/FieldChangeTracker.js(変更検知・パネル表示ロジック)
$(function () {
// ─── 設定 ───────────────────────────────────────────
var config = {
// 除外するコントロール ID のサフィックス一覧
excludeSuffixes: ['_Timestamp', '_Token', '_SwitchRecordsWith'],
// true の場合、変更カウンターが 0 でもバッジを表示する
showBadgeAlways: true,
// 値が空の場合に表示するテキスト
emptyText: '(空)'
};
// ───────────────────────────────────────────────────
// 編集画面のみ対象
if ($p.action() !== 'edit') return;
if (!$('#MainForm').length) return;
var tableName = $p.tableName();
var prefix = tableName + '_';
// 初期値のスナップショット
var initialValues = {};
// ラベル名のキャッシュ
var labelCache = {};
// ────────────────────────────────────────────────────
// 値の取得ヘルパー
// ────────────────────────────────────────────────────
function getControlValue($control) {
if (!$control.length) return undefined;
var tagName = $control.prop('tagName');
var type = $control.prop('type');
if (type === 'checkbox') {
return $control.prop('checked') ? 'true' : 'false';
}
if (tagName === 'SELECT' && $control.prop('multiple')) {
return JSON.stringify($control.val() || []);
}
return ($control.val() || '').toString();
}
// ────────────────────────────────────────────────────
// ラベル名の取得ヘルパー
// ────────────────────────────────────────────────────
function getLabel(controlId) {
if (labelCache[controlId]) return labelCache[controlId];
// .field-label 要素から取得
var $field = $('#' + controlId).closest('.field-normal, .field-wide, .field-markdown');
if ($field.length) {
var labelText = $field.find('.field-label label').first().text().trim();
if (labelText) {
labelCache[controlId] = labelText;
return labelText;
}
}
// フォールバック: コントロール ID からプレフィックスを除去
labelCache[controlId] = controlId.replace(prefix, '');
return labelCache[controlId];
}
// ────────────────────────────────────────────────────
// 対象コントロールかどうかの判定
// ────────────────────────────────────────────────────
function isTargetControl(controlId) {
if (controlId.indexOf(prefix) !== 0) return false;
for (var i = 0; i < config.excludeSuffixes.length; i++) {
if (controlId.indexOf(config.excludeSuffixes[i]) >= 0) return false;
}
return true;
}
// ────────────────────────────────────────────────────
// 初期値のスナップショットを取得
// ────────────────────────────────────────────────────
function captureInitialValues() {
initialValues = {};
$('#MainForm').find('[id^="' + prefix + '"]').each(function () {
var $el = $(this);
var id = $el.attr('id');
if (!id || !isTargetControl(id)) return;
// コントロール要素のみ対象
var tag = $el.prop('tagName');
if (tag !== 'INPUT' && tag !== 'SELECT' && tag !== 'TEXTAREA') return;
initialValues[id] = getControlValue($el);
});
}
// ────────────────────────────────────────────────────
// 変更差分の取得
// ────────────────────────────────────────────────────
function getChanges() {
var changes = [];
Object.keys(initialValues).forEach(function (controlId) {
var $control = $('#' + controlId);
if (!$control.length) return;
var current = getControlValue($control);
var initial = initialValues[controlId];
if (current !== initial) {
changes.push({
controlId: controlId,
label: getLabel(controlId),
oldValue: initial,
newValue: current
});
}
});
return changes;
}
// ────────────────────────────────────────────────────
// ハイライトとバッジの更新
// ────────────────────────────────────────────────────
function updateHighlights() {
var changeCount = 0;
Object.keys(initialValues).forEach(function (controlId) {
var $control = $('#' + controlId);
if (!$control.length) return;
var $field = $control.closest('.field-normal, .field-wide, .field-markdown');
if (!$field.length) return;
var current = getControlValue($control);
var initial = initialValues[controlId];
if (current !== initial) {
$field.addClass('fct-changed');
changeCount++;
} else {
$field.removeClass('fct-changed');
}
});
// すべて元に戻った場合は確認ダイアログの判定もリセット
if (changeCount === 0) {
$p.formChanged = false;
}
updateBadge(changeCount);
}
// ────────────────────────────────────────────────────
// バッジの作成と更新
// ────────────────────────────────────────────────────
var $badgeWrap = null;
var $badgeCount = null;
function createBadge() {
$badgeWrap = $('<div>', { class: 'fct-badge-wrap', title: '変更箇所を表示' });
var $icon = $('<span>', { class: 'material-symbols-outlined fct-badge-icon', text: 'edit_note' });
$badgeCount = $('<span>', { class: 'fct-badge-count', text: '0' });
$badgeWrap.append($icon, $badgeCount);
$badgeWrap.on('click', function () {
showPanel();
});
// ツールバー(コマンド領域)に配置
var $toolbar = $('#MainCommandsContainer .ui-widget-header');
if ($toolbar.length) {
$toolbar.append($badgeWrap);
} else {
// フォールバック: MainCommands の末尾
$('#MainCommands').append($badgeWrap);
}
}
function updateBadge(count) {
if (!$badgeCount) return;
$badgeCount.text(count);
if (count > 0) {
$badgeWrap.addClass('has-changes');
} else {
$badgeWrap.removeClass('has-changes');
}
if (!config.showBadgeAlways && count === 0) {
$badgeWrap.hide();
} else {
$badgeWrap.show();
}
}
// ────────────────────────────────────────────────────
// 表示用テキストの整形
// ────────────────────────────────────────────────────
function displayValue(val) {
if (val === null || val === undefined || val === '') {
return { text: config.emptyText, isEmpty: true };
}
var text = val.toString();
if (text.length > 80) {
text = text.substring(0, 80) + '…';
}
return { text: text, isEmpty: false };
}
// ────────────────────────────────────────────────────
// 変更一覧パネルの表示
// ────────────────────────────────────────────────────
function showPanel() {
// 既存パネルがあれば閉じる
closePanel();
var changes = getChanges();
// オーバーレイ
var $overlay = $('<div>', { class: 'fct-panel-overlay' });
$overlay.on('click', closePanel);
// パネル
var $panel = $('<div>', { class: 'fct-panel' });
// ヘッダー
var $header = $('<div>', { class: 'fct-panel-header' });
var $title = $('<div>', { class: 'fct-panel-title' });
$title.append(
$('<span>', { class: 'material-symbols-outlined', text: 'edit_note' }),
$('<span>', { text: '変更箇所(' + changes.length + ' 件)' })
);
var $close = $('<button>', { class: 'fct-panel-close' });
$close.append($('<span>', { class: 'material-symbols-outlined', text: 'close' }));
$close.on('click', closePanel);
$header.append($title, $close);
$panel.append($header);
// ボディ
var $body = $('<div>', { class: 'fct-panel-body' });
if (changes.length === 0) {
$body.append($('<div>', { class: 'fct-empty', text: '変更された項目はありません' }));
} else {
changes.forEach(function (change) {
var $item = $('<div>', { class: 'fct-item' });
// ヘッダー行(ラベル + 元に戻すボタン)
var $itemHeader = $('<div>', { class: 'fct-item-header' });
var $label = $('<span>', { class: 'fct-item-label', text: change.label });
var $revert = $('<button>', { class: 'fct-item-revert', text: '元に戻す' });
$revert.on('click', function () {
revertField(change.controlId);
$item.slideUp(150, function () {
$item.remove();
// カウント更新
var remaining = $body.find('.fct-item').length;
$title.find('span:last').text('変更箇所(' + remaining + ' 件)');
if (remaining === 0) {
$body.append($('<div>', { class: 'fct-empty', text: '変更された項目はありません' }));
}
});
});
$itemHeader.append($label, $revert);
// 差分表示
var $diff = $('<div>', { class: 'fct-diff' });
var oldVal = displayValue(change.oldValue);
var newVal = displayValue(change.newValue);
var $old = $('<span>', {
class: 'fct-diff-old' + (oldVal.isEmpty ? ' fct-diff-empty' : ''),
text: oldVal.text
});
var $arrow = $('<span>', { class: 'fct-diff-arrow material-symbols-outlined', text: 'arrow_forward' });
var $new = $('<span>', {
class: 'fct-diff-new' + (newVal.isEmpty ? ' fct-diff-empty' : ''),
text: newVal.text
});
$diff.append($old, $arrow, $new);
$item.append($itemHeader, $diff);
$body.append($item);
});
}
$panel.append($body);
$('body').append($overlay, $panel);
}
function closePanel() {
$('.fct-panel-overlay').remove();
$('.fct-panel').remove();
}
// ────────────────────────────────────────────────────
// 項目を初期値に戻す
// ────────────────────────────────────────────────────
function revertField(controlId) {
var $control = $('#' + controlId);
if (!$control.length) return;
var initial = initialValues[controlId];
$p.set($control, initial);
$control.trigger('change');
}
// ────────────────────────────────────────────────────
// イベントの登録
// ────────────────────────────────────────────────────
function bindEvents() {
// change イベントで変更を検知(デバウンス付き)
var debounceTimer;
$(document).on(
'change.fct input.fct',
'#MainForm input, #MainForm select, #MainForm textarea',
function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(updateHighlights, 100);
}
);
// AJAX 完了後にハイライトを再評価(タブ切り替え等への対応)
$(document).on('ajaxComplete.fct', function (e, xhr, settings) {
// 更新成功時はスナップショットをリセット
if (xhr.status === 200 && settings.url
&& /\/update(?:$|\?)/.test(settings.url)) {
setTimeout(function () {
captureInitialValues();
updateHighlights();
}, 300);
}
});
}
// ────────────────────────────────────────────────────
// 初期化
// ────────────────────────────────────────────────────
captureInitialValues();
createBadge();
bindEvents();
updateHighlights();
});設定項目
スクリプト先頭の config で動作を変えられます。
| 項目 | 型 | 既定値 | 説明 |
|---|---|---|---|
excludeSuffixes | array | ['_Timestamp', '_Token', '_SwitchRecordsWith'] | 追跡対象から除外するコントロール ID のサフィックス |
showBadgeAlways | boolean | true | true の場合、変更件数が 0 でもバッジを表示する |
emptyText | string | '(空)' | 値が空の場合にパネルに表示するテキスト |
特定のサイトだけに適用したい場合は、config の直後に条件分岐を追加します。
// サイト ID 12345 のみに適用する例
if (!location.pathname.match(/\/items\/12345/)) return;ポイント
| 処理 | 内容 |
|---|---|
| 初期値のスナップショット | captureInitialValues で、[id^="Results_"] のようにテーブル名のプレフィックスに一致する INPUT・SELECT・TEXTAREA の値を記録します。_Timestamp や _Token などシステム内部で使う項目は excludeSuffixes で除外します |
| ハイライトの更新 | change / input イベントで、100ms のデバウンス付きで updateHighlights を呼びます。差分がある項目の .field-normal / .field-wide / .field-markdown 親要素に .fct-changed を付け、差分がなくなれば外します |
| 確認ダイアログとの整合 | 差分が 0 件になったら $p.formChanged = false に戻し、「値はすべて元に戻っているのに離脱確認だけ出る」という不整合を避けます |
| バッジの配置 | #MainCommandsContainer .ui-widget-header(ツールバー領域)に追加します。更新ボタンなどと同じ領域です |
| 元に戻す | $p.set で初期値を復元してから change を発火させるため、ハイライトとバッジが自動で再評価されます |
| 更新成功時のリセット | ajaxComplete で URL が /update で終わるリクエストの成功(HTTP 200)を検知し、300ms 後にスナップショットを取り直します。遅延は更新レスポンスによる画面の再描画を待つためです |
INFO
リンクの作成ボタンを未保存時に制御するにあるとおり、本体は更新成功時に $p.formChanged を false に戻しますが、変更箇所のハイライトやバッジは更新しません。このスクリプトは保存成功時にスナップショットを取り直してハイライトとバッジを更新し、編集中に差分が 0 件になった時点でも $p.formChanged を false に戻します。
注意点
WARNING
マークダウンエディタは内部で textarea と表示用の div が分かれているため、エディタ上で直接編集しても textarea の change が発火しない場合があります。その場合は、タブ移動やフォーカスの移動で変更が検知されます。
WARNING
ファイル添付やリンクテーブルの変更など、INPUT / SELECT / TEXTAREA で管理されないデータは追跡の対象外です。
コメント欄を見やすくする
第 2 世代 UI のコメント欄は、コメントの間が横線で区切られているだけで、淡い背景色と線のグレーが同化しやすく、コントラストの面でも望ましくありません。入力欄と同じように、各コメントに枠線を付けます。

入力欄の枠は角丸 5px・線の色 var(--control-border)(#cecece、既存の横線と同じ色)です。これにならい、コントラストを確保するため色は黒にします。
#Editor #EditorComments .comment {
border-radius: 5px !important;
border: 1px solid #000 !important;
}- 組み込みの CSS を上書きするため
!importantを付けています。 - サイトの管理の「スタイル」に設定するとそのサイトに、拡張スタイルに入れるとすべてのサイトに適用されます。

ラジオボタンとチェックボックスをボタングループにする
分類項目のラジオボタンとチェック項目のチェックボックスを、Bootstrap 5 のボタングループのような見た目にします。ネイティブの input を視覚的に隠し、input を包んでいる label をボタンとしてスタイリングします。配色はテーマのカスタムプロパティを使うため、テーマを切り替えても追従します(ダークテーマの midnight でも、テーマ側の値に合わせて配色が変わります)。拡張スタイルだけで実装でき、スクリプトは不要です。

仕組み
プリザンター v1 のテーマ CSS では、ボタンやコントロールの配色がカスタムプロパティで定義されています(値は cerulean テーマの例)。
| カスタムプロパティ | 用途 | cerulean での値 |
|---|---|---|
--btn-normal-bg | 通常ボタン背景 | #fff |
--btn-normal-label | 通常ボタン文字色 | #222 |
--btn-normal-border | 通常ボタン枠線 | #cecece |
--btn-positive-bg | 選択ボタン背景 | #106ebe |
--btn-positive-label | 選択ボタン文字色 | #fff |
--btn-positive-hover | ホバー時背景 | #005a9e |
--control-border | コントロール枠線 | #cecece |
--control-bg-read | 読取専用コントロールの背景 | テーマごとの値 |
--control-text-read | 読取専用コントロールの文字色 | テーマごとの値 |
編集画面のラジオボタンとチェックボックスは、次の HTML で描画されます。
<!-- 分類項目(ラジオボタン): フィールドには field-wide が付く -->
<div id="Results_ClassAField" class="field-normal field-wide btn-group">
<p class="field-label"><label for="Results_ClassA">分類A</label></p>
<div class="field-control">
<div class="container-normal container-radio">
<input type="hidden" id="Results_ClassA" class="radio-value" value="選択肢1">
<label class="radio-option" for="Results_ClassA0">
<span class="radio-icon">
<input id="Results_ClassA0" name="Results_ClassA" class="control-radio" type="radio" value="選択肢1" checked>
</span>
<span class="radio-text">選択肢1</span>
</label>
<label class="radio-option" for="Results_ClassA1">...</label>
</div>
</div>
</div>
<!-- チェック項目: 項目名は label.check-option の中の span.check-text に入る -->
<div id="Results_CheckAField" class="field-normal btn-toggle">
<div class="field-control">
<div class="container-normal">
<label class="check-option" for="Results_CheckA">
<span class="check-icon">
<input id="Results_CheckA" name="Results_CheckA" class="control-checkbox" type="checkbox">
</span>
<span class="check-text">チェックA</span>
</label>
</div>
</div>
</div>| 要素 | 内容 |
|---|---|
label.radio-option / label.check-option | 選択肢ごと(チェック項目は項目ごと)のラベル。input を内側に含み、for 属性も付いているため、ラベル全体がクリックできる。これをボタンにする |
span.radio-icon / span.check-icon | 丸・四角のアイコンを描く span。中に本来の input がある。視覚的に隠す |
span.radio-text / span.check-text | 選択肢名・項目名。ボタンの文字になる |
input.radio-value | ラジオボタンの値を送信するための隠し項目。選択肢の label より前にある |
input:checked / input:disabled | 選択状態・読取専用。label の内側にあるため、.radio-option:has(input:checked) のように :has() で label 側に反映する |
- ラジオボタンの HTML は HtmlFields.cs と HtmlControls.cs、表示形式がラジオボタンの項目に
field-wideが付くのは HtmlFields.cs で確認できます。 - チェック項目は
p.field-labelを出さず(labelTextを空にして)、項目名をlabel.check-optionの中に入れます(HtmlFields.cs、HtmlControls.cs)。 - 読取専用の項目は、
inputにdisabledが付いて描画されます(HtmlFields.cs)。 - 入力エラーのメッセージ(
label.error)は.container-normalの末尾に追加されます(validator.js)。そのため、先頭・末尾のボタンは:first-child/:last-childではなく:nth-child(1 of .radio-option)/:nth-last-child(1 of .radio-option)で判定します。
INFO
この節の CSS は 1.5.8.1 の HTML 構造(上記)に合わせています。第 2 世代 UI の標準スタイルは span.radio-icon / span.check-icon の中の input を display: none にしていて(style.scss)、そのままではキーボードでフォーカスできません。下の CSS は span の側を視覚的に隠し(1px に縮めて切り抜く)、input は display: block に戻すため、Tab キーでのフォーカスと矢印キー・Space キーでの選択ができます。CSS のネスト、:has()、:nth-child(… of …) を使うため、これらに対応したブラウザ(現行の Chrome / Edge / Firefox / Safari)が必要です。
拡張スタイル
App_Data/Parameters/ExtendedStyles/ に配置します。ラジオボタンは .btn-group、チェックボックスは .btn-toggle の中にルールをまとめている(ネスト)ため、適用範囲を変えるときは外側のセレクタだけを書き換えます(適用範囲の切り替え)。
ExtendedStyles/BtnGroup.css
/* ================================================================
Bootstrap 風ボタングループ(ラジオボタン・チェックボックス共通)
1.5.8.1 の HTML 構造が対象
================================================================ */
/* --- ラジオボタン:ボタングループ化 --- */
.btn-group {
/* 選択肢を隙間なく横並びに */
.container-radio {
display: flex;
flex-wrap: wrap;
gap: 0;
}
/* 入力エラーのメッセージは次の行に表示 */
.container-radio > label.error {
flex-basis: 100%;
}
/* 選択肢(label.radio-option)をボタン化 */
.container-radio .radio-option {
position: relative;
display: inline-flex;
align-items: center;
float: none;
margin: 0;
padding: 6px 16px;
border: 1px solid var(--control-border);
background: var(--btn-normal-bg);
color: var(--btn-normal-label);
cursor: pointer;
line-height: 1.5;
white-space: nowrap;
user-select: none;
transition: background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
/* 隣接ボタンの枠線を重ねる */
.container-radio .radio-option + .radio-option {
margin-left: -1px;
}
/* 先頭だけ左角丸 */
.container-radio .radio-option:nth-child(1 of .radio-option) {
border-radius: 4px 0 0 4px;
}
/* 末尾だけ右角丸 */
.container-radio .radio-option:nth-last-child(1 of .radio-option) {
border-radius: 0 4px 4px 0;
}
/* 選択肢が 1 つだけの場合は両端角丸 */
.container-radio .radio-option:nth-child(1 of .radio-option):nth-last-child(1 of .radio-option) {
border-radius: 4px;
}
/* 丸アイコン(span.radio-icon)を視覚的に隠す(input はフォーカス可能なまま) */
.container-radio .radio-icon {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* 標準スタイルの display: none を戻す */
.container-radio .radio-icon input {
display: block;
margin: 0;
}
/* 選択肢名 */
.container-radio .radio-text {
padding: 0;
line-height: inherit;
}
/* 選択状態 */
.container-radio .radio-option:has(input:checked) {
z-index: 1;
background: var(--btn-positive-bg);
color: var(--btn-positive-label);
border-color: var(--btn-positive-bg);
}
/* ホバー(未選択・編集可能なとき) */
.container-radio .radio-option:not(:has(input:checked, input:disabled)):hover {
z-index: 1;
background: var(--btn-positive-hover);
color: var(--btn-positive-label);
border-color: var(--btn-positive-hover);
}
/* キーボードフォーカス */
.container-radio .radio-option:has(input:focus-visible) {
z-index: 2;
outline: 2px solid var(--btn-positive-bg);
outline-offset: -4px;
}
.container-radio .radio-option:has(input:checked:focus-visible) {
outline-color: var(--btn-positive-label);
}
/* 読取専用(disabled) */
.container-radio .radio-option:has(input:disabled) {
background: var(--control-bg-read);
color: var(--control-text-read);
cursor: default;
}
.container-radio .radio-option:has(input:disabled:checked) {
background: var(--btn-positive-bg);
color: var(--btn-positive-label);
border-color: var(--btn-positive-bg);
opacity: 0.65;
}
}
/* --- チェックボックス:トグルボタン化 --- */
.btn-toggle {
/* 項目(label.check-option)をボタン化 */
.check-option {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 16px;
border: 1px solid var(--control-border);
border-radius: 4px;
background: var(--btn-normal-bg);
color: var(--btn-normal-label);
cursor: pointer;
line-height: 1.5;
user-select: none;
transition: background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
/* 四角アイコン(span.check-icon)を視覚的に隠す(input はフォーカス可能なまま) */
.check-option .check-icon {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* 標準スタイルの display: none を戻す */
.check-option .check-icon input {
display: block;
margin: 0;
}
/* チェック ON */
.check-option:has(input:checked) {
background: var(--btn-positive-bg);
color: var(--btn-positive-label);
border-color: var(--btn-positive-bg);
}
/* ホバー(チェック OFF・編集可能なとき) */
.check-option:not(:has(input:checked, input:disabled)):hover {
background: var(--btn-positive-hover);
color: var(--btn-positive-label);
border-color: var(--btn-positive-hover);
}
/* キーボードフォーカス */
.check-option:has(input:focus-visible) {
outline: 2px solid var(--btn-positive-bg);
outline-offset: -4px;
}
.check-option:has(input:checked:focus-visible) {
outline-color: var(--btn-positive-label);
}
/* 読取専用(disabled) */
.check-option:has(input:disabled) {
background: var(--control-bg-read);
color: var(--control-text-read);
cursor: default;
}
.check-option:has(input:disabled:checked) {
background: var(--btn-positive-bg);
color: var(--btn-positive-label);
border-color: var(--btn-positive-bg);
opacity: 0.65;
}
}ポイントは次のとおりです。
| ポイント | 内容 |
|---|---|
ネイティブの input の隠し方 | display: none や visibility: hidden にするとフォーカスできなくなるため、input を含む span を 1px に縮めて clip-path で切り抜く。スクリーンリーダーからもラジオボタン・チェックボックスとして扱われる |
| 状態の反映 | input が label の内側にあるため、input:checked + label のような隣接セレクタは使えない。label:has(input:checked) で label 側に状態を反映する |
| 枠線の重なり | 隣り合うボタンを margin-left: -1px で重ね、選択中・ホバー中のボタンを z-index で前に出して、枠線の色が欠けないようにする |
| フォーカス | ボタンの内側に枠を描く(outline-offset: -4px)。選択中のボタンでは背景と同じ色にならないよう、枠の色を文字色に切り替える |
| 読取専用 | input:disabled のときはホバーを無効にし、選択中のボタンは薄く(opacity: 0.65)表示する |
項目の設定
サイト管理 → エディタ → 対象項目の詳細設定で、フィールド CSS を設定します。
| 項目 | 設定 |
|---|---|
| 分類項目(ラジオボタン) | コントロール種別を「ラジオボタン」にし、フィールド CSS に btn-group |
| チェック項目 | フィールド CSS に btn-toggle |
チェック項目は、項目名が label.check-option の中にあり、label に for 属性も付いているため、スクリプトで for 属性を補う必要はありません。自動ポストバックなどで要素が再描画されても、同じ HTML が出力されるため CSS はそのまま効きます。
適用範囲の切り替え
| パターン | 説明 | フィールド CSS |
|---|---|---|
| 指定した項目だけ変更(opt-in) | 上記の設定。既存画面に影響しない | btn-group / btn-toggle |
| すべて変更 | 編集画面の全ラジオボタン・チェック項目を一括変換 | 設定不要 |
| 指定した項目だけ除外(opt-out) | 全項目を変換しつつ、除外クラスの項目はスキップ | no-btn-group / no-btn-toggle |
「すべて変更」「指定した項目だけ除外」にする場合は、BtnGroup.css の外側のセレクタ 2 か所(.btn-group { と .btn-toggle {)を、フィールドのクラスに依存しない形に置き換えます。中のルールは変えません。
| 置き換え前 | すべて変更 | 指定した項目だけ除外 |
|---|---|---|
.btn-group | #Editor #MainForm :is(.field-normal, .field-wide):has(.container-radio) | #Editor #MainForm :is(.field-normal, .field-wide):has(.container-radio):not(.no-btn-group) |
.btn-toggle | #Editor #MainForm :is(.field-normal, .field-wide):has(.check-option) | #Editor #MainForm :is(.field-normal, .field-wide):has(.check-option):not(.no-btn-toggle) |
#Editor #MainForm は編集画面の入力フォームです。#MainForm を付けないと、同じ #Editor の中にあるコピーのダイアログ(field-wide のチェックボックスを含む)など、項目以外のチェックボックスまでボタンになります(IssueUtilities.cs、HtmlCopies.cs)。
「すべて変更」の CSS 例
/* --- ラジオボタン:編集画面のすべてのラジオボタンをボタングループ化 --- */
#Editor #MainForm :is(.field-normal, .field-wide):has(.container-radio) {
/* BtnGroup.css の .btn-group { … } の中身(.container-radio { … } から
読取専用のルールまで)をそのまま入れる */
.container-radio {
display: flex;
flex-wrap: wrap;
gap: 0;
}
/* … 以下、BtnGroup.css と同じ … */
}
/* --- チェックボックス:編集画面のすべてのチェック項目をトグルボタン化 --- */
#Editor #MainForm :is(.field-normal, .field-wide):has(.check-option) {
/* BtnGroup.css の .btn-toggle { … } の中身をそのまま入れる */
.check-option {
position: relative;
display: inline-flex;
/* … 以下、BtnGroup.css と同じ … */
}
}「指定した項目だけ除外」の CSS 例
/* --- ラジオボタン:no-btn-group の項目をスキップ --- */
#Editor #MainForm :is(.field-normal, .field-wide):has(.container-radio):not(.no-btn-group) {
/* BtnGroup.css の .btn-group { … } の中身をそのまま入れる */
.container-radio {
display: flex;
flex-wrap: wrap;
gap: 0;
}
/* … 以下、BtnGroup.css と同じ … */
}
/* --- チェックボックス:no-btn-toggle の項目をスキップ --- */
#Editor #MainForm :is(.field-normal, .field-wide):has(.check-option):not(.no-btn-toggle) {
/* BtnGroup.css の .btn-toggle { … } の中身をそのまま入れる */
.check-option {
position: relative;
display: inline-flex;
/* … 以下、BtnGroup.css と同じ … */
}
}TIP
置き換え後のセレクタは .btn-group より詳細度が高くなりますが、中のルール同士の優先順位は変わらないため、選択状態・ホバー・フォーカス・読取専用の見え方は「指定した項目だけ変更」と同じです。
スタイルのバリエーション
フィールド CSS に追加するだけで切り替えられるバリエーションです(例: btn-group btn-group-vertical)。いずれも BtnGroup.css の後ろに追記します。「すべて変更」「指定した項目だけ除外」で使う場合は、先頭の .btn-group を置き換え後のセレクタにし、その後ろに .btn-group-vertical などを続けてください(例: #Editor #MainForm :is(.field-normal, .field-wide):has(.container-radio).btn-group-vertical)。
/* 縦並びボタングループ */
.btn-group.btn-group-vertical {
.container-radio {
display: inline-flex;
flex-direction: column;
align-items: stretch;
}
.container-radio .radio-option + .radio-option {
margin-left: 0;
margin-top: -1px;
}
.container-radio .radio-option:nth-child(1 of .radio-option) {
border-radius: 4px 4px 0 0;
}
.container-radio .radio-option:nth-last-child(1 of .radio-option) {
border-radius: 0 0 4px 4px;
}
.container-radio .radio-option:nth-child(1 of .radio-option):nth-last-child(1 of .radio-option) {
border-radius: 4px;
}
}/* アウトラインスタイル */
.btn-group.btn-group-outline {
.container-radio .radio-option:has(input:checked) {
background: var(--btn-normal-bg);
color: var(--btn-positive-bg);
border-color: var(--btn-positive-bg);
box-shadow: inset 0 0 0 1px var(--btn-positive-bg);
}
/* 背景が白いままなので、フォーカス枠は選択色に戻す */
.container-radio .radio-option:has(input:checked:focus-visible) {
outline-color: var(--btn-positive-bg);
}
}/* ネガティブカラー */
.btn-group.btn-group-danger {
.container-radio .radio-option:has(input:checked) {
background: var(--btn-negative-bg);
color: var(--btn-negative-label);
border-color: var(--btn-negative-bg);
}
.container-radio .radio-option:not(:has(input:checked, input:disabled)):hover {
background: var(--btn-negative-hover);
color: var(--btn-negative-label);
border-color: var(--btn-negative-hover);
}
.container-radio .radio-option:has(input:focus-visible) {
outline-color: var(--btn-negative-bg);
}
.container-radio .radio-option:has(input:checked:focus-visible) {
outline-color: var(--btn-negative-label);
}
}ラジオボタンを読取専用で使う
以前の版では、分類項目の選択肢一覧をラジオボタンで表示している項目を読取専用にすると、ラジオボタン表示ではなくなっていました(1.5.8.1 では読取専用のチェックだけでラジオボタンのまま読取専用になります。後述の tip を参照)。HTML はサーバー側で組み立てられるため、クライアント側だけでは対処できません。そこで、画面ごとに読取専用設定を切り替え、編集画面ではラジオボタンに disabled 属性を付けて読取専用を実現します。

設定
- 読取専用にしたい項目のコントロール CSS に
readonlyを設定し、読取専用のチェックボックスは ON にします。 - サーバースクリプトを 2 つ作成します(項目名は環境に合わせて書き換えてください)。
if(context.Action === 'edit'){
columns.Class009.ReadOnly = false;//項目名は環境に応じて書き換えてください
}if(context.Action === 'edit') {
context.AddResponse(
'Attr',
'input.readonly[type="radio"]',
$ps.JSON.stringify({ Name: 'disabled', Value: '' })
);
}INFO
例の Class009 は Enterprise Edition の項目拡張で追加した項目です。Community Edition では ClassA〜ClassZ が対象なので、項目名を読み替えてください。仕組みはどちらも同じです。
項目ごとにサーバースクリプトとコントロール CSS を設定する必要があります。
1 つ目のスクリプトは、編集画面の表示(context.Action が edit)のときだけ読取専用を解除してラジオボタンとして描画させ、2 つ目のスクリプトでそのラジオボタンに disabled を付けます。更新(update)など編集画面の表示以外の処理では、エディタの設定どおり読取専用のままなので、サーバー側でも値は変更されません。context.Action はルートのアクション名を小文字にした値で(Context.cs)、/items/{id} の既定のアクションは Edit です(Startup.cs)。
INFO
条件を if(context.Action !== 'edit') と書くと、編集画面では読取専用が解除されず(ラジオボタンにならず)、逆に更新時に読取専用が外れてしまうため、=== にしています。
TIP
確認したソースでは、読取専用の項目でも選択肢一覧があり表示形式がラジオボタンなら ControlTypes.Radio として扱われ(HtmlFields.cs)、disabled 付きのラジオボタンとして描画されます(HtmlFields.cs)。この版では、読取専用のチェックボックスを ON にするだけでラジオボタンのまま読取専用にでき、上のサーバースクリプトは不要です。

1.5.8.1 で、サーバースクリプトを設定せずに読取専用のチェックだけを ON にした表示です。
日付項目に和暦入力を追加する
日付項目の入力欄に和暦入力ボタンを追加し、元号・年・月・日を選ぶと西暦の日付が入るモーダルを表示します。標準のカレンダー入力(flatpickr)とは独立して動作するため、どちらの方法でも入力できます。
- 明治・大正・昭和・平成・令和の 5 元号に対応します。
- モーダル内で西暦変換のプレビューをリアルタイムに表示します。
- 元号の範囲を超えた和暦年も変換し、正しい元号の表記を併記します(例: 「昭和100年1月1日」→
→ 2025年1月1日(水)= 令和7年1月1日)。 date-fieldカスタム要素を対象にするため、DateA〜DateZだけでなく、期限付きテーブルのStartTime(開始)・CompletionTime(完了)などエディタ上のすべての日付項目が対象です。

変換の考え方
| 元号 | 略称 | 開始日 | 終了日 |
|---|---|---|---|
| 明治 | M | 1868年1月25日 | 1912年7月29日 |
| 大正 | T | 1912年7月30日 | 1926年12月24日 |
| 昭和 | S | 1926年12月25日 | 1989年1月7日 |
| 平成 | H | 1989年1月8日 | 2019年4月30日 |
| 令和 | R | 2019年5月1日 | 現在 |
西暦年 = 元号の開始西暦年 + 和暦年 - 1- 元号の定義配列は、西暦 → 和暦の変換で先頭からマッチさせるため、新しい元号を先に並べます。
- 和暦 → 西暦の変換では、
Dateコンストラクタの自動補正(例: 2月30日 → 3月2日)を検出してnullを返し、存在しない日付を防ぎます。
有効化モード
スクリプト先頭の WAREKI_MODE で、和暦入力を付ける日付項目の範囲を切り替えます。
| 設定値 | フィールド CSS の設定 | 動作 |
|---|---|---|
'all' | 不要 | すべての日付項目に和暦入力を追加 |
'css' | 対象項目に wareki を設定 | 指定した項目だけに追加 |
'no-css' | 除外項目に no-wareki を設定 | 指定した項目以外に追加 |
設定
図を読み込み中…
WarekiDateInput.cssをApp_Data/Parameters/ExtendedStyles/に配置します。WarekiDateInput.jsをApp_Data/Parameters/ExtendedScripts/に配置します。WarekiDateInput.jsの先頭のWAREKI_MODEを用途に応じて設定します。新元号が制定された場合はERAS配列の先頭に追加します。
ExtendedStyles/WarekiDateInput.css(モーダルと入力部品のスタイル)
/* モーダルオーバーレイ */
#wareki-modal {
display: none;
}
#wareki-modal .wareki-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9998;
}
/* モーダルダイアログ */
#wareki-modal .wareki-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
z-index: 9999;
width: 360px;
max-width: 90vw;
}
#wareki-modal .wareki-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
font-weight: bold;
}
#wareki-modal .wareki-header button {
background: transparent;
border: none;
cursor: pointer;
padding: 0;
display: flex;
}
#wareki-modal .wareki-body {
padding: 20px;
}
#wareki-modal .wareki-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
#wareki-modal .wareki-row-date {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 8px;
}
#wareki-modal .wareki-row label {
min-width: 36px;
font-size: 14px;
}
#wareki-modal .wareki-row-date label {
min-width: auto;
}
#wareki-modal .wareki-row select {
flex: 1;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
#wareki-modal .wareki-row input[type="number"] {
width: 100%;
min-width: 0;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
#wareki-modal .wareki-preview {
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
text-align: center;
font-size: 14px;
margin-bottom: 4px;
min-height: 20px;
}
#wareki-modal .wareki-preview.wareki-error {
color: #c62828;
background: #ffebee;
}
#wareki-modal .wareki-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid #e0e0e0;
}
#wareki-modal .wareki-footer button {
padding: 8px 20px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#wareki-modal .wareki-btn-ok {
background: var(--primaryColor, #1976d2);
color: #fff;
border-color: transparent;
}
#wareki-modal .wareki-btn-ok:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#wareki-modal .wareki-btn-cancel {
background: #fff;
}ExtendedScripts/WarekiDateInput.js(和暦ボタンの追加・モーダル制御・変換処理)
$(function () {
// ┌────────────────────────────────────────┐
// │ マスター設定(環境に合わせて変更) │
// └────────────────────────────────────────┘
// 有効化モード
// 'all' : すべての日付項目に和暦入力を追加
// 'css' : フィールド CSS に wareki を設定した項目だけ
// 'no-css' : フィールド CSS に no-wareki を設定した項目以外
var WAREKI_MODE = 'all';
// 元号定義(新しい元号が先)
var ERAS = [
{ name: '令和', abbr: 'R', start: [2019, 5, 1] },
{ name: '平成', abbr: 'H', start: [1989, 1, 8] },
{ name: '昭和', abbr: 'S', start: [1926, 12, 25] },
{ name: '大正', abbr: 'T', start: [1912, 7, 30] },
{ name: '明治', abbr: 'M', start: [1868, 1, 25] }
];
// ========================================
// 変換関数
// ========================================
function warekiToDate(eraName, eraYear, month, day) {
var era = null;
for (var i = 0; i < ERAS.length; i++) {
if (ERAS[i].name === eraName) { era = ERAS[i]; break; }
}
if (!era) return null;
var y = era.start[0] + eraYear - 1;
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
var d = new Date(y, month - 1, day);
if (d.getFullYear() !== y || d.getMonth() !== month - 1
|| d.getDate() !== day) return null;
return d;
}
function dateToWareki(date) {
if (!(date instanceof Date) || isNaN(date.getTime())) return null;
var y = date.getFullYear();
for (var i = 0; i < ERAS.length; i++) {
var era = ERAS[i];
var eraStart = new Date(era.start[0], era.start[1] - 1, era.start[2]);
if (date >= eraStart) {
var ey = y - era.start[0] + 1;
return era.name + (ey === 1 ? '元' : ey) + '年'
+ (date.getMonth() + 1) + '月' + date.getDate() + '日';
}
}
return null;
}
function dateToEraComponents(date) {
if (!(date instanceof Date) || isNaN(date.getTime())) return null;
var y = date.getFullYear();
for (var i = 0; i < ERAS.length; i++) {
var era = ERAS[i];
var eraStart = new Date(era.start[0], era.start[1] - 1, era.start[2]);
if (date >= eraStart) {
return {
eraName: era.name,
eraYear: y - era.start[0] + 1,
month: date.getMonth() + 1,
day: date.getDate()
};
}
}
return null;
}
function formatDateStr(d) {
var y = d.getFullYear();
var m = ('0' + (d.getMonth() + 1)).slice(-2);
var day = ('0' + d.getDate()).slice(-2);
return y + '/' + m + '/' + day;
}
var WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
function formatPreview(d) {
return d.getFullYear() + '年' + (d.getMonth() + 1) + '月'
+ d.getDate() + '日(' + WEEKDAYS[d.getDay()] + ')';
}
// ========================================
// モーダル HTML 生成
// ========================================
function ensureModal() {
if ($('#wareki-modal').length) return;
var eraOptions = '';
for (var i = 0; i < ERAS.length; i++) {
eraOptions += '<option value="' + ERAS[i].name + '">'
+ ERAS[i].name + '</option>';
}
$('body').append(
'<div id="wareki-modal">'
+ '<div class="wareki-overlay"></div>'
+ '<div class="wareki-dialog">'
+ '<div class="wareki-header">'
+ '<span>和暦入力</span>'
+ '<button type="button" id="wareki-close">'
+ '<span class="material-symbols-outlined">close</span>'
+ '</button>'
+ '</div>'
+ '<div class="wareki-body">'
+ '<div class="wareki-row">'
+ '<label>元号</label>'
+ '<select id="wareki-era">' + eraOptions + '</select>'
+ '</div>'
+ '<div class="wareki-row wareki-row-date">'
+ '<label>年</label>'
+ '<input type="number" id="wareki-year" min="1" max="99" value="1">'
+ '<label>月</label>'
+ '<input type="number" id="wareki-month" min="1" max="12" value="1">'
+ '<label>日</label>'
+ '<input type="number" id="wareki-day" min="1" max="31" value="1">'
+ '</div>'
+ '<div id="wareki-preview" class="wareki-preview"></div>'
+ '</div>'
+ '<div class="wareki-footer">'
+ '<button type="button" class="wareki-btn-cancel" id="wareki-cancel">'
+ 'キャンセル</button>'
+ '<button type="button" class="wareki-btn-ok" id="wareki-ok">'
+ 'OK</button>'
+ '</div>'
+ '</div>'
+ '</div>'
);
$('#wareki-close, .wareki-overlay, #wareki-cancel').on('click', closeModal);
$('#wareki-era, #wareki-year, #wareki-month, #wareki-day')
.on('change input', updatePreview);
$('#wareki-ok').on('click', applyWareki);
}
// ========================================
// モーダル制御
// ========================================
var currentTargetId = null;
function openModal(controlId) {
ensureModal();
currentTargetId = controlId;
// 日付項目の現在値を元号コンポーネントに分解してプリセット
var currentVal = $('#' + controlId).val();
if (currentVal) {
var parts = currentVal.split(/[\/\-]/);
if (parts.length === 3) {
var d = new Date(
parseInt(parts[0], 10),
parseInt(parts[1], 10) - 1,
parseInt(parts[2], 10)
);
var comp = dateToEraComponents(d);
if (comp) {
$('#wareki-era').val(comp.eraName);
$('#wareki-year').val(comp.eraYear);
$('#wareki-month').val(comp.month);
$('#wareki-day').val(comp.day);
}
}
}
updatePreview();
$('#wareki-modal').show();
}
function closeModal() {
$('#wareki-modal').hide();
currentTargetId = null;
}
function updatePreview() {
var eraName = $('#wareki-era').val();
var eraYear = parseInt($('#wareki-year').val(), 10);
var month = parseInt($('#wareki-month').val(), 10);
var day = parseInt($('#wareki-day').val(), 10);
var $preview = $('#wareki-preview');
var $ok = $('#wareki-ok');
if (isNaN(eraYear) || isNaN(month) || isNaN(day)) {
$preview.text('年・月・日を入力してください')
.addClass('wareki-error');
$ok.prop('disabled', true);
return;
}
var d = warekiToDate(eraName, eraYear, month, day);
if (!d) {
$preview.text('無効な日付です').addClass('wareki-error');
$ok.prop('disabled', true);
return;
}
// 正しい元号での和暦表記を併記(元号範囲外の自動変換)
var text = '→ ' + formatPreview(d);
var correctWareki = dateToWareki(d);
if (correctWareki) {
text += '= ' + correctWareki;
}
$preview.text(text).removeClass('wareki-error');
$ok.prop('disabled', false);
}
function applyWareki() {
if (!currentTargetId) return;
var eraName = $('#wareki-era').val();
var eraYear = parseInt($('#wareki-year').val(), 10);
var month = parseInt($('#wareki-month').val(), 10);
var day = parseInt($('#wareki-day').val(), 10);
var d = warekiToDate(eraName, eraYear, month, day);
if (!d) return;
var dateStr = formatDateStr(d);
var $target = $('#' + currentTargetId);
$target.val(dateStr).trigger('change');
closeModal();
}
// ========================================
// 日付項目への和暦ボタン追加
// ========================================
function ensureDateFieldButtonStyle(dateFieldEl) {
if (!dateFieldEl || !dateFieldEl.shadowRoot) return;
if (dateFieldEl.shadowRoot.querySelector('#wareki-btn-style')) return;
var style = document.createElement('style');
style.id = 'wareki-btn-style';
style.textContent = [
'.field-date { position: relative; }',
'.wareki-date-btn {',
' position: absolute;',
' top: 0;',
' right: 24px;',
' z-index: 2;',
' display: flex;',
' align-items: center;',
' justify-content: center;',
' width: 24px;',
' height: 100%;',
' margin: 0;',
' padding: 0;',
' color: var(--base-text);',
' background: transparent;',
' border: none;',
' outline: none;',
' cursor: pointer;',
'}',
'.wareki-date-btn .material-symbols-sharp {',
" font-family: 'Material Symbols outlined';",
" font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;",
' font-size: 18px;',
' line-height: 1;',
'}',
'.wareki-date-btn:hover {',
' color: var(--primaryColor, #1976d2);',
'}',
'.input-date ::slotted(input),',
'.input-date ::slotted(textarea),',
'.input-date ::slotted(select) {',
' padding-right: 48px !important;',
'}'
].join('\n');
dateFieldEl.shadowRoot.appendChild(style);
}
function findDateFields() {
var $fields;
switch (WAREKI_MODE) {
case 'css':
$fields = $('.wareki').find('date-field');
break;
case 'no-css':
$fields = $('date-field').filter(function () {
return !$(this).closest('.no-wareki').length;
});
break;
default: // 'all'
$fields = $('date-field');
break;
}
return $fields;
}
function setupWarekiFields() {
findDateFields().each(function () {
var $dateField = $(this);
var $field = $dateField.closest('[id$="Field"]');
if (!$field.length) return;
// 日付コントロールの input を取得
var $control = $dateField.find('input[type="text"]');
if (!$control.length) return;
var controlId = $control.attr('id');
if (!controlId) return;
var dateFieldEl = $dateField[0];
if (!dateFieldEl || !dateFieldEl.shadowRoot) return;
// 二重初期化を防止
if (dateFieldEl.shadowRoot.querySelector('.wareki-date-btn')) return;
ensureDateFieldButtonStyle(dateFieldEl);
var currentBtn = dateFieldEl.shadowRoot.querySelector('.current-date');
if (!currentBtn || !currentBtn.parentNode) return;
var warekiBtn = document.createElement('button');
warekiBtn.type = 'button';
warekiBtn.className = 'wareki-date-btn';
warekiBtn.setAttribute('title', '和暦で入力');
var iconSpan = document.createElement('span');
iconSpan.className = 'material-symbols-sharp';
iconSpan.setAttribute('aria-hidden', 'true');
iconSpan.textContent = 'swap_horiz';
warekiBtn.appendChild(iconSpan);
warekiBtn.addEventListener('click', function (e) {
e.preventDefault();
openModal(controlId);
});
// current-date ボタンの左側に挿入
currentBtn.parentNode.insertBefore(warekiBtn, currentBtn);
});
}
// ========================================
// 初期化と Ajax 再読み込み対応
// ========================================
var debounceTimer;
$(document).ajaxComplete(function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(setupWarekiFields, 100);
});
setupWarekiFields();
});ポイント
変換アイコンをクリックしてからの処理は次のとおりです。
図を読み込み中…
- 和暦ボタンは
date-fieldの Shadow DOM 内、current-dateボタンの左側に差し込みます。そのため、ボタン用のスタイルは拡張スタイルではなく、スクリプトから Shadow DOM 内に注入しています(拡張スタイルはモーダルと入力部品の見た目を担当します)。 - モーダルを開くと、日付項目の現在値から元号・年・月・日を逆算してプリセットします。OK を押すと
YYYY/MM/DD形式で値をセットし、changeを発火させます。 - Ajax による再描画に備え、
ajaxCompleteで(100ms のデバウンス付きで)再セットアップします。Shadow DOM 内に既にボタンがあれば二重に追加しません。
WARNING
ExtendedScripts / ExtendedStyles フォルダーのファイルは、パラメータの読み込み時にまとめてメモリへ読み込まれ(Initializer.cs、Initializer.cs)、画面にはそのメモリ上の内容が出力されます。そのため、ファイルを配置・変更しただけでは反映されません。プリザンターを再起動するか、特権ユーザーでパラメータ再読み込み(/admins/reloadparameters)を実行してください(ParametersInitializer.cs)。
選択したレコードを一括復元する
編集画面の「変更履歴の一覧」タブでは 1 件ずつしか復元できません。インポートミスや一括更新の失敗で複数レコードを戻せるよう、一覧画面に「一括復元」ボタンを追加し、選択したレコードをそれぞれ「1 つ前の履歴バージョン」に復元します。
WARNING
バージョン 1.4.0.0 以降を対象にしています。
通常の復元の仕組み
編集画面の変更履歴タブで「復元」を押すと、RestoreFromHistory アクションが呼ばれます(IssueUtilities.cs)。
図を読み込み中…
POST /items/{id}/restoreFromHistoryに、フォームデータGridCheckedItems={ver}でバージョン番号(レコード ID ではない)を渡します。VerUp = trueで更新するため、復元前の現在値も新しい履歴バージョンとして自動保存されます。
つまり「現在の状態を履歴に残しつつ、過去バージョンで上書きする」操作です。一括復元ではこのエンドポイントをレコードごとに直接呼ぶため、通常の復元処理(VerUp・添付ファイルの復元・通知・AfterUpdate トリガー)をそのまま踏襲できます。
処理の流れ
$p.selectedIds()で選択されたレコード ID を取得します(未選択なら警告を表示して終了)。- 選択 ID を CSV にして拡張 SQL(
Api: true)を/api/extended/sqlで実行し、各レコードの復元対象バージョンRestoreVer(MAX(Ver) - 1)を取得します。 - 確認ダイアログ(復元可能件数・履歴なし件数)を表示します。
- 各レコードに対して
RestoreFromHistoryを 1 件ずつ順番に呼び出します。 - 完了メッセージを表示し、一覧を更新します。
図を読み込み中…
前提条件
App_Data/Parameters/History.json の Restore を true にします(1.5.8.1 の既定値は true)。false の場合、RestoreFromHistory は InvalidRequest エラーを返します(IssueUtilities.cs)。
{
"Restore": true,
"PhysicalDelete": true
}WARNING
パラメータファイルの変更後はプリザンターの再起動が必要です。パラメータ再読み込み機能を使う場合は、特権ユーザでログインして実行してください。
また、テーブルの管理で「履歴の復元」が無効(AllowRestoreHistories が false)だと動作しません。既定は有効です。
配置ファイル
| ファイル | 種別 | 配置先 | 役割 |
|---|---|---|---|
BulkRestoreHistoryLatestByIds.json | 拡張 SQL | App_Data/Parameters/ExtendedSqls/ | API 公開設定 |
BulkRestoreHistoryLatestByIds.json.sql | 拡張 SQL | App_Data/Parameters/ExtendedSqls/ | 選択 ID に限定して復元対象バージョンを取得 |
BulkRestoreHistory.js | 拡張スクリプト | App_Data/Parameters/ExtendedScripts/ | 選択 ID の取得・API 呼び出し・復元処理 |
BulkRestoreHistoryButton.json | 拡張サーバースクリプト | App_Data/Parameters/ExtendedServerScripts/ | ボタン追加(設定) |
BulkRestoreHistoryButton.json.js | 拡張サーバースクリプト | App_Data/Parameters/ExtendedServerScripts/ | ボタン追加(本体) |
拡張 SQL
{
"Name": "BulkRestoreHistoryLatestByIds",
"Api": true
}CommandText は同名の .json.sql に分離しています。.json.sql があると、起動時に読み込まれて CommandText にセットされます(Initializer.cs)。
SELECT IssueId AS ReferenceId, MAX(Ver) - 1 AS RestoreVer
FROM Issues_history
WHERE SiteId = @SiteId
AND SiteId IN (SELECT SiteId FROM Sites WHERE TenantId = @_T)
AND IssueId IN (
SELECT TRY_CAST([value] AS bigint)
FROM STRING_SPLIT(@SelectedIds, ',')
WHERE TRY_CAST([value] AS bigint) IS NOT NULL
)
GROUP BY IssueId
HAVING MAX(Ver) - 1 > 0
UNION ALL
SELECT ResultId AS ReferenceId, MAX(Ver) - 1 AS RestoreVer
FROM Results_history
WHERE SiteId = @SiteId
AND SiteId IN (SELECT SiteId FROM Sites WHERE TenantId = @_T)
AND ResultId IN (
SELECT TRY_CAST([value] AS bigint)
FROM STRING_SPLIT(@SelectedIds, ',')
WHERE TRY_CAST([value] AS bigint) IS NOT NULL
)
GROUP BY ResultId
HAVING MAX(Ver) - 1 > 0SELECT "IssueId" AS "ReferenceId", MAX("Ver") - 1 AS "RestoreVer"
FROM "Issues_history"
WHERE "SiteId" = @SiteId
AND "SiteId" IN (SELECT "SiteId" FROM "Sites" WHERE "TenantId" = @ipT)
AND CAST("IssueId" AS text) = ANY (string_to_array(@SelectedIds, ','))
GROUP BY "IssueId"
HAVING MAX("Ver") - 1 > 0
UNION ALL
SELECT "ResultId" AS "ReferenceId", MAX("Ver") - 1 AS "RestoreVer"
FROM "Results_history"
WHERE "SiteId" = @SiteId
AND "SiteId" IN (SELECT "SiteId" FROM "Sites" WHERE "TenantId" = @ipT)
AND CAST("ResultId" AS text) = ANY (string_to_array(@SelectedIds, ','))
GROUP BY "ResultId"
HAVING MAX("Ver") - 1 > 0SELECT IssueId AS ReferenceId, MAX(Ver) - 1 AS RestoreVer
FROM Issues_history
WHERE SiteId = @SiteId
AND SiteId IN (SELECT SiteId FROM Sites WHERE TenantId = @ipT)
AND FIND_IN_SET(IssueId, @SelectedIds) > 0
GROUP BY IssueId
HAVING MAX(Ver) - 1 > 0
UNION ALL
SELECT ResultId AS ReferenceId, MAX(Ver) - 1 AS RestoreVer
FROM Results_history
WHERE SiteId = @SiteId
AND SiteId IN (SELECT SiteId FROM Sites WHERE TenantId = @ipT)
AND FIND_IN_SET(ResultId, @SelectedIds) > 0
GROUP BY ResultId
HAVING MAX(Ver) - 1 > 0SQL ファイル名はいずれも ExtendedSqls/BulkRestoreHistoryLatestByIds.json.sql です。
- 拡張 SQL は動的な IN 句の組み立てが苦手なため、CSV 文字列の
@SelectedIdsを分割して選択 ID に限定しています。SQL Server はSTRING_SPLIT、PostgreSQL はstring_to_arrayと= ANY、MySQL はFIND_IN_SETを使います。 - PostgreSQL では列名・テーブル名・別名を二重引用符で囲みます。囲まないと小文字の名前として扱われ、テーブルが見つからないうえ、結果の列名も
referenceidになって拡張スクリプトのrows[i].ReferenceIdで読めません。 - 期限付きテーブル・記録テーブルの両方を
ReferenceIdに統一して、同じマップで扱います。 SiteId = @SiteIdで対象サイトを固定します。SiteIdはブラウザーから送られる値なので、SiteId IN (SELECT SiteId FROM Sites WHERE TenantId = @_T)(PostgreSQL・MySQL では@ipT)でログインユーザーのテナントのサイトに限定しています。Issues_history/Results_historyにはTenantId列が無いため(テナント ID を持つのはSitesテーブル)、Sitesを経由して絞り込みます。
INFO
拡張 SQL の実行時には、ログインユーザーのテナント ID・組織 ID・ユーザー ID が @_T・@_D・@_U(PostgreSQL・MySQL では @ipT・@ipD・@ipU)として自動で渡されるため(接頭辞は Parameter.json の SqlParameterPrefix が空の既定のとき。SqlIo.cs、Parameter.cs)、上の SQL ではこれを使ってテナントの条件を追加しています。なお、復元そのものは RestoreFromHistory がレコードごとに権限を確認するため、この SQL が返すのは復元対象のバージョン番号だけです。
拡張スクリプト
ExtendedScripts/BulkRestoreHistory.js
// 一括復元スクリプト
// プレフィックス brh_ (BulkRestoreHistory) で名前空間を分離
function brh_getLatestMapBySelectedIds(ids) {
var deferred = $.Deferred();
var siteId = $p.siteId() || 0;
var selectedIdsCsv = ids.join(',');
$p.apiExec('/api/extended/sql', {
data: {
Name: 'BulkRestoreHistoryLatestByIds',
Params: {
SelectedIds: selectedIdsCsv,
SiteId: siteId
}
}
}).done(function(res) {
var map = {};
var rows = ((((res || {}).Response || {}).Data || {}).Table) || [];
for (var i = 0; i < rows.length; i++) {
var id = String(rows[i].ReferenceId || '').trim();
var ver = parseInt(rows[i].RestoreVer, 10);
if (id !== '' && ver > 0) map[id] = ver;
}
deferred.resolve(map);
}).fail(function(xhr) {
deferred.reject('復元対象版の取得に失敗しました(HTTP ' + xhr.status + ')');
});
return deferred.promise();
}
function brh_showDialog(ids, latestMap) {
var available = ids.filter(function(id) {
var key = String(id || '').trim();
return Object.prototype.hasOwnProperty.call(latestMap, key);
}).length;
var missing = ids.length - available;
var $dialog = $('<div title="一括復元"></div>');
$dialog.append(
'<p style="margin:0 0 12px">'
+ '選択した <strong>' + ids.length + ' 件</strong> のレコードを1つ前の履歴で一括復元します。'
+ '</p>'
+ '<p style="margin:0 0 8px;font-size:0.9em;color:#666">'
+ '復元前の現在値は新しいバージョンとして履歴に自動保存されます。'
+ '</p>'
+ '<p style="margin:0;font-size:0.9em;color:#666">'
+ '復元可能: ' + available + ' 件 / 履歴なし: ' + missing + ' 件'
+ '</p>'
);
$dialog.dialog({
modal: true,
width: 440,
open: function() {
$(this).css('margin-bottom', 0);
$(this).next('.ui-dialog-buttonpane').css('margin-top', 0);
},
buttons: {
'復元を実行': function() {
$(this).dialog('close');
$(this).remove();
brh_restoreAll(ids, latestMap);
},
'キャンセル': function() {
$(this).dialog('close');
$(this).remove();
}
}
});
}
function brh_isRestoreSuccess(res) {
var arr = Array.isArray(res) ? res : (res ? [res] : []);
return arr.some(function(r) { return r.Method === 'Href'; });
}
function brh_getErrorText(res) {
var arr = Array.isArray(res) ? res : (res ? [res] : []);
for (var i = 0; i < arr.length; i++) {
if (arr[i].Method === 'Message') {
try {
var msg = typeof arr[i].Value === 'string'
? JSON.parse(arr[i].Value) : arr[i].Value;
if (msg && msg.Css === 'alert-error') return msg.Text;
} catch (e) { /* ignore */ }
}
}
return null;
}
function brh_restoreAll(ids, latestMap) {
var total = ids.length;
var successes = 0;
var failedIds = [];
var index = 0;
var csrfToken = $('meta[name="csrf-token"]').attr('content') || '';
var requestToken = $('#Token').val() || '';
var headers = {};
if (csrfToken) {
headers['X-CSRF-TOKEN'] = csrfToken;
}
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-information',
Text: '復元処理中... 0 / ' + total + ' 件'
}));
function restoreNext() {
if (index >= total) {
brh_showResult(successes, failedIds);
return;
}
var id = ids[index++];
var key = String(id || '').trim();
var hasHistory = Object.prototype.hasOwnProperty.call(latestMap, key);
var ver = latestMap[key];
if (!hasHistory) {
failedIds.push({ id: id, msg: '履歴が存在しません' });
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-information',
Text: '復元処理中... ' + index + ' / ' + total + ' 件'
}));
restoreNext();
return;
}
var postData = { GridCheckedItems: ver.toString() };
if (requestToken) {
postData.Token = requestToken;
}
$.ajax({
url: '/items/' + id + '/restoreFromHistory',
method: 'POST',
data: postData,
headers: headers
}).done(function(res) {
var json = typeof res === 'string' ? JSON.parse(res) : res;
if (brh_isRestoreSuccess(json)) {
successes++;
} else {
var errText = brh_getErrorText(json) || '不明なエラー';
failedIds.push({ id: id, msg: errText });
}
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-information',
Text: '復元処理中... ' + index + ' / ' + total + ' 件'
}));
restoreNext();
}).fail(function(xhr) {
failedIds.push({ id: id, msg: 'HTTP ' + xhr.status });
restoreNext();
});
}
restoreNext();
}
function brh_showResult(successes, failedIds) {
var msg;
if (failedIds.length === 0) {
msg = { Css: 'alert-success', Text: successes + ' 件のレコードを復元しました' };
} else {
var failSummary = failedIds.map(function(f) { return 'ID ' + f.id + '(' + f.msg + ')'; }).join(', ');
msg = {
Css: 'alert-warning',
Text: successes + ' 件を復元しました(失敗 ' + failedIds.length + ' 件: ' + failSummary + ')'
};
}
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify(msg));
// 一覧を先頭から再読み込み(第 4 引数 false でメッセージを消さない)
$('#GridOffset').val('0');
$p.setData($('#GridOffset'));
$p.send($('#Grid'), undefined, true, false);
}
function brh_bulkRestoreHistory() {
var ids = $p.selectedIds();
if (!ids || ids.length === 0) {
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-warning',
Text: 'レコードが選択されていません'
}));
return;
}
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-information',
Text: '復元対象版を取得中...'
}));
brh_getLatestMapBySelectedIds(ids)
.done(function(latestMap) {
brh_showDialog(ids, latestMap);
})
.fail(function(errMsg) {
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-error',
Text: errMsg
}));
});
}拡張サーバースクリプト(ボタン追加)
{
"BeforeOpeningPage": true,
"Actions": ["index"],
"TryCatch": true,
"Body": "-- loaded from .json.js"
}context.AddResponse(
'Append',
'#MainCommands:has(#BulkDeleteCommand)',
'<button id="BulkRestoreHistoryCommand" class="button button-icon" type="button"'
+ ' onclick="brh_bulkRestoreHistory()">'
+ '<span class="ui-icon ui-icon-arrowreturnthick-1-n"></span>'
+ '<span>一括復元</span>'
+ '</button>'
);Actionsを["index"]に限定して、一覧画面にだけボタンを表示します。SiteIdListを省略するとすべてのサイトが対象です。特定のテーブルに限定する場合は"SiteIdList": [12345]を追加します。- 一括削除ボタン(
#BulkDeleteCommand)は削除権限がある場合だけ描画されるため、:has()でその有無を確認し、間接的な権限チェックにしています。更新権限で制御したい場合は#OpenBulkUpdateSelectorDialogCommand(一括更新ボタン)に変えます。
ポイント
$p.apiExecを使うと、#Tokenがある場合は自動でリクエストに付与されます。この API はExtendedController.csからExtensionUtilities.Sqlを経由して拡張 SQL を実行します(ExtendedController.cs)。restoreFromHistoryは/api配下ではないため、Tokenを明示的に送信しています。meta[name="csrf-token"]はフォーム機能の画面でのみ出力されるため一覧画面では取得できない場合があり、ある場合だけX-CSRF-TOKENヘッダーを付けています(Startup.cs)。RestoreFromHistoryは成功・失敗どちらでも HTTP 200 を返します。成功時はレスポンスにMethod: "Href"、失敗時はMethod: "Message"(Css: "alert-error")が含まれるので、これで判定し、失敗したレコードの ID とエラー内容を報告します。- サーバーへの同時接続数を抑えるため、
restoreNextを再帰的に呼んで 1 件ずつ直列に処理しています。 - 完了後の一覧の再読み込みは、スクロール時の追加読み込みと同じ仕組みを使っています。
#GridOffsetを0にして#Grid(data-action="GridRows")を送信すると、サーバーは既存の行を消して先頭から描画し直します(scroll.js、IssueUtilities.cs)。$p.send($('#ViewFilters\\,Search'))と書いても、1.5.8.1 のソースにはこの ID の要素が無く、何も送信されません。また、サイト ID は$p.SiteId(存在しません)ではなく$p.siteId()で取得します。