diff --git a/features/menu-item.feature b/features/menu-item.feature index c5a040568..6d4cc407d 100644 --- a/features/menu-item.feature +++ b/features/menu-item.feature @@ -195,6 +195,52 @@ Feature: Manage WordPress menu items | custom | First | 1 | https://first.com | | custom | Third | 2 | https://third.com | + Scenario: Menu order is recalculated on update + When I run `wp menu create "Sidebar Menu"` + Then STDOUT should not be empty + + When I run `wp menu item add-custom sidebar-menu Alpha https://alpha.com --porcelain` + Then save STDOUT as {ITEM_ID_1} + + When I run `wp menu item add-custom sidebar-menu Beta https://beta.com --porcelain` + Then save STDOUT as {ITEM_ID_2} + + When I run `wp menu item add-custom sidebar-menu Gamma https://gamma.com --porcelain` + Then save STDOUT as {ITEM_ID_3} + + When I run `wp menu item list sidebar-menu --fields=type,title,position,link` + Then STDOUT should be a table containing rows: + | type | title | position | link | + | custom | Alpha | 1 | https://alpha.com | + | custom | Beta | 2 | https://beta.com | + | custom | Gamma | 3 | https://gamma.com | + + When I run `wp menu item update {ITEM_ID_3} --position=1` + Then STDOUT should be: + """ + Success: Menu item updated. + """ + + When I run `wp menu item list sidebar-menu --fields=type,title,position,link` + Then STDOUT should be a table containing rows: + | type | title | position | link | + | custom | Gamma | 1 | https://gamma.com | + | custom | Alpha | 2 | https://alpha.com | + | custom | Beta | 3 | https://beta.com | + + When I run `wp menu item update {ITEM_ID_1} --position=3` + Then STDOUT should be: + """ + Success: Menu item updated. + """ + + When I run `wp menu item list sidebar-menu --fields=type,title,position,link` + Then STDOUT should be a table containing rows: + | type | title | position | link | + | custom | Gamma | 1 | https://gamma.com | + | custom | Beta | 2 | https://beta.com | + | custom | Alpha | 3 | https://alpha.com | + Scenario: Get menu item details When I run `wp menu create "Sidebar Menu"` Then STDOUT should not be empty diff --git a/src/Menu_Item_Command.php b/src/Menu_Item_Command.php index 94a1ea3f3..2fa97edfa 100644 --- a/src/Menu_Item_Command.php +++ b/src/Menu_Item_Command.php @@ -634,7 +634,71 @@ private function add_or_update_item( $method, $type, $args, $assoc_args ) { } $menu_item_args['menu-item-type'] = $type; - $result = wp_update_nav_menu_item( $menu->term_id, $menu_item_db_id, $menu_item_args ); + $pending_menu_order_updates = []; + + // Reorder other menu items when the position changes on update. + if ( 'update' === $method ) { + $new_position = (int) $menu_item_args['menu-item-position']; + if ( $new_position > 0 ) { + // Fetch all menu items sorted by their raw menu_order to determine + // normalized (1-indexed) ranks, since wp_get_nav_menu_items(ARRAY_A) + // normalises menu_order to 1,2,3… which may differ from the raw DB values. + $sorted_item_ids = get_posts( + [ + 'post_type' => 'nav_menu_item', + 'numberposts' => -1, + 'orderby' => 'menu_order', + 'order' => 'ASC', + 'post_status' => 'any', + 'tax_query' => [ + [ + 'taxonomy' => 'nav_menu', + 'field' => 'term_taxonomy_id', + 'terms' => $menu->term_taxonomy_id, + ], + ], + 'fields' => 'ids', + ] + ); + + // Normalise to integers so that strict comparisons below work regardless of + // whether $wpdb->get_col() returned strings or integers. + $sorted_item_ids = array_map( 'intval', $sorted_item_ids ); + + // Clamp the requested position to the valid range of menu items. + $max_position = count( $sorted_item_ids ); + if ( $max_position > 0 && $new_position > $max_position ) { + // Treat out-of-range positions as "move to end", consistent with core behavior. + $new_position = $max_position; + } + + // Find the 1-indexed normalized rank of the item being moved. + $item_idx = array_search( (int) $menu_item_db_id, $sorted_item_ids, true ); + $old_position_normalized = ( false !== $item_idx ) ? $item_idx + 1 : 0; + + if ( $old_position_normalized > 0 && $new_position !== $old_position_normalized ) { + if ( $new_position < $old_position_normalized ) { + // Moving up: items at 0-indexed [new_pos-1, old_pos-2] shift down by +1. + for ( $i = $new_position - 1; $i <= $old_position_normalized - 2; $i++ ) { + $pending_menu_order_updates[] = [ + 'ID' => $sorted_item_ids[ $i ], + 'menu_order' => $i + 2, + ]; + } + } else { + // Moving down: items at 0-indexed [old_pos, new_pos-1] shift up by -1. + for ( $i = $old_position_normalized; $i <= $new_position - 1; $i++ ) { + $pending_menu_order_updates[] = [ + 'ID' => $sorted_item_ids[ $i ], + 'menu_order' => $i, + ]; + } + } + } + } + } + + $result = wp_update_nav_menu_item( $menu->term_id, $menu_item_db_id, $menu_item_args ); if ( is_wp_error( $result ) ) { WP_CLI::error( $result->get_error_message() ); @@ -645,6 +709,26 @@ private function add_or_update_item( $method, $type, $args, $assoc_args ) { WP_CLI::error( "Couldn't update menu item." ); } } else { + // Apply deferred reordering of other menu items only after a successful update. + if ( ! empty( $pending_menu_order_updates ) ) { + global $wpdb; + + $ids_to_update = []; + $case_clauses = ''; + foreach ( $pending_menu_order_updates as $update_args ) { + $item_id = (int) $update_args['ID']; + $ids_to_update[] = $item_id; + $case_clauses .= $wpdb->prepare( ' WHEN %d THEN %d', $item_id, $update_args['menu_order'] ); + } + + $ids_sql = implode( ',', $ids_to_update ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $case_clauses and $ids_sql are constructed from prepared/safe integer values. + $wpdb->query( "UPDATE {$wpdb->posts} SET menu_order = CASE ID {$case_clauses} END WHERE ID IN ({$ids_sql})" ); + + foreach ( $ids_to_update as $id ) { + clean_post_cache( $id ); + } + } if ( ( 'add' === $method ) && $menu_item_args['menu-item-position'] ) { $this->reorder_menu_items( $menu->term_id, $menu_item_args['menu-item-position'], +1, $result );