diff --git a/src/CP/Navigation/NavTransformer.php b/src/CP/Navigation/NavTransformer.php index 2f1f8001ebc..c90ad400d6a 100644 --- a/src/CP/Navigation/NavTransformer.php +++ b/src/CP/Navigation/NavTransformer.php @@ -281,6 +281,30 @@ protected function getReorderedItems($originalList, $newList): bool|array */ protected function calculateMinimumItemsForReorder($originalList, $newList): int { + // When the new list contains items not in the original (e.g., custom sections), + // we need to include enough items to establish where those new items are positioned, + // BUT only if they appear in the middle of the list. Items appended at the end + // don't need to be explicitly positioned. + + $originalSet = collect($originalList); + $lastCustomPositionInMiddle = 0; + + foreach ($newList as $index => $item) { + if (! $originalSet->contains($item)) { + // Check if there are any original items after this custom item + $hasOriginalItemsAfter = collect($newList) + ->slice($index + 1) + ->contains(fn ($futureItem) => $originalSet->contains($futureItem)); + + // Only track this position if there are original items after it + // (meaning it's in the middle, not at the end) + if ($hasOriginalItemsAfter) { + $lastCustomPositionInMiddle = $index + 1; + } + } + } + + // Use the original algorithm for reordering existing items $continueRejecting = true; $minimumItemsCount = collect($originalList) @@ -295,7 +319,11 @@ protected function calculateMinimumItemsForReorder($originalList, $newList): int }) ->count(); - return max(1, $minimumItemsCount - 1); + $minimumFromReordering = max(1, $minimumItemsCount - 1); + + // If we have custom items in the middle of the list, we need to include + // enough items to establish their position. Return the maximum of the two. + return max($minimumFromReordering, $lastCustomPositionInMiddle); } /** @@ -329,7 +357,7 @@ protected function minify() ->values() ->all(); - $this->config = $reorder + $this->config = ! empty($reorder) ? array_filter(compact('reorder', 'sections')) : $sections; diff --git a/tests/CP/Navigation/NavTransformerTest.php b/tests/CP/Navigation/NavTransformerTest.php index fa867884bbc..54c1a629f4b 100644 --- a/tests/CP/Navigation/NavTransformerTest.php +++ b/tests/CP/Navigation/NavTransformerTest.php @@ -1315,6 +1315,60 @@ public function it_can_transform_complex_json_payload_copied_from_actual_vue_sub $this->assertEquals($expected, $transformed); } + + #[Test] + public function it_preserves_reorder_array_when_moving_custom_section_to_first_position() + { + // This test reproduces a bug where moving a custom section to the first position + // (after "Top Level") results in the reorder array being dropped during the minifying + // process, even though it is necessary to maintain the custom section's position. + + $transformed = $this->transform([ + ['display_original' => 'Top Level'], + [ + 'display' => '⭐ Favorites', + 'action' => '@create', + 'items' => [ + [ + 'id' => 'favorites::edit_homepage', + 'manipulations' => [ + 'action' => '@create', + 'display' => 'Edit homepage', + 'url' => '/cp', + 'icon' => 'edit', + ], + ], + ], + ], + ['display_original' => 'Content'], + ['display_original' => 'Fields'], + ['display_original' => 'Tools'], + ['display_original' => 'Settings'], + ['display_original' => 'Users'], + ]); + + $expected = [ + 'reorder' => [ + 'favorites', + ], + 'sections' => [ + 'favorites' => [ + 'display' => '⭐ Favorites', + 'action' => '@create', + 'items' => [ + 'favorites::edit_homepage' => [ + 'action' => '@create', + 'display' => 'Edit homepage', + 'url' => '/cp', + 'icon' => 'edit', + ], + ], + ], + ], + ]; + + $this->assertEquals($expected, $transformed); + } } class IncrementalIdHasher