Feature Request - Child product ordering via drag-and-drop in type C (Multi Variant) customfield panel
VirtueMart version: 4.6.8 (also checked on 4.6.6 and 4.9.3)
Joomla version: 5.4.6
PHP: 8.1 - 8.5
Summary
When a product uses a type C "Multi Variant" customfield, the child product table in the backend panel provides no way to reorder variants. The display order is determined by sortChildIds() which sorts by option combinations - not by the merchant's preferred order. Reordering is a common need (e.g. keeping size variants sorted smallest to largest, or putting the most popular variant first).
Current behaviour
The child table in the type C panel renders with a vmicon-16-move handle in each row and jQuery UI Sortable is already initialised on #syncro (the tbody). Dragging rows works visually but nothing is ever saved - the update callback is absent and no pordering value is submitted with the form.
After a save, sortChildIds() re-sorts children by their option combination order, overriding whatever was in the database.
A second issue: the parent product (which is itself a variant in type C products) is always forced to the first row via array_unshift, regardless of drag order. Its pordering is also never saved because the childs save loop in product.php skips entries where $productId == $data['virtuemart_product_id'].
Expected behaviour
Dragging any row (including the parent variant) should update and persist the display order. The frontend already uses ORDER BY pordering ASC via getAllProductChildIds() - the infrastructure is in place. Only the admin side is missing.
Fix - three changes
1. models/customfields.php - add hidden pordering input in renderProductChildLine() (first <td>, before the link):
$html .= '<td style="white-space:nowrap;">'
. '<span class="vmicon vmicon-16-move"
style="cursor:move;font-size:20px;color:#666;vertical-align:middle;margin:0 6px 0 5px;"
title="Reorder">⋮⋮</span>'
. JHTML::_('link', ...)
. '<input type="hidden" class="vmchild-ordering"
name="childs[' . $child->virtuemart_product_id . '][pordering]"
value="' . (int)($child->pordering ?? 0) . '" />'
. '</td>';
2. models/customfields.php - replace sortChildIds + array_unshift block with pordering-aware sort:
if (isset($childIds[$product_id])) {
$sorted = self::sortChildIds($product_id, $childIds[$product_id], $field->options);
$allIds = array_merge([$product_id], array_values($childIds[$product_id]));
$db = JFactory::getDBO();
$db->setQuery('SELECT virtuemart_product_id, pordering FROM #__virtuemart_products'
. ' WHERE virtuemart_product_id IN (' . implode(',', array_map('intval', $allIds)) . ')');
$porderingMap = $db->loadAssocList('virtuemart_product_id', 'pordering');
$sorted[] = ['parent_id' => $product_id, 'vm_product_id' => $product_id]; // parent included
usort($sorted, function ($a, $b) use ($porderingMap) {
$posA = isset($porderingMap[$a['vm_product_id']]) ? (int)$porderingMap[$a['vm_product_id']] : 999;
$posB = isset($porderingMap[$b['vm_product_id']]) ? (int)$porderingMap[$b['vm_product_id']] : 999;
return $posA - $posB;
});
} else {
$sorted[] = ['parent_id' => $product_id, 'vm_product_id' => $product_id];
}
// array_unshift(...) removed - parent is now part of the sorted array
3. models/customfields.php - add update callback to the #syncro sortable (existing JS block):
jQuery(document).ready(function ($) {
$('#syncro').sortable({
cursorAt: { top: 0, left: 0 },
handle: '.vmicon-16-move',
update: function (event, ui) {
$(this).find('.vmchild-ordering').each(function (index, el) {
$(el).val(index);
});
}
});
$('#syncro .vmchild-ordering').each(function (index, el) {
$(el).val(index);
});
});
4. models/product.php - save parent's pordering when submitted from the type C panel:
In the foreach ($data['childs'] as $productId => $child) loop, add an else branch for the case where $productId == $data['virtuemart_product_id']:
} else {
// Parent variant: only update pordering if explicitly submitted
if (isset($child['pordering'])) {
$db = JFactory::getDBO();
$db->setQuery('UPDATE #__virtuemart_products SET pordering = '
. (int)$child['pordering']
. ' WHERE virtuemart_product_id = ' . (int)$productId);
$db->execute();
}
}
Why it matters
Merchants managing products with several size or format variants (e.g. 100 cm / 130 cm / 150 cm / 200 cm / 250 cm) have no control over which variant appears first in the selector on the product page. Making the existing drag infrastructure actually save the order would be a low-risk, high-value improvement. The pordering field and the ORDER BY pordering clause are already in place on the frontend side - the admin panel simply never writes to them.
Thanks again for reading this...