В этой статье — готовое универсальное решение для WordPress, которое превращает скучный список записей в админке в интерактивный инструмент: записи можно переставлять мышью, а при необходимости — править значение сортировки прямо в колонке, не открывая страницу редактирования. Разберём путь от регистрации CPT и метабокса до полностью рабочего drag-and-drop с автосохранением через AJAX.
Постановка задачи и желаемый результат
Представьте типичный сайт-каталог: «инструменты», «плагины», «проекты», «услуги» — что угодно, где записи выводятся в определённом порядке. По умолчанию в WordPress записи сортируются по дате публикации, и поменять их местами — значит открыть каждую запись, выставить дату, сохранить, повторить. Это мучительно.
Нам хочется двух вещей сразу:
- Drag-and-drop в списке админки. Хватаем строку мышью за специальную ручку, тащим вверх или вниз, отпускаем — порядок сохраняется. Без перезагрузки, без диалоговых окон, без открытия записей.
- Inline-редактирование значения сортировки. Иногда нужно задать точное число — например, «поставить эту запись между 80 и 60». Клик по числу в колонке превращает его в input, вводим значение, нажимаем Enter — строка сама встаёт на нужное место.
Желаемый результат выглядит так:
- В колонке «Сорт.» отображается числовое значение, защищённое от случайного изменения: править можно только по явному клику.
- Слева от числа — ручка
⋮⋮, за которую строку можно перетаскивать. - При drop’е отправляется один AJAX-запрос с массивом ID; сервер присваивает значения 10, 20, 30… (или 80, 70, 60… в зависимости от направления) и обновляет мета-поле.
- При inline-сохранении — то же самое, но для одной записи, после чего DOM пересортировывается мгновенно, без перезагрузки.
- На публичной части сайта записи сортируются по этому же полю, по убыванию: большее значение — выше.
- Код универсален: работает в любой теме, не зависит от её структуры папок и не требует сборщика.
Шаг 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);
});
Шаг 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;
});
Шаг 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));
});
Шаг 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. Содержит четыре логических блока:
- Инициализация SortableJS на
#the-listс колбэкомonUpdate. - Click-to-edit: клик по
.dk-sort-valueпоказывает скрытый.dk-sort-input. - Сохранение через AJAX: Enter или blur у input’а, Esc — отмена.
- Мгновенная перестановка 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; }
Что получилось
После применения всех шагов у вас в админке на экране «Все инструменты» появится:
- Колонка «Сорт.» с числом и ручкой
⋮⋮в каждой строке. - Возможность перетаскивать строки мышью: хватаете за ручку, тащите — порядок сохраняется, числа пересчитываются автоматически.
- Возможность кликнуть на число, ввести новое значение и нажать Enter — строка сама встанет на нужное место (и на админке, и на публичной части сайта).
- Подсветка результата сохранения: зелёная вспышка — успех, красная — ошибка (например, попытка сохранить не-число).
- Заголовок колонки «Сорт.» кликабелен — сортировка по возрастанию/убыванию через стандартный механизм 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 — он только дополняет его одной колонкой, одним метабоксом и одной удобной механикой управления порядком.
Комментарии
Оставить комментарий