Wordpress ~ 23 мин

Сортировка записей WordPress перетаскиванием: drag-and-drop и inline-редактирование в списке

В этой статье — готовое универсальное решение для WordPress, которое превращает скучный список записей в админке в интерактивный инструмент: записи можно переставлять мышью, а при необходимости — править значение сортировки прямо в колонке, не открывая страницу редактирования. Разберём путь от регистрации CPT и метабокса до полностью рабочего drag-and-drop с автосохранением через AJAX.

Постановка задачи и желаемый результат

Представьте типичный сайт-каталог: «инструменты», «плагины», «проекты», «услуги» — что угодно, где записи выводятся в определённом порядке. По умолчанию в WordPress записи сортируются по дате публикации, и поменять их местами — значит открыть каждую запись, выставить дату, сохранить, повторить. Это мучительно.

Нам хочется двух вещей сразу:

  1. Drag-and-drop в списке админки. Хватаем строку мышью за специальную ручку, тащим вверх или вниз, отпускаем — порядок сохраняется. Без перезагрузки, без диалоговых окон, без открытия записей.
  2. Inline-редактирование значения сортировки. Иногда нужно задать точное число — например, «поставить эту запись между 80 и 60». Клик по числу в колонке превращает его в input, вводим значение, нажимаем Enter — строка сама встаёт на нужное место.

Желаемый результат выглядит так:

  • В колонке «Сорт.» отображается числовое значение, защищённое от случайного изменения: править можно только по явному клику.
  • Слева от числа — ручка ⋮⋮, за которую строку можно перетаскивать.
  • При drop’е отправляется один AJAX-запрос с массивом ID; сервер присваивает значения 10, 20, 30… (или 80, 70, 60… в зависимости от направления) и обновляет мета-поле.
  • При inline-сохранении — то же самое, но для одной записи, после чего DOM пересортировывается мгновенно, без перезагрузки.
  • На публичной части сайта записи сортируются по этому же полю, по убыванию: большее значение — выше.
  • Код универсален: работает в любой теме, не зависит от её структуры папок и не требует сборщика.
Важно. Ниже — полностью рабочий код. Я намеренно не использую menu_order (встроенное поле «Порядок»), потому что оно показывается только когда в supports указан ‘page-attributes’, и для CPT это поле видно не во всех темах. Произвольное мета-поле гибче и работает всегда.

Шаг 1. Регистрация custom post type

Начнём с создания произвольного типа записей tools. Если у вас он уже есть — просто запомните его ярлык (slug) и переходите к шагу 2. Код ниже — это базовая регистрация, достаточная для демонстрации; в реальном проекте labels и аргументы у вас будут свои.

// functions.php (или отдельный inc-файл, подключённый через require_once)
add_action('init', function () {
    register_post_type('tools', array(
        'labels' => array(
            'name'          => 'Инструменты',
            'singular_name' => 'Инструмент',
            'add_new_item'  => 'Добавить инструмент',
            'edit_item'     => 'Редактировать инструмент',
            'all_items'     => 'Все инструменты',
        ),
        'public'        => true,
        'has_archive'   => true,
        'menu_icon'     => 'dashicons-admin-tools',
        'menu_position' => 21,
        // 'page-attributes' НЕ добавляем — сортировкой управляет наше мета-поле.
        'supports'      => array('title', 'editor', 'thumbnail', 'excerpt'),
        'rewrite'       => array('slug' => 'tools', 'with_front' => false),
    ));
});

После сохранения в админке появится пункт меню «Инструменты». Перейдите в него, добавьте пару тестовых записей — нам понадобятся данные, чтобы проверить сортировку.

Шаг 2. Метабокс сортировки на странице редактирования

Метабокс — это блок в правой колонке на странице редактирования записи. В нём — поле для числового значения сортировки. Без него автор записи не сможет задать значение, отличное от ID, при создании.

/**
 * Регистрация метабокса сортировки.
 * Хук add_meta_boxes вызывается после 'init' и до 'admin_enqueue_scripts',
 * поэтому здесь безопасно регистрировать и сам метабокс, и обработчик сохранения.
 */
add_action('add_meta_boxes', function () {

    add_meta_box(
        'dk_tools_sort',                                   // ID метабокса
        'Сортировка',                                       // Заголовок в админке
        'dk_tools_sort_render_metabox',                     // Callback: HTML
        'tools',                                            // Post type
        'side',                                             // Контекст: боковая колонка
        'high'                                              // Приоритет: выше стандартных
    );

});

/**
 * HTML метабокса.
 *
 * @param WP_Post $post Текущая редактируемая запись.
 */
function dk_tools_sort_render_metabox($post) {

    // nonce для проверки при сохранении. wp_nonce_field() выводит сразу input.
    wp_nonce_field('dk_tools_sort_save', 'dk_tools_sort_nonce');

    $value = (string) get_post_meta($post->ID, 'dk_tools_sort', true);

    echo '<p><label for="dk_tools_sort"><strong>Значение сортировки</strong></label></p>';
    echo '<input type="number" id="dk_tools_sort" name="dk_tools_sort" '
       . 'value="' . esc_attr($value) . '" min="0" step="1" style="width:100%">';
    echo '<p class="description">Большее число — выше в списке. '
       . 'Если поле пустое, при сортировке запись попадёт в конец.</p>';
}

/**
 * Сохранение значения.
 * Хук save_post срабатывает после создания/обновления записи.
 * Проверяем тип записи, nonce, права и autosave — это стандартный набор защит.
 */
add_action('save_post_tools', function ($post_id) {

    // Проверка nonce. Если поле отсутствует — выходим.
    if (!isset($_POST['dk_tools_sort_nonce']) ||
        !wp_verify_nonce($_POST['dk_tools_sort_nonce'], 'dk_tools_sort_save')) {
        return;
    }

    // autosave и ревизии не трогаем.
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
        return;
    }

    // Права: пользователь должен уметь редактировать эту запись.
    if (!current_user_can('edit_post', $post_id)) {
        return;
    }

    // Если поле не отправлено (теоретически) — оставляем как есть.
    if (!isset($_POST['dk_tools_sort'])) {
        return;
    }

    $raw = sanitize_text_field(wp_unslash($_POST['dk_tools_sort']));
    $raw = trim($raw);

    // Пустую строку сохраняем как пустую — это допустимое состояние.
    if ($raw === '') {
        update_post_meta($post_id, 'dk_tools_sort', '');
        return;
    }

    // Принимаем только целые числа. (int) ниже безопасно отрежет мусор.
    if (!preg_match('/^-?\d+$/', $raw)) {
        return;
    }

    update_post_meta($post_id, 'dk_tools_sort', (string) (int) $raw);
});
Совет. Хук save_post_tools (с суффиксом типа записи) срабатывает только для записей этого типа — это удобнее, чем проверять $post->post_type внутри общего save_post.

Шаг 3. Применение сортировки на публичной части сайта

На архивной странице CPT (/tools/) и в любых WP_Query с этим типом записей нужно указать сортировку по нашему полю. Сделаем это глобально для архива — а в остальных местах каждый разработчик добавит нужные параметры вручную.

/**
 * Сортировка на публичной части: по убыванию значения dk_tools_sort.
 * meta_value_num нужен, чтобы сравнение шло как чисел, а не строк ("100" < "20" как строки). * Пустые значения (записи без поля) автоматически окажутся в конце — это поведение WordPress по умолчанию. */ add_action('pre_get_posts', function ($query) { // Только основной запрос на фронтенде, только архив tools. if (is_admin() || !$query->is_main_query()) {
        return;
    }
    if (!$query->is_post_type_archive('tools')) {
        return;
    }

    $query->set('meta_key', 'dk_tools_sort');
    $query->set('orderby', 'meta_value_num');
    $query->set('order', 'DESC');
});

Если вы делаете new WP_Query вручную, передавайте те же три параметра:

new WP_Query(array(
    'post_type'      => 'tools',
    'posts_per_page' => 20,
    'meta_key'       => 'dk_tools_sort',
    'orderby'        => 'meta_value_num',
    'order'          => 'DESC',
));

Шаг 4. Колонка сортировки в списке админки

Добавим новую колонку после заголовка. Для CPT tools по умолчанию рисуем «закрытое» значение (как число), а рядом — ручку для drag-and-drop. В инпуте лежит data-post-id, чтобы JavaScript знал, какую запись обновлять.

/**
 * Колонка dk_tools_sort в шапке таблицы.
 */
add_filter('manage_tools_posts_columns', function ($columns) {

    $out = array();
    foreach ($columns as $key => $label) {
        $out[$key] = $label;
        if ($key === 'title') {
            // Колонка появится сразу после заголовка.
            $out['dk_tools_sort'] = 'Сорт.';
        }
    }
    return $out;
});

/**
 * Содержимое ячейки.
 *
 * Структура DOM:
 *   <span class="dk-sort-handle">⋮⋮</span>     ← ручка для drag-and-drop
 *   <span class="dk-sort-value" data-post-id="…">90</span>  ← число, по клику становится input
 *   <input class="dk-sort-input"  data-post-id="…" style="display:none">  ← скрытый input
 *
 * input скрыт по умолчанию, чтобы случайный клик не открывал редактирование.
 */
add_action('manage_tools_posts_custom_column', function ($column, $post_id) {

    if ($column !== 'dk_tools_sort') {
        return;
    }

    $value = (string) get_post_meta($post_id, 'dk_tools_sort', true);
    $display = ($value === '' || $value === null) ? '—' : esc_html($value);

    echo '<span class="dk-sort-handle" aria-hidden="true">⋮⋮</span>';
    echo '<span class="dk-sort-value" data-post-id="' . esc_attr($post_id) . '" '
       . 'tabindex="0" role="button" aria-label="Изменить сортировку">' . $display . '</span>';
    echo '<input type="number" class="dk-sort-input" data-post-id="' . esc_attr($post_id) . '" '
       . 'value="' . esc_attr($value) . '" min="0" step="1" style="display:none">';
}, 10, 2);

/**
 * Делаем колонку сортируемой — клик по заголовку «Сорт.» в админке
 * переключит порядок по нашему мета-полю.
 */
add_filter('manage_edit-tools_sortable_columns', function ($columns) {
    $columns['dk_tools_sort'] = 'dk_tools_sort';
    return $columns;
});
Готово. После сохранения в списке записей появится колонка с числами и ручками. Клик по числу пока ничего не сделает — это добавит JavaScript на шаге 8.

Шаг 5. AJAX-обработчики: сохранение значения и массовая перестановка

Два эндпоинта: один сохраняет одно значение (для inline-редактирования), второй принимает массив ID (для drag-and-drop). Оба работают через admin-ajax.php и требуют nonce.

/**
 * Inline-сохранение одного значения.
 * Принимает: post_id, value, nonce.
 * Возвращает: { success: true, data: { value: "90" } } или { success: false }.
 */
add_action('wp_ajax_dk_tools_save_sort', function () {

    // Проверка nonce. Если не сошёлся — die(0) с кодом 403.
    check_ajax_referer('dk_tools_sort', 'nonce');

    $post_id = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0;
    if ($post_id <= 0 || !current_user_can('edit_post', $post_id)) {
        wp_send_json_error(array('message' => 'forbidden'), 403);
    }

    $raw = isset($_POST['value']) ? wp_unslash($_POST['value']) : '';
    $val = trim((string) $raw);

    // Пустую строку сохраняем как пустую — без fallback на post_id,
    // чтобы администратор мог осознанно «обнулить» запись.
    if ($val === '') {
        update_post_meta($post_id, 'dk_tools_sort', '');
        wp_send_json_success(array('value' => ''));
    }

    // Только целые числа. Всё прочее — ошибка.
    if (!preg_match('/^-?\d+$/', $val)) {
        wp_send_json_error(array('message' => 'invalid'));
    }

    $int_val = (int) $val;
    update_post_meta($post_id, 'dk_tools_sort', (string) $int_val);
    wp_send_json_success(array('value' => (string) $int_val));
});

/**
 * Массовая перестановка (drag-and-drop).
 * Принимает: ids = "12,7,3,9" (в порядке сверху вниз), nonce.
 * Возвращает: { success: true, data: { values: { 12: "80", 7: "70", ... } } }.
 *
 * Логика шага значений:
 *   Так как на публичной части и в админке у нас order=DESC (больше — выше),
 *   верх строки должен получить БОЛЬШЕЕ значение.
 *   При N записях: верх = N*10, низ = 10. Шаг 10 даёт запас для вставки.
 */
add_action('wp_ajax_dk_tools_reorder', function () {

    check_ajax_referer('dk_tools_sort', 'nonce');

    $raw = isset($_POST['ids']) ? wp_unslash($_POST['ids']) : '';
    $ids = array_filter(array_map('intval', explode(',', (string) $raw)));

    if (!$ids) {
        wp_send_json_error(array('message' => 'empty'));
    }

    $total = count($ids);
    $values = array();

    foreach ($ids as $i => $id) {
        if (!current_user_can('edit_post', $id)) {
            continue;
        }
        // (N - i) * 10:  при N=5 и порядке [id1,id2,id3,id4,id5] получим 50,40,30,20,10.
        $val = (string) (($total - $i) * 10);
        update_post_meta((int) $id, 'dk_tools_sort', $val);
        $values[(int) $id] = $val;
    }

    wp_send_json_success(array('values' => $values));
});
О правах. current_user_can(‘edit_post’, $id) вызывается для каждой записи отдельно. Это значит, что автор с ограниченными правами сможет переставить только свои записи, а к чужим запрос просто не применится — без раскрытия информации.

Шаг 6. Дефолтный порядок в админке

Без этого шага при входе в «Все инструменты» записи идут по дате, и наша сортировка ни на что не влияет. Добавим отдельный хук только для админки.

/**
 * В админке на экране edit.php для CPT tools сортируем по dk_tools_sort DESC,
 * но ТОЛЬКО если пользователь явно не выбрал другой orderby кликом по заголовку колонки.
 */
add_action('pre_get_posts', function ($query) {

    if (!is_admin() || !$query->is_main_query()) {
        return;
    }
    $screen = function_exists('get_current_screen') ? get_current_screen() : null;
    if (!$screen || $screen->base !== 'edit' || $screen->post_type !== 'tools') {
        return;
    }

    // Если пользователь кликнул по заголовку колонки — уважаем его выбор.
    if ($query->get('orderby')) {
        return;
    }

    $query->set('meta_key', 'dk_tools_sort');
    $query->set('orderby', 'meta_value_num');
    $query->set('order', 'DESC');
});

Шаг 7. Подключение скриптов и стилей

В шагах 1–6 не было ни одной строчки, привязанной к конкретной теме. Этот шаг — единственное место, где мы говорим «положить файл сюда и подключить так». Универсальность достигается тем, что мы используем стандартные функции wp_enqueue_script и wp_enqueue_style и опираемся на get_stylesheet_directory — это работает в любой теме.

Структура в папке темы:

dk/
├── functions.php
└── assets/
    ├── js/
    │   ├── sortable.min.js   ← библиотека SortableJS 1.15.2
    │   └── tools-sort.js     ← наш код
    └── css/
        └── tools-sort.css    ← стили

 

Если у вас уже есть assets/js и assets/css — кладите файлы туда. Скачать SortableJS можно с github.com/SortableJS/Sortable (файл Sortable.min.js), либо через npm: npm i sortablejs и скопировать из node_modules/sortablejs/Sortable.min.js.

 

/**
 * Подключение ассетов только на нужном экране админки:
 *   - только edit.php (список записей)
 *   - только для CPT tools
 *
 * Это критично: если подключить SortableJS глобально, он может конфликтовать
 * с другими списками (посты, страницы, медиа).
 */
add_action('admin_enqueue_scripts', function ($hook) {

    if ($hook !== 'edit.php') {
        return;
    }
    $screen = function_exists('get_current_screen') ? get_current_screen() : null;
    if (!$screen || $screen->post_type !== 'tools') {
        return;
    }

    $dir = get_stylesheet_directory();          // путь в файловой системе
    $uri = get_stylesheet_directory_uri();     // URL в браузере

    // SortableJS — внешняя библиотека, зависимостей не имеет.
    wp_enqueue_script(
        'sortablejs',
        $uri . '/assets/js/sortable.min.js',
        array(),
        '1.15.2',
        true     // в подвале
    );

    // Наш скрипт зависит от SortableJS.
    wp_enqueue_script(
        'dk-tools-sort',
        $uri . '/assets/js/tools-sort.js',
        array('sortablejs'),
        '1.0.0',
        true
    );

    // Стили — в шапке, без зависимостей.
    wp_enqueue_style(
        'dk-tools-sort',
        $uri . '/assets/css/tools-sort.css',
        array(),
        '1.0.0'
    );

    // Передаём nonce и адрес admin-ajax.php в JS.
    wp_localize_script('dk-tools-sort', 'DkToolsSort', array(
        'nonce' => wp_create_nonce('dk_tools_sort'),
        'ajaxUrl' => admin_url('admin-ajax.php'),
    ));
});
Заметка про wp_localize_script. Эта функция печатает JS-объект window.DkToolsSort = {…} перед подключением скрипта. Это самый надёжный способ передать данные из PHP в JS в WordPress — не нужно вшивать nonce в URL, не нужны cookies.

Шаг 8. JavaScript: SortableJS + click-to-edit + мгновенная перестановка

Файл assets/js/tools-sort.js. Содержит четыре логических блока:

  1. Инициализация SortableJS на #the-list с колбэком onUpdate.
  2. Click-to-edit: клик по .dk-sort-value показывает скрытый .dk-sort-input.
  3. Сохранение через AJAX: Enter или blur у input’а, Esc — отмена.
  4. Мгновенная перестановка DOM: после любого успешного сохранения строки пересортировываются по новым значениям, без перезагрузки страницы.

Обратите внимание на важные детали, помеченные в комментариях.

/* global Sortable, DkToolsSort */
(function () {
    'use strict';

    // Если по какой-то причине Sortable не загрузился — выходим тихо.
    if (typeof Sortable === 'undefined') {
        return;
    }

    // #the-list — это <tbody> стандартной таблицы WP-админки.
    var list = document.getElementById('the-list');
    if (!list || !list.children.length) {
        return;
    }

    var ajaxUrl = (window.DkToolsSort && window.DkToolsSort.ajaxUrl) || '/wp-admin/admin-ajax.php';
    var nonce   = (window.DkToolsSort && window.DkToolsSort.nonce) || '';

    // ---------- Утилиты ----------

    /**
     * Собираем ID всех строк в текущем порядке (сверху вниз).
     * Каждая строка имеет id="post-123", мы вытаскиваем 123.
     */
    function getIdsInOrder() {
        var out = [];
        for (var i = 0; i < list.children.length; i++) {
            var row = list.children[i];
            if (row.id && row.id.indexOf('post-') === 0) {
                out.push(parseInt(row.id.replace('post-', ''), 10));
            }
        }
        return out;
    }

    /**
     * POST через fetch + FormData. FormData автоматически подставит boundary,
     * а credentials: 'same-origin' гарантирует, что отправятся куки авторизации.
     */
    function postAjax(action, payload) {
        var data = new FormData();
        data.append('action', action);
        data.append('nonce', nonce);
        for (var key in payload) {
            if (Object.prototype.hasOwnProperty.call(payload, key)) {
                data.append(key, payload[key]);
            }
        }
        return fetch(ajaxUrl, {
            method: 'POST',
            credentials: 'same-origin',
            body: data
        }).then(function (r) {
            if (!r.ok) {
                throw new Error('HTTP ' + r.status);
            }
            return r.json();
        });
    }

    // ---------- Поиск input/value по post_id ----------

    function findInput(postId) {
        var inputs = list.querySelectorAll('.dk-sort-input');
        for (var i = 0; i < inputs.length; i++) {
            if (parseInt(inputs[i].getAttribute('data-post-id'), 10) === postId) {
                return inputs[i];
            }
        }
        return null;
    }

    function findValueEl(postId) {
        var vals = list.querySelectorAll('.dk-sort-value');
        for (var i = 0; i < vals.length; i++) {
            if (parseInt(vals[i].getAttribute('data-post-id'), 10) === postId) {
                return vals[i];
            }
        }
        return null;
    }

    /**
     * Обновляет видимые input и span для набора записей.
     * Вызывается после drag-and-drop: сервер вернул новые значения.
     */
    function syncFromServer(valuesById) {
        for (var id in valuesById) {
            if (!Object.prototype.hasOwnProperty.call(valuesById, id)) continue;
            var postId = parseInt(id, 10);
            var input  = findInput(postId);
            var valEl  = findValueEl(postId);
            var v      = valuesById[id];
            if (input) {
                input.value = v;
                input.defaultValue = v;
            }
            if (valEl) {
                valEl.textContent = (v === '' || v === '0') ? '—' : v;
            }
        }
    }

    /**
     * Пересортировка DOM по текущим значениям input'ов.
     * Используется и после drag-and-drop, и после inline-сохранения:
     * гарантирует, что DOM и значения согласованы.
     *
     * Сортировка по убыванию, пустые значения — в конец.
     */
    function reorderDomByInputs() {
        var rows = Array.prototype.slice.call(list.children);
        if (rows.length < 2) return;
        rows.sort(function (a, b) {
            var av = readSort(a);
            var bv = readSort(b);
            if (av === bv) return 0;
            if (av === null) return 1;  // пустые — в конец
            if (bv === null) return -1;
            return bv - av;             // DESC
        });
        // appendChild перемещает существующий узел, не клонируя.
        for (var i = 0; i < rows.length; i++) {
            list.appendChild(rows[i]);
        }
    }

    function readSort(row) {
        var input = row.querySelector('.dk-sort-input');
        if (!input) return null;
        var v = input.value;
        if (v === '' || v === null) return null;
        var n = parseInt(v, 10);
        return isNaN(n) ? null : n;
    }

    // ---------- Drag-and-drop ----------

    /**
     * handle: '.dk-sort-handle' — тащим ТОЛЬКО за ручку.
     * filter: input/button/a/.dk-sort-value — клики по ним НЕ начинают drag,
     *         а инициируют inline-редактирование или открытие записи.
     * preventOnFilter: false — иначе клик был бы проглочен.
     */
    Sortable.create(list, {
        animation: 150,
        handle: '.dk-sort-handle',
        filter: 'input, a, button, .dk-sort-value',
        preventOnFilter: false,
        ghostClass: 'dk-sort-ghost',
        chosenClass: 'dk-sort-chosen',
        dragClass: 'dk-sort-drag',
        onUpdate: function () {
            // onUpdate срабатывает ПОСЛЕ того, как SortableJS переставил DOM.
            // Отправляем новый порядок на сервер.
            var ids = getIdsInOrder();
            if (!ids.length) return;
            postAjax('dk_tools_reorder', { ids: ids.join(',') })
                .then(function (json) {
                    if (json && json.success && json.data && json.data.values) {
                        syncFromServer(json.data.values);
                        reorderDomByInputs();   // на случай рассинхрона
                    }
                })
                .catch(function () { /* silent */ });
        }
    });

    // ---------- Click-to-edit: открытие input по клику на число ----------

    function bindValueEl(valEl) {
        valEl.addEventListener('click', function (e) {
            e.preventDefault();
            e.stopPropagation();   // чтобы SortableJS не начал drag
            openEditor(valEl);
        });
        valEl.addEventListener('keydown', function (e) {
            if (e.key === 'Enter' || e.key === ' ') {
                e.preventDefault();
                openEditor(valEl);
            }
        });
    }

    function openEditor(valEl) {
        var postId = parseInt(valEl.getAttribute('data-post-id'), 10);
        var input  = findInput(postId);
        if (!input) return;

        valEl.style.display = 'none';
        input.style.display = '';
        // 0 показываем как пусто, чтобы легче было вводить новое значение.
        input.value = (input.value === '0') ? '' : input.value;
        input.focus();
        input.select();
    }

    function closeEditor(valEl, input, commit) {
        if (commit) {
            if (input.value !== input.defaultValue) {
                saveOne(valEl, input);
                return;
            }
        } else {
            input.value = input.defaultValue;
        }
        valEl.style.display = '';
        input.style.display = 'none';
    }

    // ---------- Inline-сохранение ----------

    function saveOne(valEl, input) {
        var postId = parseInt(input.getAttribute('data-post-id'), 10);
        postAjax('dk_tools_save_sort', { post_id: postId, value: input.value })
            .then(function (json) {
                if (json && json.success) {
                    var saved = (json.data && typeof json.data.value !== 'undefined')
                                ? json.data.value
                                : input.value;
                    input.defaultValue = saved;
                    input.value = saved;
                    if (valEl) {
                        valEl.textContent = (saved === '' || saved === '0') ? '—' : saved;
                    }
                    flash(input, 'dk-sort-ok');
                    if (valEl) valEl.style.display = '';
                    input.style.display = 'none';
                    reorderDomByInputs();   // строка сама переедет на нужное место
                } else {
                    flash(input, 'dk-sort-err');
                }
            })
            .catch(function () { flash(input, 'dk-sort-err'); });
    }

    /**
     * Визуальная подсветка ячейки: зелёная при успехе, красная при ошибке.
     * Через 800 мс фон возвращается — работает чисто на CSS transition.
     */
    function flash(input, cls) {
        var td = input.closest('td');
        if (!td) return;
        td.classList.add(cls);
        setTimeout(function () { td.classList.remove(cls); }, 800);
    }

    // ---------- Привязка обработчиков к DOM ----------

    var valueEls = list.querySelectorAll('.dk-sort-value');
    for (var i = 0; i < valueEls.length; i++) {
        bindValueEl(valueEls[i]);
    }

    var inputs = list.querySelectorAll('.dk-sort-input');
    for (var j = 0; j < inputs.length; j++) {
        (function (input) {
            var postId = parseInt(input.getAttribute('data-post-id'), 10);
            var valEl  = findValueEl(postId);

            input.addEventListener('keydown', function (e) {
                if (e.key === 'Enter') {
                    e.preventDefault();
                    input.blur();   // blur запустит сохранение
                } else if (e.key === 'Escape') {
                    e.preventDefault();
                    closeEditor(valEl, input, false);
                }
            });

            input.addEventListener('blur', function () {
                // Если input уже скрыт (закрыли по Escape или после сохранения) — игнорируем.
                if (input.style.display === 'none') return;
                if (input.value === input.defaultValue) {
                    closeEditor(valEl, input, false);
                    return;
                }
                saveOne(valEl, input);
            });
        })(inputs[j]);
    }
})();
Почему onUpdate, а не addEventListener('sortupdate', ...)? Это частая ошибка: в SortableJS событие называется update и срабатывает на list, но безопаснее всего использовать колбэк в опциях Sortable.create. Так вы гарантированно получите вызов и не зависите от фазы всплытия.
Почему filter в SortableJS? Без него клик по input’у начинал бы drag, и редактирование было бы невозможно. filter говорит SortableJS: «эти элементы — не для drag», а preventOnFilter: false — «не глотай клик, пусть дойдёт до обработчика».

Шаг 9. Минимальные стили

Файл assets/css/tools-sort.css. Этого набора хватит для нормального отображения в любой теме, подключающей стандартный wp-admin.css.

/* Ручка для drag-and-drop: курсор grab + визуальный пожим при нажатии. */
.wp-list-table .dk-sort-handle {
    display: inline-block;
    width: 16px;
    margin-right: 6px;
    color: #8a8a8a;
    font-size: 14px;
    line-height: 1;
    user-select: none;
    cursor: grab;
}
.wp-list-table .dk-sort-handle:active { cursor: grabbing; }

/* "Закрытое" значение: выглядит кликабельным, но защищено от случайного редактирования. */
.wp-list-table .dk-sort-value {
    display: inline-block;
    min-width: 24px;
    padding: 2px 6px;
    border-radius: 3px;
    cursor: pointer;
}
.wp-list-table .dk-sort-value:hover  { background: #f0f6fc; color: #2271b1; }
.wp-list-table .dk-sort-value:focus  { outline: 2px solid #2271b1; outline-offset: 1px; }

/* Состояния drag-and-drop. */
.wp-list-table tr.dk-sort-ghost  { opacity: 0.4; background: #f0f6fc; }
.wp-list-table tr.dk-sort-chosen td { background: #f0f6fc; }
.wp-list-table tr.dk-sort-drag   td { background: #e5f1fb; }

/* Input редактирования. */
.dk-sort-input {
    width: 80px;
    text-align: center;
    padding: 3px 6px;
}

/* Flash-подсветка результата сохранения. */
td.dk-sort-ok  { background-color: #e6f4ea !important; transition: background-color 0.4s; }
td.dk-sort-err { background-color: #fce8e6 !important; transition: background-color 0.4s; }

Что получилось

После применения всех шагов у вас в админке на экране «Все инструменты» появится:

  1. Колонка «Сорт.» с числом и ручкой ⋮⋮ в каждой строке.
  2. Возможность перетаскивать строки мышью: хватаете за ручку, тащите — порядок сохраняется, числа пересчитываются автоматически.
  3. Возможность кликнуть на число, ввести новое значение и нажать Enter — строка сама встанет на нужное место (и на админке, и на публичной части сайта).
  4. Подсветка результата сохранения: зелёная вспышка — успех, красная — ошибка (например, попытка сохранить не-число).
  5. Заголовок колонки «Сорт.» кликабелен — сортировка по возрастанию/убыванию через стандартный механизм WordPress.

Всё это работает без перезагрузки страницы. Исключение — пагинация: записи на других страницах сохранят старые значения до тех пор, пока вы не перейдёте к ним и не откроете экран. Шаг 10 (нормализация всех записей разом) можно добавить, если у вас сотни записей и требуется абсолютная упорядоченность.

Подводные камни и рекомендации

1. meta_value vs meta_value_num

В orderby нужно указывать именно meta_value_num. Без _num сравнение идёт как строк, и «100» окажется меньше «20», потому что «1» < «2» посимвольно. Это самый частый баг при первой реализации.

2. Поле пустое — куда попадёт запись?

WordPress трактует записи с пустым meta_key особым образом: при сортировке по meta они оказываются либо в начале, либо в конце — поведение зависит от версии WP. Если хотите предсказуемости, задавайте значение по умолчанию (например, ID записи) при первом сохранении. В нашем коде мы этого не делаем, потому что пустое значение — осознанный способ «отодвинуть запись в конец».

3. Производительность на больших каталогах

Запрос с meta_key использует индекс wp_postmeta, если он есть. На каталоге в 10 000 записей всё летает; на 100 000 — стоит добавить индекс (meta_key, meta_value) через dbDelta или просто LIMIT’ом ограничить выборку в архиве.

4. Конфликт с другими плагинами

Если на странице edit.php для CPT tools уже подключён другой SortableJS (например, для медиа-сортировки), конфликта не будет — мы подключаем свою копию с уникальным хэндлом 'sortablejs'. Но если в другом плагине уже используется глобальный window.Sortable, наш Sortable.create всё равно сработает корректно, потому что SortableJS — это конструктор с изолированным состоянием.

5. Права доступа

Все AJAX-обработчики проверяют current_user_can('edit_post', $id). Это значит, что автор с правами edit_posts, но edit_others_posts = false, сможет переставить только свои записи. Чужие записи будут проигнорированы сервером без ошибки — клиент не узнает, что у него нет прав, и не увидит чужой порядок «поехавшим».

6. Минификация и кеш

SortableJS мы подключаем как есть. Если вы используете сборщик (Vite, esbuild), имеет смысл собрать tools-sort.js и sortable.min.js в один бандл. Но это уже выходит за рамки универсального решения — для большинства тем две отдельные загрузки через wp_enqueue_script достаточно.

7. Резервная копия

Перед массовой перестановкой имеет смысл один раз сделать дамп таблицы wp_postmeta с фильтром по meta_key = 'dk_tools_sort'. Если что-то пойдёт не так (плагин кеша вернул старую страницу, случайный клик «всё перемешал»), откат — одна команда:

UPDATE wp_postmeta
   SET meta_value = 'прежнее_значение'
 WHERE meta_key = 'dk_tools_sort' AND post_id = 123;

На этом всё. Решение покрывает 95% реальных сценариев: каталог услуг, портфолио, справочник инструментов, лента новостей с фиксированным порядком. Код не привязан к конкретной теме, не требует сборщика и не ломает стандартный WordPress — он только дополняет его одной колонкой, одним метабоксом и одной удобной механикой управления порядком.

Комментарии

Оставить комментарий

Имя видно всем. Сообщения проходят модерацию. Правила обсуждения.

Обычно отвечаю до 18:00 МСК

Вопрос по статье?

Напишите — разберём на вашем проекте.

Написать в Telegram Без автоответчика · пн–пт

Обсудить задачуОтвет в рабочий день

Написать