【Craft 4.5 の新機能】共有可能なエレメントフィルタで、管理画面をカスタマイズする(実践編)

前回の記事では、エレメントフィルタの共有 URL がどのような構造になっているのかを確認しました。

今回は、その仕組みを利用して「プルダウンから選択されたカテゴリに関連づけられたエントリだけを絞り込み表示」するカスタマイズを加えてみようと思います。

これは Craft CMS Advent Calendar 2023 11日目の記事です。

事前準備

Element Index のツールバー直下に JavaScript でプルダウンメニューを追加するため、プラグインとテンプレートを1つずつ追加します。

プラグインのインストール

管理画面のメニューから プラグインストア に移動し、cp-jsで検索します。

プラグインストアの画面サンプル

プラグイン詳細にある「インストール」ボタンをクリックして、インストールします。

インストールできたら 設定 > プラグイン 枠にある「Control Panel JS」アイコンをクリックし、プラグイン設定に移動します。

プラグイン設定のサンプル

プラグイン設定では JavaScript File(s) に JavaScript ファイルのパスを指定します。

ラベル
JavaScript File(s)/resources/custom.js

テンプレートの作成

プラグイン設定で指定したパスに、テンプレートファイルを用意します。

今回の例では、Craft CMS のインストールディレクトリ直下にある templates 配下に resources/custom.js を作成します。ファイルの中身は空でよいため、ひとまずコメント文だけを記述しておきます。

// templates/resources/custom.js

ブラウザで管理画面のダッシュボードなどにアクセスし、このファイルが読み込まれていることを確認しておきましょう。

目的の共有 URL の取得

今回は ブログ セクションのエントリをカテゴリごとに絞り込み表示したいので、エレメントフィルタを次のようにセットします。

プラグイン設定のサンプル

追加したフィルターでカテゴリ向けのリレーションフィールドを選択し、次の値に関連する に続けて任意のカテゴリをセットして「適用」します。

このときの共有 URL を後から利用しますので、ブラウザのアドレスバーに表示されている URL をコピーしておきましょう。

テンプレートをカスタマイズ

ここからはテンプレートファイル templates/resources/custom.js を編集していきますが、完成したコードは次のようになります。

/**
 * Craft.js で定義されているオリジナルのファンクションをセット
 *
 * 定義元:vendor/craftcms/cms/src/web/assets/cp/dist/js/Craft.js
 */
const _existFnc = {
  'EntryIndex': {
    'onUpdateElements' : Craft.EntryIndex.prototype.onUpdateElements
  }
};

/**
 * エントリのエレメントインデックスの更新が終わった際のファンクションを拡張(モーダル・一覧ページ共通)
 */
Craft.EntryIndex.prototype.onUpdateElements = function() {
  const _self         = this,
        _context      = _self.settings.context,
        _sourceHandle = $(_self.$source).attr('data-handle');

  // 「ブログ」セクションの一覧ページ(モーダルを除く)のみ
  if(_sourceHandle == 'blog' && _context == 'index') {
    // 絞り込み用のプルダウンを整形
    const _controls = `
      <div class="select append-searchFilter_item" style="width: 50%; margin: -5px 0 15px;">
        <select name="categoryFilter" class="append-searchFilter_selector" style="width: 100%;">
          <option value="">カテゴリで絞り込み</option>
          {%~ for category in craft.categories.group('blog').all() %}
            <option value="{{ category.id }}">{% if category.level == 2 %}- {% endif %}{{ category.title }}</option>
          {%~ endfor %}
        </select>
      </div>
    `;

    // #filter-container の存在チェック
    if($('#filter-container').length) {
      // 既に存在しているなら、中身を空に
      $('#filter-container').empty();
    } else {
      // コンテナ要素を追加
      $('#content-container').prepend('<div id="filter-container" class="append-searchFilter"></div>');
    }

    // プルダウンを DOM に追加
    $('#filter-container').append(_controls);

    // URL パラメータから取得した filters を2回デコードした文字列から、elementId を抽出
    const _url            = new URL(location.href),
          _urlPathname    = _url.pathname,
          _urlParams      = _url.searchParams,
          _requestFilters = _urlParams.get('filters'),
          _decodedFilters = decodeURIComponent(decodeURIComponent(_requestFilters)),
          _matches        = (_requestFilters) ? _decodedFilters.match(/\[elementId\]=(\d+)/) : [];

    // URL パラメータから絞り込み条件を取得できた場合
    if(_matches.length) {
      // 選択されたカテゴリをプルダウンに反映
      $('#filter-container').find('.append-searchFilter_selector option').each(function() {
        if(Number($(this).val()) === Number(_matches[1])) {
          $(this).prop('selected', true);
        }
      });
    }

    // セレクト要素の change イベントを追加
    $('#filter-container').find('.append-searchFilter_selector').on('change', function() {
      const _categoryId = $(this).val();

      // URL をセット
      let _redirectUrl = _urlPathname;

      // カテゴリが選択されていれば、URL に絞り込み条件を追加
      if(_categoryId){
        _redirectUrl = _redirectUrl + `?source=section%3Abeadf99c-009f-431c-8fb0-4f996b148c95&filters=condition%255Bclass%255D%3Dcraft%255Celements%255Cconditions%255Centries%255CEntryCondition%26condition%255Bconfig%255D%3D%257B%2522elementType%2522%253A%2522craft%255C%255Celements%255C%255CEntry%2522%252C%2522fieldContext%2522%253A%2522global%2522%257D%26condition%255BconditionRules%255D%255B1%255D%255Buid%255D%3D2bee8468-c796-456a-91c6-c9b4b53595fd%26condition%255BconditionRules%255D%255B1%255D%255Bclass%255D%3Dcraft%255Cfields%255Cconditions%255CRelationalFieldConditionRule%26condition%255BconditionRules%255D%255B1%255D%255Btype%255D%3D%257B%2522class%2522%253A%2522craft%255C%255Cfields%255C%255Cconditions%255C%255CRelationalFieldConditionRule%2522%252C%2522uid%2522%253A%25222bee8468-c796-456a-91c6-c9b4b53595fd%2522%252C%2522operator%2522%253A%2522relatedTo%2522%252C%2522elementId%2522%253Anull%252C%2522fieldUid%2522%253A%25225a81461c-25ad-4a3a-a369-7038b8ee5ba9%2522%257D%26condition%255BconditionRules%255D%255B1%255D%255Boperator%255D%3DrelatedTo%26condition%255BconditionRules%255D%255B1%255D%255BelementId%255D%3D%26condition%255BconditionRules%255D%255B1%255D%255BelementId%255D%3D${_categoryId}%26condition%255Bnew-rule-type%255D%3D`;
      }

      // ページ遷移
      location.href = _redirectUrl;
    });
  } else {
    // 「ブログ」セクション以外なら、念のため追加要素を削除
    $('#filter-container').remove();
  }

  // オリジナルのファンクションを実行
  _existFnc.EntryIndex.onUpdateElements.apply(this, arguments);
};

ポイントを解説します。

Craft 4 では、管理画面でセクションを切り替えたり、アセット一覧を表示後にエントリ一覧を再表示する際など、キャッシュを利用して画面を切り替える場合があります。そのため、DOM に調整を加える場合には Element Index の更新時に実行される Craft.EntryIndex.prototype.onUpdateElements を拡張すると効率的です。

そこで、はじめに Craft CMS デフォルトの処理を変数 _existFnc にセットし、上書きしたファンクションの最後で実行するようにします。(いわゆる、モンキーパッチです。)

const _existFnc = {
  'EntryIndex': {
    'onUpdateElements' : Craft.EntryIndex.prototype.onUpdateElements
  }
};

/**
 * エントリのエレメントインデックスの更新が終わった際のファンクションを拡張(モーダル・一覧ページ共通)
 */
Craft.EntryIndex.prototype.onUpdateElements = function() {
  // (ここに追加したい処理を定義)

  // オリジナルのファンクションを実行
  _existFnc.EntryIndex.onUpdateElements.apply(this, arguments);
};

次に、Craft.EntryIndex.prototype.onUpdateElements の冒頭で「ブログ」セクションの一覧ページのみが対象となるよう制限します。Element Index のソースのハンドル(この場合は、セクションハンドル)とコンテキスト(index または modal)から判断すれば、影響範囲を柔軟にコントロールできます。

なお、最後にオリジナルのファンクションを実行する関係で、早期リターンは利用していません。

const _self         = this,
      _context      = _self.settings.context,
      _sourceHandle = $(_self.$source).attr('data-handle');

// 「ブログ」セクションの一覧ページ(モーダルを除く)のみ
if(_sourceHandle == 'blog' && _context == 'index') {
    // (ここに該当する場合の処理を定義)
}

次に、プルダウンメニューの HTML を用意します。
カテゴリグループ blog に含まれるカテゴリをループ処理しているため、登録データに応じて option 要素は自動で増減します。

// 絞り込み用のプルダウンを整形
const _controls = `
  <div class="select append-searchFilter_item" style="width: 50%; margin: -5px 0 15px;">
    <select name="categoryFilter" class="append-searchFilter_selector" style="width: 100%;">
      <option value="">カテゴリで絞り込み</option>
      {%~ for category in craft.categories.group('blog').all() %}
        <option value="{{ category.id }}">{% if category.level == 2 %}- {% endif %}{{ category.title }}</option>
      {%~ endfor %}
    </select>
  </div>
`;

このプルダウンメニューをエレメント一覧(div#content-container)の上に追加します。

// #filter-container の存在チェック
if($('#filter-container').length) {
  // 既に存在しているなら、中身を空に
  $('#filter-container').empty();
} else {
  // コンテナ要素を追加
  $('#content-container').prepend('<div id="filter-container" class="append-searchFilter"></div>');
}

// プルダウンを DOM に追加
$('#filter-container').append(_controls);

次に、リクエスト URL でカテゴリが選択されている場合は、プルダウンメニューに反映させます。

// URL パラメータから取得した filters を2回デコードした文字列から、elementId を抽出
const _url            = new URL(location.href),
      _urlPathname    = _url.pathname,
      _urlParams      = _url.searchParams,
      _requestFilters = _urlParams.get('filters'),
      _decodedFilters = decodeURIComponent(decodeURIComponent(_requestFilters)),
      _matches        = (_requestFilters) ? _decodedFilters.match(/\[elementId\]=(\d+)/) : [];

// URL パラメータから絞り込み条件を取得できた場合
if(_matches.length) {
  // 選択されたカテゴリをプルダウンに反映
  $('#filter-container').find('.append-searchFilter_selector option').each(function() {
    if(Number($(this).val()) === Number(_matches[1])) {
      $(this).prop('selected', true);
    }
  });
}

最後に、プルダウンメニューが変更された際の処理を定義します。

先にコピーしておいた共有 URL の elementId 指定部分を elementId%255D%3D${_categoryId} とすることで、選択されたカテゴリ ID のフィルタ結果に遷移するようにしています。

// セレクト要素の change イベントを追加
$('#filter-container').find('.append-searchFilter_selector').on('change', function() {
  const _categoryId = $(this).val();

  // URL をセット
  let _redirectUrl = _urlPathname;

  // カテゴリが選択されていれば、URL に絞り込み条件を追加
  if(_categoryId){
    _redirectUrl = _redirectUrl + `?source=section%3Abeadf99c-009f-431c-8fb0-4f996b148c95&filters=condition%255Bclass%255D%3Dcraft%255Celements%255Cconditions%255Centries%255CEntryCondition%26condition%255Bconfig%255D%3D%257B%2522elementType%2522%253A%2522craft%255C%255Celements%255C%255CEntry%2522%252C%2522fieldContext%2522%253A%2522global%2522%257D%26condition%255BconditionRules%255D%255B1%255D%255Buid%255D%3D2bee8468-c796-456a-91c6-c9b4b53595fd%26condition%255BconditionRules%255D%255B1%255D%255Bclass%255D%3Dcraft%255Cfields%255Cconditions%255CRelationalFieldConditionRule%26condition%255BconditionRules%255D%255B1%255D%255Btype%255D%3D%257B%2522class%2522%253A%2522craft%255C%255Cfields%255C%255Cconditions%255C%255CRelationalFieldConditionRule%2522%252C%2522uid%2522%253A%25222bee8468-c796-456a-91c6-c9b4b53595fd%2522%252C%2522operator%2522%253A%2522relatedTo%2522%252C%2522elementId%2522%253Anull%252C%2522fieldUid%2522%253A%25225a81461c-25ad-4a3a-a369-7038b8ee5ba9%2522%257D%26condition%255BconditionRules%255D%255B1%255D%255Boperator%255D%3DrelatedTo%26condition%255BconditionRules%255D%255B1%255D%255BelementId%255D%3D%26condition%255BconditionRules%255D%255B1%255D%255BelementId%255D%3D${_categoryId}%26condition%255Bnew-rule-type%255D%3D`;
  }

  // ページ遷移
  location.href = _redirectUrl;
});

これで「ブログ」セクションを表示中のみ、プルダウンメニューでカテゴリごとの絞り込みができるようになりました。

プルダウンメニューによるカテゴリの切り替え

まとめ

今回はエレメントフィルタの共有 URL を利用して、管理画面のカスタマイズを行いました。

正直なところ、独自プラグイン向けの専用テンプレートを用意する場合を除き、Craft CMS の管理画面を JavaScript で操作する必要性は少ないと考えています。

とはいえ、「カスタムソースで用意するには膨大な数で、管理が煩雑になる」といったケースであれば、このような方法を検討してみてはいかがでしょうか。