Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions app/Models/MenuItem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Spatie\EloquentSortable\Sortable;
use Spatie\EloquentSortable\SortableTrait;

class MenuItem extends Model implements Sortable
{
use SortableTrait;

protected $table = 'menu_items';

protected $fillable = [
'menu_section_id',
'label',
'label_key',
'label_overrides',
'url',
'route_name',
'route_params',
'open_in_new_tab',
'is_active',
'sort_order',
];

protected $casts = [
'label_overrides' => 'array',
'route_params' => 'array',
'open_in_new_tab' => 'bool',
'is_active' => 'bool',
'sort_order' => 'int',
];

public $sortable = [
'order_column_name' => 'sort_order',
'sort_when_creating' => true,
'sort_on_has_many' => true,
];

public function section(): BelongsTo
{
return $this->belongsTo(MenuSection::class, 'menu_section_id');
}

public function resolvedLabel(?string $locale = null): string
{
$locale = $locale ?: app()->getLocale();

$override = $this->label_overrides[$locale] ?? null;
if (is_string($override) && trim($override) !== '') {
return $override;
}

if (is_string($this->label_key) && $this->label_key !== '') {
return __($this->label_key);
}

return (string) ($this->label ?? '');
}

public function resolvedHref(): ?string
{
if (is_string($this->route_name) && $this->route_name !== '') {
$params = is_array($this->route_params) ? $this->route_params : [];

if (Route::has($this->route_name)) {
return route($this->route_name, $params);
}
}

if (is_string($this->url) && $this->url !== '') {
return $this->url;
}

return null;
}

protected static function booted(): void
{
static::saved(function (self $item) {
$location = $item->section?->location;
if ($location) {
Cache::forget("menus.location.{$location}.v1");
}
});

static::deleted(function (self $item) {
$location = $item->section?->location;
if ($location) {
Cache::forget("menus.location.{$location}.v1");
}
});
}
}

52 changes: 52 additions & 0 deletions app/Models/MenuSection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Cache;
use Spatie\EloquentSortable\Sortable;
use Spatie\EloquentSortable\SortableTrait;

class MenuSection extends Model implements Sortable
{
use SortableTrait;

protected $table = 'menu_sections';

protected $fillable = [
'location',
'column',
'title',
'title_key',
'sort_order',
'is_active',
];

protected $casts = [
'is_active' => 'bool',
'sort_order' => 'int',
];

public $sortable = [
'order_column_name' => 'sort_order',
'sort_when_creating' => true,
];

public function items(): HasMany
{
return $this->hasMany(MenuItem::class, 'menu_section_id');
}

protected static function booted(): void
{
static::saved(function (self $section) {
Cache::forget("menus.location.{$section->location}.v1");
});

static::deleted(function (self $section) {
Cache::forget("menus.location.{$section->location}.v1");
});
}
}

101 changes: 101 additions & 0 deletions app/Nova/MenuItem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

namespace App\Nova;

use App\Models\MenuItem as MenuItemModel;
use Illuminate\Http\Request;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\Boolean;
use Laravel\Nova\Fields\Code;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\KeyValue;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
use Outl1ne\NovaSortable\Traits\HasSortableRows;

class MenuItem extends Resource
{
use HasSortableRows;

public static $model = MenuItemModel::class;

public static $title = 'id';

public static $search = [
'id',
'label',
'label_key',
'url',
'route_name',
];

public static $displayInNavigation = false;

public function title(): string
{
$label = $this->resource?->resolvedLabel() ?: "Item #{$this->id}";
$href = $this->resource?->resolvedHref();
$hrefPart = $href ? " · {$href}" : '';

return $label.$hrefPart;
}

public function fields(Request $request): array
{
return [
ID::make()->sortable(),

BelongsTo::make('Section', 'section', MenuSection::class)
->sortable()
->rules('required'),

Text::make('Label Key', 'label_key')
->nullable()
->rules('nullable', 'max:255')
->help('Preferred. Example: menu.webinars'),

Text::make('Label (literal)', 'label')
->nullable()
->rules('nullable', 'max:255')
->help('Used only if Label Key is empty (and no locale override exists).'),

KeyValue::make('Label Overrides', 'label_overrides')
->keyLabel('Locale')
->valueLabel('Label')
->nullable()
->rules('nullable')
->help('Optional per-locale labels. Example key: en, fr, de'),

Text::make('Route Name', 'route_name')
->nullable()
->rules('nullable', 'max:255')
->help('Preferred for internal links, e.g. educational-resources'),

Code::make('Route Params', 'route_params')
->json()
->nullable()
->rules('nullable')
->help('Optional JSON object, e.g. {"slug":"xyz"}'),

Text::make('URL', 'url')
->nullable()
->rules('nullable', 'max:2048')
->help('Use for external links or hardcoded internal paths like /podcasts'),

Boolean::make('Open in new tab', 'open_in_new_tab')->default(false),
Boolean::make('Active', 'is_active')->default(true),

Text::make('Preview', function () {
$label = $this->resource?->resolvedLabel() ?: '';
$href = $this->resource?->resolvedHref() ?: '';
return trim($label.' '.$href);
})->onlyOnDetail(),
];
}

public static function indexQuery(NovaRequest $request, $query)
{
return $query->orderBy('sort_order')->orderBy('id');
}
}

91 changes: 91 additions & 0 deletions app/Nova/MenuSection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace App\Nova;

use App\Models\MenuSection as MenuSectionModel;
use Illuminate\Http\Request;
use Laravel\Nova\Fields\Boolean;
use Laravel\Nova\Fields\HasMany;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Select;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
use Outl1ne\NovaSortable\Traits\HasSortableRows;

class MenuSection extends Resource
{
use HasSortableRows;

public static $group = 'Site';

public static $model = MenuSectionModel::class;

public static $title = 'id';

public static $search = [
'id',
'location',
'column',
'title',
'title_key',
];

public static function label()
{
return 'Menu Sections';
}

public static function singularLabel()
{
return 'Menu Section';
}

public function title(): string
{
$location = (string) ($this->location ?? '');
$column = (string) ($this->column ?? '');
$title = $this->title_key ? __((string) $this->title_key) : (string) ($this->title ?? '');
$title = trim($title) !== '' ? $title : "Section #{$this->id}";

return trim("{$location} · {$column} · {$title}");
}

public function fields(Request $request): array
{
return [
ID::make()->sortable(),

Text::make('Location', 'location')
->rules('required', 'max:255')
->help('Example: resources_dropdown'),

Select::make('Column', 'column')
->options([
'left' => 'Left',
'right' => 'Right',
])
->displayUsingLabels()
->rules('required'),

Text::make('Title Key', 'title_key')
->nullable()
->rules('nullable', 'max:255')
->help('Preferred. Example: menu.learn_to_code'),

Text::make('Title (literal)', 'title')
->nullable()
->rules('nullable', 'max:255')
->help('Used only if Title Key is empty.'),

Boolean::make('Active', 'is_active')->default(true),

HasMany::make('Items', 'items', MenuItem::class),
];
}

public static function indexQuery(NovaRequest $request, $query)
{
return $query->orderBy('sort_order')->orderBy('id');
}
}

7 changes: 7 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use App\Services\Support\Gmail\GmailConnector;
use App\Services\Support\Gmail\GoogleGmailConnector;
use App\Services\Support\Gmail\NullGmailConnector;
use App\Services\Menu\MenuRepository;

class AppServiceProvider extends ServiceProvider
{
Expand All @@ -42,6 +43,12 @@ public function boot(): void
View::share('locales', config('app.locales'));

Carbon::setLocale('app.locale');

View::composer('layout.menu', function ($view) {
$menuRepository = app(MenuRepository::class);
$view->with('resourcesDropdownSections', $menuRepository->sectionsForLocation('resources_dropdown'));
});

\View::composer(
['event.add', 'event.search', 'event.edit'],
function ($view) {
Expand Down
2 changes: 2 additions & 0 deletions app/Providers/NovaServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use App\Nova\SupportCaseMessage as SupportCaseMessageNova;
use App\Nova\SupportGmailCursor as SupportGmailCursorNova;
use App\Nova\TrainingResource as TrainingResourceNova;
use App\Nova\MenuSection as MenuSectionNova;
use App\Nova\Metrics\UsersPerDay;
use Illuminate\Support\Facades\Gate;
use Laravel\Nova\Menu\MenuSection;
Expand All @@ -39,6 +40,7 @@ public function boot(): void
SupportApprovalNova::class,
SupportCaseMessageNova::class,
SupportGmailCursorNova::class,
MenuSectionNova::class,
]);

// Ensure dashboards are registered at boot so /nova/dashboards/main is always available
Expand Down
Loading
Loading