Skip to content

編集画面のカスタマイズ ​

第13版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

編集画面(および関連する一覧画面の操作)を使いやすくするカスタマイズをまとめます。いずれも本体を改修せず、スタイル・サーバースクリプト・拡張機能(拡張スタイル、拡張スクリプト、拡張サーバースクリプト、拡張 SQL)で実装できます。

カスタマイズ使う機能
項目の物理名を表示する(開発者向けのトグル表示を含む)スタイル / 拡張スタイル(トグルは + 拡張スクリプト)
自動ポストバックが有効な項目を示す拡張スタイル
リンクの作成ボタンを未保存時に制御する拡張スクリプト
リンク一覧を別タブで開く(一覧ごと / レコードごと)拡張スクリプト + 拡張スタイル
誤って閉じたページの入力データを復旧する拡張スクリプト + 拡張スタイル
変更箇所を可視化する拡張スクリプト + 拡張スタイル
コメント欄を見やすくするスタイル / 拡張スタイル
ラジオボタンとチェックボックスをボタングループにする拡張スタイル
ラジオボタンを読取専用で使うサーバースクリプト
日付項目に和暦入力を追加する拡張スクリプト + 拡張スタイル
選択したレコードを一括復元する拡張スクリプト + 拡張サーバースクリプト + 拡張 SQL

項目の物理名を表示する ​

スクリプトやサーバースクリプトで必要になる項目の物理名(ClassA など)を、編集画面のラベルの後ろに表示します。開発者ツールで要素を調べなくても、画面を見るだけで物理名が分かります。

仕組み ​

編集画面の各項目は #(テーブル種別)_(物理名)Field という ID の要素(例: Results_TitleField)で囲まれ、その中の p.field-label label が項目名です。この外側の ID を CSS の attr() で取り出し、疑似要素 ::after で表示します。

TIP

ラベルの for 属性(attr(for))を使う方法もありますが、for を持たない項目(添付ファイルなど)でも疑似要素が出てしまい、読み取り専用項目では表示されません。外側の ID を使う次の方法なら、すべての項目に表示できます。

設定 ​

スタイル(または拡張スタイル)に次の CSS を設定します。

css
[id$="Field"] {
    --field-name: attr(id);
    p.field-label label::after {
        content: ' (' var(--field-name) ')';
        color: gray
    }
}

編集画面のラベルの下に (Issues_TitleField) のような ID が表示された様子

表示するユーザーを限定する ​

拡張スタイルなどにそのまま入れると全ユーザーに表示されます。$p.userId の値が入っている #UserId 要素の値で条件を付けると、特定のユーザー(例ではユーザー ID 3)がログインしているときだけ表示できます。

css
: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
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;
}
js
// ===================================================================
//  項目物理名トグル表示 – 拡張スクリプト
// ===================================================================

// ---- 設定 ----------------------------------------------------------
// 物理名トグルボタンを表示する開発者ユーザーの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 クラスが付きます。自動ポストバックが送る内容と、項目連携・画面の項目での絞り込みとの関係は 項目連携・自動ポストバック・画面項目での絞り込みの関係 にまとめています。

html
<select id="Results_ClassA" name="Results_ClassA" class="control-dropdown control-auto-postback">

必須項目の赤いアスタリスクのデザインを流用し、:has() セレクタで対象のフィールドのラベルに疑似要素を追加します。チェックボックスはラベルの構造が異なるため、別のルールにしています。

設定 ​

次の CSS を拡張スタイルとして組み込みます。

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)。

html
<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/ に配置します。

js
$(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();
});
js
$(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();
});
js
$(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);
});
js
$(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" で区別できます。

html
<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 ファイルを配置します。

css
.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;
}
js
$(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)。

html
<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)。

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 ファイルを配置します。

js
$(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
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)。

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(通知バーのスタイル)
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;
}
js
$(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);
  }
});

設定項目とポイント ​

項目型既定値説明
storageKeystring'FormBackup'localStorage で使用するキーのプレフィックス
maxAgeHoursnumber24バックアップの有効期間(時間単位)。0 で無期限
  • 保存キーにページのパス(location.pathname)を含めるため、レコードごとに個別にバックアップされます。
  • 復旧時はレコード ID・有効期限・更新日時の 3 段階でチェックします。
  • 復旧後に $p.formChanged = true とするため、ページを離れる際の確認ダイアログが有効になり、復旧内容の保存忘れを防ぎます。
  • 更新リクエスト(URL が /update で終わる)が成功したら、ajaxComplete でバックアップを削除します。
  • 通知バーのアイコンは、プリザンターに標準で読み込まれている Material Symbols を使います。

特定のサイトだけに適用したい場合は、config の直後に条件分岐を追加します。

js
// サイト 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)。

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(ハイライト・バッジ・パネルのスタイル)
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(変更検知・パネル表示ロジック)
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 で動作を変えられます。

項目型既定値説明
excludeSuffixesarray['_Timestamp', '_Token', '_SwitchRecordsWith']追跡対象から除外するコントロール ID のサフィックス
showBadgeAlwaysbooleantruetrue の場合、変更件数が 0 でもバッジを表示する
emptyTextstring'(空)'値が空の場合にパネルに表示するテキスト

特定のサイトだけに適用したい場合は、config の直後に条件分岐を追加します。

js
// サイト 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、既存の横線と同じ色)です。これにならい、コントラストを確保するため色は黒にします。

css
#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 で描画されます。

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
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 例
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 例
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)。

css
/* 縦並びボタングループ */
.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;
  }
}
css
/* アウトラインスタイル */
.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);
  }
}
css
/* ネガティブカラー */
.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 属性を付けて読取専用を実現します。

読取専用にするとラジオボタンでなくなる例(以前の版。1.5.8.1 ではこうならない)

設定 ​

  1. 読取専用にしたい項目のコントロール CSS に readonly を設定し、読取専用のチェックボックスは ON にします。
  2. サーバースクリプトを 2 つ作成します(項目名は環境に合わせて書き換えてください)。
js
if(context.Action === 'edit'){
    columns.Class009.ReadOnly = false;//項目名は環境に応じて書き換えてください
}
js
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(完了)などエディタ上のすべての日付項目が対象です。

日付項目の横の和暦ボタンから開く「和暦入力」ダイアログ(令和 8 年 9 月 17 日)

変換の考え方 ​

元号略称開始日終了日
明治M1868年1月25日1912年7月29日
大正T1912年7月30日1926年12月24日
昭和S1926年12月25日1989年1月7日
平成H1989年1月8日2019年4月30日
令和R2019年5月1日現在
text
西暦年 = 元号の開始西暦年 + 和暦年 - 1
  • 元号の定義配列は、西暦 → 和暦の変換で先頭からマッチさせるため、新しい元号を先に並べます。
  • 和暦 → 西暦の変換では、Date コンストラクタの自動補正(例: 2月30日 → 3月2日)を検出して null を返し、存在しない日付を防ぎます。

有効化モード ​

スクリプト先頭の WAREKI_MODE で、和暦入力を付ける日付項目の範囲を切り替えます。

設定値フィールド CSS の設定動作
'all'不要すべての日付項目に和暦入力を追加
'css'対象項目に wareki を設定指定した項目だけに追加
'no-css'除外項目に no-wareki を設定指定した項目以外に追加

設定 ​

図を読み込み中…

  1. WarekiDateInput.css を App_Data/Parameters/ExtendedStyles/ に配置します。
  2. WarekiDateInput.js を App_Data/Parameters/ExtendedScripts/ に配置します。
  3. WarekiDateInput.js の先頭の WAREKI_MODE を用途に応じて設定します。新元号が制定された場合は ERAS 配列の先頭に追加します。
ExtendedStyles/WarekiDateInput.css(モーダルと入力部品のスタイル)
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(和暦ボタンの追加・モーダル制御・変換処理)
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)。

図を読み込み中…

  1. POST /items/{id}/restoreFromHistory に、フォームデータ GridCheckedItems={ver} でバージョン番号(レコード ID ではない)を渡します。
  2. VerUp = true で更新するため、復元前の現在値も新しい履歴バージョンとして自動保存されます。

つまり「現在の状態を履歴に残しつつ、過去バージョンで上書きする」操作です。一括復元ではこのエンドポイントをレコードごとに直接呼ぶため、通常の復元処理(VerUp・添付ファイルの復元・通知・AfterUpdate トリガー)をそのまま踏襲できます。

処理の流れ ​

  1. $p.selectedIds() で選択されたレコード ID を取得します(未選択なら警告を表示して終了)。
  2. 選択 ID を CSV にして拡張 SQL(Api: true)を /api/extended/sql で実行し、各レコードの復元対象バージョン RestoreVer(MAX(Ver) - 1)を取得します。
  3. 確認ダイアログ(復元可能件数・履歴なし件数)を表示します。
  4. 各レコードに対して RestoreFromHistory を 1 件ずつ順番に呼び出します。
  5. 完了メッセージを表示し、一覧を更新します。

図を読み込み中…

前提条件 ​

App_Data/Parameters/History.json の Restore を true にします(1.5.8.1 の既定値は true)。false の場合、RestoreFromHistory は InvalidRequest エラーを返します(IssueUtilities.cs)。

json
{
    "Restore": true,
    "PhysicalDelete": true
}

WARNING

パラメータファイルの変更後はプリザンターの再起動が必要です。パラメータ再読み込み機能を使う場合は、特権ユーザでログインして実行してください。

また、テーブルの管理で「履歴の復元」が無効(AllowRestoreHistories が false)だと動作しません。既定は有効です。

配置ファイル ​

ファイル種別配置先役割
BulkRestoreHistoryLatestByIds.json拡張 SQLApp_Data/Parameters/ExtendedSqls/API 公開設定
BulkRestoreHistoryLatestByIds.json.sql拡張 SQLApp_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 ​

json
{
    "Name": "BulkRestoreHistoryLatestByIds",
    "Api": true
}

CommandText は同名の .json.sql に分離しています。.json.sql があると、起動時に読み込まれて CommandText にセットされます(Initializer.cs)。

sql
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 > 0
sql
SELECT "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 > 0
sql
SELECT 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 > 0

SQL ファイル名はいずれも 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
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
      }));
    });
}

拡張サーバースクリプト(ボタン追加) ​

json
{
    "BeforeOpeningPage": true,
    "Actions": ["index"],
    "TryCatch": true,
    "Body": "-- loaded from .json.js"
}
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() で取得します。

関連ページ ​

変更履歴

第13版記事の確認版を繰り返す表現を整理する
第12版履歴の一括復元用 SQL を3種類のDBMSに対応
第11版「編集画面のカスタマイズ」の画像をこのサイトで配信するようにする
第10版編集画面のレシピにスクリーンショットを追加
第9版編集画面のレシピに、自動ポストバックの仕組みへのリンクを追加
第8版「拡張機能」「画面カスタマイズ集」に対応バージョンを表示
第7版本文から元記事や以前の版への言及を除き、正しい動作だけを書く形に整理
第6版ボタングループ化の CSS を 1.5.8.1 のラジオボタン・チェックボックスの構造に合わせて書き直し
第5版画面カスタマイズ集のコードを 1.5.8.1 のソースで検証し、動かなかったサンプルを修正
第4版画面カスタマイズ集にコード全体を収録し、サイト画像とサイト種別アイコンを両立するレシピを追加
第3版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第2版編集画面のレシピを拡充し、入力支援(@メンション・電卓・年月モード)を追加
第1版「画面カスタマイズ集」セクションの記事を追加