Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.00% covered (warning)
65.00%
39 / 60
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
MenuController
65.00% covered (warning)
65.00%
39 / 60
33.33% covered (danger)
33.33%
2 / 6
31.89
0.00% covered (danger)
0.00%
0 / 1
 index
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 store
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 edit
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 update
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
9
 destroy
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 linkableRoutes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace App\Http\Controllers\admin;
4
5use App\Http\Controllers\Controller;
6use App\Models\Category;
7use App\Models\Menu;
8use App\Models\MenuItem;
9use App\Models\Page;
10use App\Services\Builder\MenuService;
11use Illuminate\Http\RedirectResponse;
12use Illuminate\Http\Request;
13use Illuminate\Support\Facades\DB;
14use Illuminate\Validation\Rule;
15use Illuminate\View\View;
16
17/**
18 * Settings → Menus. A WordPress-style menu builder: create a menu, drop
19 * links into it, drag to reorder / nest, then assign it to a location
20 * (config/builder.php). The storefront renders it via <x-menu location="…">.
21 */
22class MenuController extends Controller
23{
24    public function index(): View
25    {
26        return view('admin.menus.index', [
27            'menus' => Menu::withCount('items')->orderBy('name')->get(),
28            'locations' => config('builder.menu_locations', []),
29        ]);
30    }
31
32    public function store(Request $request): RedirectResponse
33    {
34        $data = $request->validate(['name' => ['required', 'string', 'max:80']]);
35
36        $menu = Menu::create(['name' => $data['name']]);
37
38        return redirect()->route('admin.menus.edit', $menu)->with('success', 'Menu created — now add some links.');
39    }
40
41    public function edit(Menu $menu): View
42    {
43        $menu->load(['items' => fn ($q) => $q->orderBy('sort_order')]);
44
45        return view('admin.menus.edit', [
46            'menu' => $menu,
47            'tree' => app(MenuService::class)->forHandle($menu->handle),
48            'locations' => config('builder.menu_locations', []),
49            'categories' => Category::orderBy('name')->pluck('name', 'id'),
50            'pages' => Page::where('status', 'published')->orderByRaw('position is null')->orderBy('position')->orderBy('title')->pluck('title', 'slug'),
51            'routeChoices' => $this->linkableRoutes(),
52        ]);
53    }
54
55    public function update(Request $request, Menu $menu): RedirectResponse
56    {
57        $data = $request->validate([
58            'name' => ['required', 'string', 'max:80'],
59            'location' => ['nullable', Rule::in(array_keys(config('builder.menu_locations', [])))],
60            'items' => ['array'],
61            'items.*.id' => ['nullable', 'integer'],
62            'items.*.label' => ['required_with:items', 'string', 'max:120'],
63            'items.*.type' => ['required_with:items', Rule::in(['url', 'route', 'category', 'page'])],
64            'items.*.value' => ['nullable', 'string', 'max:255'],
65            'items.*.depth' => ['nullable', 'integer', 'min:0', 'max:1'],
66            'items.*.new_tab' => ['nullable', 'boolean'],
67        ]);
68
69        // Free this menu's location if another menu already holds it.
70        if (! empty($data['location'])) {
71            Menu::where('location', $data['location'])->where('id', '!=', $menu->id)->update(['location' => null]);
72        }
73
74        DB::transaction(function () use ($menu, $data) {
75            $menu->update(['name' => $data['name'], 'location' => $data['location'] ?? null]);
76
77            $submitted = $data['items'] ?? [];
78            $keepIds = collect($submitted)->pluck('id')->filter()->all();
79            $menu->items()->whereNotIn('id', $keepIds ?: [0])->delete();
80
81            $lastTopLevelId = null;
82            foreach (array_values($submitted) as $i => $row) {
83                $isChild = (int) ($row['depth'] ?? 0) === 1 && $lastTopLevelId !== null;
84
85                $attributes = [
86                    'menu_id' => $menu->id,
87                    'parent_id' => $isChild ? $lastTopLevelId : null,
88                    'label' => $row['label'],
89                    'type' => $row['type'],
90                    'value' => $row['value'] ?? null,
91                    'new_tab' => (bool) ($row['new_tab'] ?? false),
92                    'sort_order' => $i,
93                ];
94
95                $existing = ! empty($row['id']) ? $menu->items()->find($row['id']) : null;
96                $item = $existing ? tap($existing)->update($attributes) : MenuItem::create($attributes);
97
98                if (! $isChild) {
99                    $lastTopLevelId = $item->id;
100                }
101            }
102        });
103
104        return redirect()->route('admin.menus.edit', $menu)->with('success', 'Menu saved.');
105    }
106
107    public function destroy(Menu $menu): RedirectResponse
108    {
109        $menu->delete();
110
111        return redirect()->route('admin.menus.index')->with('success', 'Menu deleted.');
112    }
113
114    /** Route names safe to link to from a menu (named GET routes, no params). */
115    private function linkableRoutes(): array
116    {
117        return collect(app('router')->getRoutes())
118            ->filter(fn ($r) => in_array('GET', $r->methods()) && $r->getName() && ! str_contains($r->uri(), '{')
119                && ! str_starts_with($r->uri(), 'admin') && ! str_starts_with((string) $r->getName(), 'admin.'))
120            ->mapWithKeys(fn ($r) => [$r->getName() => $r->getName() . '  (/' . $r->uri() . ')'])
121            ->sort()
122            ->all();
123    }
124}