2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プリザンターで誤って閉じたページの入力データを復旧できるようにしてみる

2
Posted at

はじめに

プリザンターで編集中に、うっかりブラウザのタブを閉じてしまったり、誤って別のページに移動してしまった経験はありませんか? 標準では「ページを離れますか?」の確認ダイアログが表示されますが、キャンセルし損ねると入力内容はすべて失われてしまいます。

この記事では、拡張スクリプトと localStorage を組み合わせて、編集中のデータを自動的にバックアップし、次回ページを開いたときに復旧できる機能を紹介します。

主な機能

  • 自動バックアップ: ページを離れる際に、変更のある入力データを localStorage へ自動保存
  • 更新日時チェック: バックアップ時と現在のレコード更新日時を比較し、他のユーザーによる更新を検知
  • 復旧 / 破棄の選択: 更新日時が一致していれば復旧するかどうかをユーザーに確認

仕組みを整理する

$p.data の構造

プリザンターのフロントエンドは、フォームの入力値を $p.data というグローバルオブジェクトに保持しています。

// 初期化(_init.js)
window.$p = {
    data: {},
    events: {},
    ex: {},
    modal: {}
};

$p.data はフォーム ID をキーとしたオブジェクトで、各フォーム内のコントロールの値が格納されます。

$p.data
  └─ MainForm
       ├─ Results_Title: "タイトル文字列"
       ├─ Results_Body: "本文..."
       ├─ Results_ClassA: "選択値"
       ├─ Results_NumA: "100"
       ├─ Results_CheckA: true
       └─ ...

値の収集は $p.setMustData 関数が行います。第 2 引数に 'create' を指定すると、フォーム内の全コントロールから値を収集します。

// 全コントロールの値を $p.data.MainForm に収集
$p.setMustData($('#MainForm'), 'create');

内部では $p.setData が各コントロールの種類(テキスト、チェックボックス、ドロップダウン、マークダウンなど)に応じて値を取得し、$p.data[formId][controlId] に格納しています。

何を保存するか

$p.data.MainForm にはシステム内部で使われるフィールド(TokenControlId など)も含まれます。保存対象はユーザーが編集するレコードのフィールドに限定します。

レコードのフィールドは {テーブル名}_{カラム名} の命名規則(例: Results_TitleIssues_Body)に従っているため、$p.tableName() + '_' で始まるキーだけを抽出すれば安全にフィルタリングできます。

更新日時による競合チェック

プリザンターの編集画面にはレコードの更新日時が <time> 要素として表示されています。この要素の datetime 属性にはレコードの最終更新日時が格納されています。

// 更新日時を取得(例: "2026/04/20 10:30:00")
$('#' + $p.tableName() + '_UpdatedTime').attr('datetime');

バージョン番号($p.ver())はバージョンアップの設定(テーブルの管理 → エディタ → 自動バージョンアップ)によっては更新時に加算されない場合があります。一方、更新日時は設定に関わらず常に更新されるため、競合チェックにはこちらを使用します。

バックアップ時に更新日時を一緒に保存しておけば、復旧時に以下の判定ができます。

条件 判定 動作
保存した更新日時と現在の更新日時が一致 未更新 復旧するか確認
保存した更新日時と現在の更新日時が不一致 更新済み 復旧不可を通知

処理の流れ

バックアップ

復旧

実装してみよう

拡張スタイルと拡張スクリプトの 2 ファイルで構成します。

拡張機能 役割
拡張スタイル 通知バーの見た目
拡張スクリプト バックアップ・復旧ロジック

通知バーのスタイル(拡張スタイル)

拡張スタイルとして App_Data/Parameters/ExtendedStyles/ に配置します。

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;
}

ポイントをまとめます。

  • position: fixedz-index: 99999 で画面最上部に固定表示
  • .fb-info(青系)と .fb-warn(オレンジ系)でメッセージの種類を視覚的に区別
  • animation: fb-slide-down で上からスライドインするアニメーション
  • ボタンは右端に配置し、復旧ボタンを目立たせるデザイン
  • Material Symbols のアイコンを使用(プリザンターに標準で読み込まれているため追加不要)

バックアップ・復旧ロジック(拡張スクリプト)

拡張スクリプトとして App_Data/Parameters/ExtendedScripts/ に配置します。

ExtendedScripts/FormBackup.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);
  }
});

設定項目

config オブジェクトの各項目を変更することで動作をカスタマイズできます。

項目 既定値 説明
storageKey string 'FormBackup' localStorage で使用するキーのプレフィックス
maxAgeHours number 24 バックアップの有効期間(時間単位)。0 で無期限

保存キーにはページのパス(location.pathname)を含めているため、レコードごとに個別にバックアップを管理できます。

ソースコードの解説

各処理のポイントを見ていきましょう。

バックアップ保存

beforeunload イベントで $p.formChangedtrue(変更あり)の場合にバックアップを保存します。

$(window).on('beforeunload.formBackup', function () {
  if ($p.formChanged) {
    saveBackup();
  }
});

saveBackup 関数では、まず $p.setMustData で全コントロールの値を $p.data.MainForm に収集します。

$p.setMustData($('#MainForm'), 'create');

$p.setMustData の第 2 引数に 'create' を渡すと、フォーム内の [class*="control-"] に一致する全要素から値を取得します。これにより、テキスト入力・ドロップダウン・チェックボックス・マークダウンエディタなど、あらゆるコントロールの値が $p.data.MainForm に格納されます。

収集されたデータからテーブル名プレフィックス($p.tableName() + '_')に一致するキーだけを抽出し、レコードの更新日時と $p.id() を添えて localStorage に JSON 文字列として保存します。

バックアップの復旧

ページ読み込み時に localStorage のバックアップを確認し、レコード ID・有効期限・更新日時の 3 段階でチェックします。

更新日時が一致する場合は通知バーを表示し、ユーザーが「復旧する」を選択すると restoreBackup 関数が呼ばれます。

Object.keys(data).forEach(function (controlId) {
  var $control = $('#' + controlId);
  if ($control.length) {
    $p.set($control, data[controlId]);
  }
});
$p.formChanged = true;

$p.set はプリザンター標準の関数で、コントロールの種類に応じた値の設定を行います。

コントロールの種類 $p.set の動作
チェックボックス prop('checked', val)
テキストエリア(マークダウン) val(val) → ビューア表示を更新
ドロップダウン(検索型) AJAX でオプションを再取得して選択
複数選択 $p.selectMultiSelect で選択状態を復元
ラジオボタン .radio-value 経由でラジオ入力を切り替え
その他(テキスト、数値等) val(val)

復旧後に $p.formChanged = true を設定することで、ページを離れる際の確認ダイアログが有効になり、復旧した内容を保存し忘れるリスクを軽減します。

更新成功時のバックアップ削除

レコードの更新が成功したらバックアップを自動的に削除します。ajaxComplete で更新リクエストの成功を検知しています。

$(document).on('ajaxComplete.formBackup', function (e, xhr, settings) {
  if (xhr.status === 200 && settings.url
    && /\/update(?:$|\?)/.test(settings.url)) {
    localStorage.removeItem(fullKey);
  }
});

特定のサイトだけに適用する

拡張スクリプトは既定ではすべてのページに適用されます。特定のサイトだけに適用したい場合は、config の直後に条件分岐を追加します。

// サイト ID 12345 のみに適用する例
if (!location.pathname.match(/\/items\/12345/)) return;

注意事項

localStorage はブラウザとドメイン単位で保持されるため、別のブラウザや別の端末で編集した内容は復旧できません。

localStorage には容量制限(一般的に 5 〜 10 MB)があります。多数のレコードでバックアップが蓄積すると容量を圧迫する可能性があるため、maxAgeHours に適切な値を設定してください。不要なバックアップは有効期限により自動的に削除されます。

この機能はあくまで入力内容の一時的な復旧手段です。ファイル添付やリンクテーブルの変更など、$p.data で管理されないデータは復旧対象外です。重要な変更は都度「更新」ボタンで保存することを推奨します。

まとめ

  • 拡張スクリプトと拡張スタイルだけで、編集中データの自動バックアップ・復旧機能を実装できます
  • $p.setMustData$p.data.MainForm に全コントロールの値を収集し、テーブル名プレフィックスでフィルタリングして localStorage に保存します
  • レコードの更新日時を比較し、他のユーザーによる更新がある場合は復旧できない旨を通知します
  • $p.set でコントロールの種類に応じた値の復元を行うため、チェックボックスやドロップダウンなど多様なコントロールに対応できます
  • 更新成功時に ajaxComplete でバックアップを自動削除するため、不要なバックアップが残りません
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?