structure changes
This commit is contained in:
@@ -5,60 +5,86 @@ namespace Modules\Menus\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use JsonException;
|
||||
use Modules\Menus\Models\Menu;
|
||||
|
||||
final class MenuController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('menus::admin.index', ['records' => Menu::query()->latest()->paginate(20)]);
|
||||
return view('menus::admin.index', ['records' => Menu::query()->orderBy('location')->paginate(30)]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('menus::admin.form');
|
||||
return view('menus::admin.form', ['locations' => $this->locations()]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['name' => ['required', 'string', 'max:255'],
|
||||
'location' => ['required', 'string', 'max:255'],
|
||||
'items' => ['nullable', 'array'],
|
||||
'enabled' => ['sometimes', 'boolean']]);
|
||||
if ($request->filled('items_json')) {
|
||||
$data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
$data['enabled'] = $request->boolean('enabled');
|
||||
Menu::create($data);
|
||||
Menu::create($this->validated($request));
|
||||
|
||||
return redirect()->route('administration.menus.index')->with('status', 'Menu created.');
|
||||
return redirect()->route('administration.menus.index')->with('status', 'Menu created. Every header or footer using this location has been updated.');
|
||||
}
|
||||
|
||||
public function edit(Menu $menu): View
|
||||
{
|
||||
return view('menus::admin.form', ['record' => $menu]);
|
||||
return view('menus::admin.form', ['record' => $menu, 'locations' => $this->locations()]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Menu $menu): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['name' => ['required', 'string', 'max:255'],
|
||||
'location' => ['required', 'string', 'max:255'],
|
||||
'items' => ['nullable', 'array'],
|
||||
'enabled' => ['sometimes', 'boolean']]);
|
||||
if ($request->filled('items_json')) {
|
||||
$data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
$data['enabled'] = $request->boolean('enabled');
|
||||
$menu->update($data);
|
||||
$menu->update($this->validated($request));
|
||||
|
||||
return redirect()->route('administration.menus.index')->with('status', 'Menu updated.');
|
||||
return redirect()->route('administration.menus.index')->with('status', 'Menu updated everywhere it is used.');
|
||||
}
|
||||
|
||||
public function destroy(Menu $menu): RedirectResponse
|
||||
{
|
||||
$menu->delete();
|
||||
|
||||
return back()->with('status', 'Menu deleted.');
|
||||
return back()->with('status', 'Menu deleted. Components using its location will use their safe fallback.');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'location' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9_-]+$/'],
|
||||
'items_json' => ['nullable', 'string'],
|
||||
'enabled' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$items = json_decode($data['items_json'] ?: '[]', true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
throw ValidationException::withMessages(['items_json' => 'The menu items could not be saved. Please reload the editor and try again.']);
|
||||
}
|
||||
|
||||
if (! is_array($items)) {
|
||||
throw ValidationException::withMessages(['items_json' => 'Menu items must be a list.']);
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $data['name'],
|
||||
'location' => $data['location'],
|
||||
'items' => array_values($items),
|
||||
'enabled' => $request->boolean('enabled'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function locations(): array
|
||||
{
|
||||
return [
|
||||
'primary' => 'Primary navigation',
|
||||
'footer' => 'Footer — Explore',
|
||||
'footer_account' => 'Footer — Account',
|
||||
'footer_legal' => 'Footer — Legal',
|
||||
'mobile' => 'Mobile navigation override',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
<x-layouts.app :title="isset($record) ? __('Edit Menu') : __('Create Menu')">
|
||||
<div class="mx-auto max-w-4xl space-y-6"><div><h1 class="text-2xl font-semibold">{{ isset($record) ? 'Edit' : 'Create' }} Menu</h1></div><form method="POST" action="{{ isset($record) ? route('administration.menus.update', $record) : route('administration.menus.store') }}" class="space-y-5 rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">@csrf @isset($record) @method('PUT') @endisset
|
||||
<div><label class="mb-1 block text-sm font-medium">Name</label><input type="text" name="name" value="{{ old('name', $record->name ?? '') }}" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">@error('name')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
|
||||
<div><label class="mb-1 block text-sm font-medium">Location</label><input type="text" name="location" value="{{ old('location', $record->location ?? '') }}" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">@error('location')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
|
||||
<div><label class="mb-1 block text-sm font-medium">Items (JSON)</label><textarea name="items_json" rows="6" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">{{ old('items_json', isset($record) ? json_encode($record->items, JSON_PRETTY_PRINT) : '') }}</textarea></div>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="enabled" value="1" @checked(old('enabled', $record->enabled ?? true))><span>Enabled</span></label>
|
||||
<div class="flex gap-3"><button class="rounded-lg bg-zinc-900 px-4 py-2 text-white dark:bg-white dark:text-zinc-900">Save</button><a href="{{ route('administration.menus.index') }}" class="rounded-lg border px-4 py-2">Cancel</a></div></form></div>
|
||||
<x-layouts.app :title="isset($record) ? __('Edit menu') : __('Create menu')">
|
||||
@php($initialItems = old('items_json', json_encode($record->items ?? [], JSON_UNESCAPED_SLASHES)))
|
||||
<div class="mx-auto max-w-6xl space-y-6" x-data="menuEditor(@js(json_decode($initialItems ?: '[]', true) ?: []))">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-orange-600">Reusable navigation</p>
|
||||
<h1 class="text-3xl font-semibold">{{ isset($record) ? 'Edit' : 'Create' }} menu</h1>
|
||||
<p class="mt-2 text-sm text-zinc-500">Edit links once. Every header and footer assigned to this menu location updates automatically.</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ isset($record) ? route('administration.menus.update', $record) : route('administration.menus.store') }}" class="space-y-6">
|
||||
@csrf
|
||||
@isset($record) @method('PUT') @endisset
|
||||
|
||||
<section class="grid gap-5 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm md:grid-cols-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<label class="block"><span class="text-sm font-medium">Menu name</span><input type="text" name="name" value="{{ old('name', $record->name ?? '') }}" required class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"></label>
|
||||
<label class="block"><span class="text-sm font-medium">Menu location</span><select name="location" required class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">@foreach($locations as $value => $label)<option value="{{ $value }}" @selected(old('location', $record->location ?? 'primary') === $value)>{{ $label }}</option>@endforeach</select></label>
|
||||
<label class="flex items-center gap-3 md:col-span-2"><input type="checkbox" name="enabled" value="1" @checked(old('enabled', $record->enabled ?? true)) class="rounded"><span class="font-medium">Menu enabled</span></label>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="mb-5 flex items-center justify-between gap-4"><div><h2 class="text-lg font-semibold">Menu items</h2><p class="text-sm text-zinc-500">Relative paths such as <code>/blog</code>, anchors and full external URLs are supported.</p></div><button type="button" @click="addItem()" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white dark:bg-white dark:text-zinc-900">Add link</button></div>
|
||||
<input type="hidden" name="items_json" :value="JSON.stringify(items)">
|
||||
@error('items_json')<p class="mb-4 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
|
||||
<div class="space-y-4">
|
||||
<template x-for="(item, index) in items" :key="item._key">
|
||||
<article class="rounded-xl border border-zinc-200 p-5 dark:border-zinc-700">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3"><strong x-text="item.label || `Link ${index + 1}`"></strong><div class="flex gap-2"><button type="button" @click="move(index,-1)" :disabled="index===0" class="rounded border px-3 py-1 disabled:opacity-30">↑</button><button type="button" @click="move(index,1)" :disabled="index===items.length-1" class="rounded border px-3 py-1 disabled:opacity-30">↓</button><button type="button" @click="removeItem(index)" class="rounded border border-red-400 px-3 py-1 text-red-600">Remove</button></div></div>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<label><span class="text-sm font-medium">Label</span><input type="text" x-model="item.label" class="mt-2 w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"></label>
|
||||
<label class="lg:col-span-2"><span class="text-sm font-medium">URL or path</span><input type="text" x-model="item.url" placeholder="/, /blog, #contact or https://…" class="mt-2 w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"></label>
|
||||
<label><span class="text-sm font-medium">Open in</span><select x-model="item.target" class="mt-2 w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option value="">Same window</option><option value="_blank">New window</option></select></label>
|
||||
<label class="flex items-center gap-3"><input type="checkbox" x-model="item.enabled" class="rounded"><span class="text-sm font-medium">Visible</span></label>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<div x-show="items.length===0" class="rounded-xl border border-dashed border-zinc-300 p-10 text-center text-sm text-zinc-500 dark:border-zinc-700">This menu has no links. Add the first link above.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-end gap-3"><a href="{{ route('administration.menus.index') }}" class="rounded-lg border px-4 py-2">Cancel</a><flux:button type="submit" variant="primary">Save menu</flux:button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function menuEditor(initialItems) {
|
||||
return {
|
||||
items: (initialItems || []).map(item => ({_key: crypto.randomUUID(), enabled: item.enabled !== false, label: item.label || '', url: item.url || '', target: item.target || ''})),
|
||||
addItem() { this.items.push({_key: crypto.randomUUID(), enabled: true, label: 'New link', url: '/', target: ''}); },
|
||||
removeItem(index) { if (confirm('Remove this menu item?')) this.items.splice(index, 1); },
|
||||
move(index, direction) { const target=index+direction; if(target<0||target>=this.items.length)return; [this.items[index],this.items[target]]=[this.items[target],this.items[index]]; },
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<x-layouts.app :title="__('Menus')">
|
||||
<div class="space-y-6"><div class="flex items-center justify-between"><div><h1 class="text-2xl font-semibold">Menus</h1><p class="text-sm text-zinc-500">Manage menus content.</p></div><a href="{{ route('administration.menus.create') }}" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white dark:bg-white dark:text-zinc-900">Add new</a></div>
|
||||
@if(session('status'))<div class="rounded-lg bg-emerald-50 p-3 text-emerald-800">{{ session('status') }}</div>@endif
|
||||
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"><table class="w-full text-sm"><thead class="bg-zinc-50 dark:bg-zinc-800"><tr><th class="px-4 py-3 text-left">Name</th><th class="px-4 py-3 text-left">Location</th><th class="px-4 py-3 text-left">Items</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">@forelse($records as $record)<tr><td class="px-4 py-3">{{ is_array($record->name) ? json_encode($record->name) : Str::limit((string) $record->name, 70) }}</td><td class="px-4 py-3">{{ is_array($record->location) ? json_encode($record->location) : Str::limit((string) $record->location, 70) }}</td><td class="px-4 py-3">{{ is_array($record->items) ? json_encode($record->items) : Str::limit((string) $record->items, 70) }}</td><td class="px-4 py-3 text-right"><a class="mr-3 underline" href="{{ route('administration.menus.edit', $record) }}">Edit</a><form class="inline" method="POST" action="{{ route('administration.menus.destroy', $record) }}">@csrf @method('DELETE')<button class="text-red-600" onclick="return confirm('Delete this record?')">Delete</button></form></td></tr>@empty<tr><td colspan="4" class="px-4 py-10 text-center text-zinc-500">No records yet.</td></tr>@endforelse</tbody></table></div>{{ $records->links() }}</div>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-sm font-medium text-orange-600">Single source of navigation</p><h1 class="text-3xl font-semibold">Menus</h1><p class="mt-2 text-sm text-zinc-500">Reusable menus update every header and footer that references their location.</p></div><div class="flex gap-3"><a href="{{ route('administration.settings.index') }}" class="rounded-lg border px-4 py-2 text-sm">Site settings</a><a href="{{ route('administration.menus.create') }}" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white dark:bg-white dark:text-zinc-900">Add menu</a></div></div>
|
||||
@if(session('status'))<div class="rounded-xl bg-emerald-50 p-4 text-sm text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">{{ session('status') }}</div>@endif
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@forelse($records as $record)
|
||||
<article class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="flex items-start justify-between gap-4"><div><span class="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium dark:bg-zinc-800">{{ $record->location }}</span><h2 class="mt-3 text-lg font-semibold">{{ $record->name }}</h2><p class="mt-1 text-sm text-zinc-500">{{ count($record->items ?? []) }} links · {{ $record->enabled ? 'Enabled' : 'Disabled' }}</p></div></div><div class="mt-5 flex gap-3"><a class="rounded-lg border px-3 py-2 text-sm" href="{{ route('administration.menus.edit', $record) }}">Edit menu</a><form method="POST" action="{{ route('administration.menus.destroy', $record) }}">@csrf @method('DELETE')<button class="rounded-lg px-3 py-2 text-sm text-red-600" onclick="return confirm('Delete this menu?')">Delete</button></form></div></article>
|
||||
@empty
|
||||
<div class="col-span-full rounded-2xl border border-dashed border-zinc-300 p-12 text-center dark:border-zinc-700"><h2 class="font-semibold">No menus yet</h2><p class="mt-1 text-sm text-zinc-500">Run the Saasland installer or create your first reusable menu.</p></div>
|
||||
@endforelse
|
||||
</div>
|
||||
{{ $records->links() }}
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Modules\Menus\Models\Menu;
|
||||
|
||||
it('normalises reusable menu items through the navigation service', function (): void {
|
||||
Menu::query()->create(['name' => 'Primary', 'location' => 'primary', 'enabled' => true, 'items' => [['enabled' => true, 'label' => 'Home', 'url' => '/']]]);
|
||||
expect(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary())->toHaveCount(1)->and(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary()[0]['label'])->toBe('Home');
|
||||
});
|
||||
Reference in New Issue
Block a user