プラグインなしで固定ページを階層構造に並べ替える方法

Cocoonカスタマイズ集
この記事は約21分で読めます。
記事内に広告が含まれています。

はじめに

1つずつ編集画面を開いて親ページや順位を変更し直す必要があり、非常に手間がかかります。

そこで、プラグイン「Simple Page Ordering」などを使用せずに、固定ページをドラッグ&ドロップ操作だけで親子関係や並び替える機能を追加します。

この記事では、この方法について説明します。

完成イメージ

実装手順

以下のコードをfunctions.phpに追加します。

// 管理画面メニューを追加
add_action('admin_menu', function () {
  add_submenu_page(
    'edit.php?post_type=page',
    '固定ページ並べ替え',
    '固定ページ並べ替え',
    'edit_pages',
    'page_order',
    'render_page_order'
  );
});


/**
 * 管理画面ページを出力
 *
 * @return void
 */
if (!function_exists('render_page_order')):
function render_page_order() {
  // 固定ページ情報を取得
  $pages = get_pages([
    'post_status' => ['publish', 'draft', 'pending', 'private', 'future'],
    'sort_column' => 'menu_order, post_title',
    'hierarchical' => true,
  ]);

  // 取得エラー時は空配列を設定
  if (!is_array($pages)) {
    $pages = [];
  }

  // 親IDごとに固定ページを分類
  $map = [];

  // 親ページIDをキーにしてページ情報を登録
  foreach ($pages as $p) {
    $map[(int) $p->post_parent][] = $p;
  }

  // 同じ親を持つページを menu_order、タイトル順に並び替え
  foreach ($map as &$siblings) {
    usort($siblings, function ($a, $b) {
      $result = (int) $a->menu_order <=> (int) $b->menu_order;

      return $result !== 0
        ? $result
        : strcmp($a->post_title, $b->post_title);
    });
  }
  unset($siblings);

  ?>
  <div class="wrap">
    <h1>固定ページ 階層・並び順</h1>
    <p>上下ドラッグで順序、左右ドラッグで階層を変更できます。親ページを移動すると、子ページ・孫ページも一緒に移動します。</p>

    <ul id="page-order-list">
      <?php
      // 印刷済みフラグを初期化
      $printed = [];

      // 親の直後に子孫を再帰的に表示する処理を設定
      $print = function ($parent_id, $depth) use (&$print, &$printed, $map) {
        // 指定された親IDに属するページを取得
        foreach ($map[$parent_id] ?? [] as $p) {
          $id = (int) $p->ID;

          if (isset($printed[$id])) {
            continue;
          }

          // 印刷済みとして登録
          $printed[$id] = true;

          // タイトルを取得
          $title = get_the_title($p);
          if ($title === '') {
            $title = '(タイトルなし)';
          }

          // ステータスを設定
          $status = $p->post_status !== 'publish'
            ? ' (' . $p->post_status . ')'
            : '';

          $term_id = (int) $id;
          $depth_val = (int) $depth;
          $margin_left = $depth_val * 30;
          $display_title = esc_html($title . $status);

          echo '<li data-id="' . $term_id . '" data-depth="' . $depth_val . '" style="margin-left:' . $margin_left . 'px">';
          echo '<span class="page-order__grip" aria-hidden="true">⋮⋮</span>';
          echo '<span class="page-order__title">' . $display_title . '</span>';
          echo '<small class="page-order__depth">depth: <b>' . $depth_val . '</b></small>';
          echo '</li>';

          // 子ページを次の階層で表示する処理を設定
          $print($id, $depth + 1);
        }
      };

      // 最上位ページを表示
      $print(0, 0);

      // 親ページが取得対象に含まれない場合も残りを表示
      foreach ($pages as $p) {
        $id = (int) $p->ID;

        if (!isset($printed[$id])) {
          // 残りのページを印刷済みとして登録
          $printed[$id] = true;

          $title = get_the_title($p) ?: '(タイトルなし)';
          $status = $p->post_status !== 'publish' ? ' (' . $p->post_status . ')' : '';
          $display_title = esc_html($title . $status);

          echo '<li data-id="' . $id . '" data-depth="0" style="margin-left:0">';
          echo '<span class="page-order__grip" aria-hidden="true">⋮⋮</span>';
          echo '<span class="page-order__title">' . $display_title . '</span>';
          echo '<small class="page-order__depth">depth: <b>0</b></small>';
          echo '</li>';

          // 残りの子ページを表示
          $print($id, 1);
        }
      }
      ?>
    </ul>

    <p>
      <button type="button" id="page-order-save" class="button button-primary">設定を保存</button>
      <span id="page-order-msg" role="status"></span>
    </p>
  </div>
  <?php
}
endif;


// 管理画面用JS・CSSを追加
add_action('admin_enqueue_scripts', function () {
  if (($_GET['page'] ?? '') !== 'page_order') {
    return;
  }

  ob_start();
  ?>
  #page-order-list {
    background-color: #fff;
    border: 1px solid #ccd0d4;
    border-radius: 4px;
    list-style: none;
    margin-top: 20px;
    max-width: 700px;
    padding: 10px;
  }

  #page-order-list li {
    align-items: center;
    background-color: #f6f7f7;
    border: 1px solid #c3c4c7;
    border-radius: 3px;
    box-sizing: border-box;
    cursor: move;
    display: flex;
    font-weight: bold;
    margin-bottom: 8px;
    min-height: 39px;
    padding: 8px 12px;
  }

  #page-order-list li:last-child {
    margin-bottom: 0;
  }

  #page-order-list li:hover {
    background-color: #f0f0f1;
  }

  .page-order__grip {
    color: #8c8f94;
    flex-shrink: 0;
    font-weight: normal;
    letter-spacing: -1px;
    margin-right: 8px;
    user-select: none;
  }

  .page-order__title {
    font-weight: normal;
    overflow-wrap: anywhere;
  }

  .page-order__depth {
    color: #787c82;
    flex-shrink: 0;
    font-size: 12px;
    font-weight: normal;
    margin-left: auto;
    padding-left: 12px;
  }

  #page-order-list li.page-order__placeholder {
    background-color: #e5f5fa;
    border: 1px dashed #2271b1;
    border-radius: 3px;
    box-sizing: border-box;
    height: 39px;
    margin-bottom: 8px;
    padding: 0;
  }

  #page-order-list li.ui-sortable-helper {
    background-color: #fff;
    box-shadow: 0 2px 8px rgba(0, 0, 0, .2);
  }
  <?php
  wp_add_inline_style('wp-admin', ob_get_clean());

  ob_start();
  ?>
  jQuery(function ($) {
    // ドラッグ幅・対象リスト・移動情報・子ページを設定
    var I = 30, $l = $("#page-order-list"), x0, d0, C = $();

    // 固定ページの階層を取得
    var D = function ($i) {
      return +$i.attr("data-depth") || 0;
    };

    // 固定ページの階層と表示位置を変更
    var S = function ($i, d) {
      $i.attr("data-depth", d).css("margin-left", d * I);
      $i.find("b").text(d);
    };

    // 固定ページリストのドラッグ操作を設定
    $l.sortable({
      // ドラッグ中のプレースホルダーと判定方法を設定
      placeholder: "page-order__placeholder",
      tolerance: "pointer",

      // ドラッグ開始時の階層と子ページを取得
      start: function (e, ui) {
        // ドラッグ開始位置と対象ページの階層を取得
        x0 = e.pageX;
        d0 = D(ui.item);

        // 移動対象の子ページを初期化
        C = $();

        // 対象ページに属する子孫ページを取得
        ui.item.nextAll("li").not(ui.placeholder).each(function () {
          // 同じ階層以下のページで取得を終了
          if (D($(this)) <= d0) return false;

          // 移動対象の子孫ページを登録
          C = C.add(this);
        });

        // 子孫ページを一時的にリストから切り離す
        C.detach();

        // ドラッグ位置の再計算を設定
        $l.sortable("refreshPositions");
      },

      // ドラッグ中の移動先階層を変更
      sort: function (e, ui) {
        // プレースホルダーの直前にあるページを取得
        var p = ui.placeholder.prevAll("li").not(ui.item).first();

        // 横方向の移動量から階層を計算して変更
        var d = Math.max(0, Math.min(d0 + Math.round((e.pageX - x0) / I), p.length ? D(p) + 1 : 0));

        // 移動先の階層を一時保存
        ui.item.data("nd", d);

        // プレースホルダーの表示位置を変更
        ui.placeholder.css("margin-left", d * I);
      },

      // ドラッグ終了時に階層と子ページの位置を変更
      stop: function (e, ui) {
        // 移動先の直前にあるページを取得
        var p = ui.item.prev();

        // ドラッグ中に設定した階層を取得
        var d = ui.item.data("nd");

        // 移動先の階層を有効な範囲に変更
        d = Math.max(0, Math.min(d === undefined ? d0 : d, p.length ? D(p) + 1 : 0));

        // 移動したページの階層を変更
        S(ui.item, d);

        // 子孫ページの階層を親ページに合わせて変更
        C.each(function () {
          S($(this), D($(this)) + d - d0);
        });

        // 子孫ページを親ページの後ろに登録
        ui.item.after(C);

        // 子孫ページの一時情報を初期化
        C = $();

        // 階層の連続性を確認するための初期値を設定
        var prev = -1;

        // 各ページの階層を確認して変更
        $l.children().each(function () {
          // 直前のページより深い階層になりすぎないように変更
          var n = Math.min(D($(this)), prev + 1);

          // 必要な場合は階層を変更
          if (n !== D($(this))) {
            S($(this), n);
          }

          // 次のページの比較用階層を設定
          prev = n;
        });
      }
    });

    // 保存ボタンのクリック処理を設定
    $("#page-order-save").on("click", function () {
      // 親ページ情報と保存対象を初期化
      var s = [], items = [];

      // 画面上の順序からページIDと親IDを取得
      $l.children().each(function () {
        // 現在の階層とページIDを取得
        var d = Math.min(D($(this)), s.length);
        var id = $(this).data("id");

        // 現在の階層に合わせて親情報を設定
        s.length = d;

        // ページIDと親ページIDを登録
        items.push([id, s[d - 1] || 0]);

        // 次のページの親情報としてページIDを登録
        s.push(id);
      });

      // AJAXで保存処理を実行
      $.post(ajaxurl, {
        // 保存処理のアクションを設定
        action: "page_order_save",

        // 固定ページ情報をJSON形式で登録
        items: JSON.stringify(items)
      })
      .done(function (r) {
        // 保存成功時は画面を再読み込み
        if (r.success) {
          location.reload();
        } else {
          // 保存失敗時のメッセージを設定
          $("#page-order-msg").text("保存に失敗しました。");
        }
      })
      .fail(function () {
        // 通信失敗時のメッセージを設定
        $("#page-order-msg").text("通信エラー");
      });
    });
  });
  <?php

  wp_enqueue_script('jquery-ui-sortable');
  wp_add_inline_script('jquery-ui-sortable', ob_get_clean());
});


// Ajaxアクションを追加
add_action('wp_ajax_page_order_save', function () {
  // 保存権限がない場合は処理対象外
  if (!current_user_can('edit_pages')) {
    wp_send_json_error(['message' => '保存する権限がありません。']);
  }

  // 送信された固定ページ情報をJSONから取得
  $items = json_decode(wp_unslash($_POST['items'] ?? ''), true);

  // 配列形式でない場合は処理対象外
  if (!is_array($items)) {
    wp_send_json_error(['message' => '送信データが正しくありません。']);
  }

  $valid = [];
  $seen = [];
  $err = 0;

  // ページID・親IDを検証
  foreach (array_values($items) as $order => $item) {
    if (!is_array($item) || !isset($item[0])) {
      $err++;
      continue;
    }

    $id = absint($item[0]);
    $parent = isset($item[1]) ? absint($item[1]) : 0;

    if (!$id || isset($seen[$id])) {
      $err++;
      continue;
    }

    // 投稿情報を取得
    $post = get_post($id);

    if (
      !$post ||
      $post->post_type !== 'page' ||
      !current_user_can('edit_post', $id)
    ) {
      $err++;
      continue;
    }

    if ($parent) {
      // 親の投稿情報を取得
      $parent_post = get_post($parent);

      if (
        !$parent_post ||
        $parent_post->post_type !== 'page' ||
        !current_user_can('edit_post', $parent)
      ) {
        $err++;
        continue;
      }
    }

    // 閲覧済みとして登録
    $seen[$id] = true;

    // 有効なデータを登録
    $valid[$id] = [
      'id' => $id,
      'parent' => $parent,
      'order' => $order,
    ];
  }

  // 送信された階層内で循環参照を検出
  foreach ($valid as $id => &$row) {
    $parent = $row['parent'];
    $chain = [$id => true];
    $steps = 0;

    while ($parent) {
      if (isset($chain[$parent])) {
        $row['parent'] = 0;
        $err++;
        break;
      }

      $chain[$parent] = true;

      // 一覧に含まれない親は既存の親子関係をたどる
      if (isset($valid[$parent])) {
        $parent = $valid[$parent]['parent'];
      } else {
        // 親の投稿情報を取得
        $parent_post = get_post($parent);

        if (!$parent_post || $parent_post->post_type !== 'page') {
          $row['parent'] = 0;
          $err++;
          break;
        }

        $parent = (int) $parent_post->post_parent;
      }

      if (++$steps > count($valid) + 100) {
        $row['parent'] = 0;
        $err++;
        break;
      }
    }
  }
  unset($row);

  // 親IDと menu_order を保存
  $updated = 0;

  foreach ($valid as $row) {
    $id = $row['id'];
    $parent = $row['parent'];
    $order = $row['order'];
    // 投稿情報を取得
    $post = get_post($id);

    if (
      (int) $post->post_parent === $parent &&
      (int) $post->menu_order === $order
    ) {
      continue;
    }

    // 投稿情報を変更して保存
    $result = wp_update_post([
      'ID' => $id,
      'post_parent' => $parent,
      'menu_order' => $order,
    ], true);

    if (is_wp_error($result) || !$result) {
      $err++;
    } else {
      $updated++;
    }
  }

  // 投稿キャッシュをクリアして終了
  clean_post_cache(array_keys($valid));

  // 保存結果の通知内容を設定
  set_transient('page_order_admin_notice', [
    'type' => !$err ? 'success' : 'warning',
    'message' => !$err ? '設定を保存しました。' : '一部の固定ページを保存できませんでした。',
  ], 30);

  // 更新件数とエラー件数をJSON形式で返却して処理を終了
  wp_send_json_success(['n' => $updated, 'err' => $err]);
});


// 通知メッセージを出力
add_action('admin_notices', function () {
  if ($notice = get_transient('page_order_admin_notice')) {
    echo '<div class="notice notice-' . esc_attr($notice['type']) . ' is-dismissible"><p>' . esc_html($notice['message']) . '</p></div>';
    delete_transient('page_order_admin_notice');
  }
});


// 固定ページウィジェットの並び順をmenu_orderに変更
add_filter('widget_pages_args', function ($args) {
  $args['sort_column'] = 'menu_order, post_title';
  return $args;
});

さいごに

今回紹介した方法を使えば、プラグインを使わずに、管理画面の固定ページ一覧を自由にドラッグで並び替えることができます。
ぜひ自作テーマやカスタマイズにお役立てください。

タイトルとURLをコピーしました