feature: ConnectionProvider integration

- Added `ConnectionProviderData`, `NavItemData`, and `NavSubItemData` DTOs for structured data handling.
- Implemented CRUD operations for `ConnectionProviderService`, including transaction-safe create, update, and delete methods.
- Introduced Livewire components for ConnectionProvider list, edit, and create pages with dynamic and reusable UI elements.
- Refactored routes to consolidate paths under the `apps` prefix and updated navigation to dynamically include new sections for providers.
- Enhanced sidebar with structured submenu for ConnectionProviders and Settings using dynamic collections.
- Updated existing Create and Edit templates to use consistent route naming and validation handling.
- Replaced hardcoded navigation items with dynamically generated structures leveraging the `NavItemData` collection.
This commit is contained in:
= 2026-05-15 09:18:40 +00:00
parent bbb00c12b0
commit 48d2c1a47e
16 changed files with 460 additions and 127 deletions

2
CONTRIBUTING.MD Normal file
View File

@ -0,0 +1,2 @@
Always use `#[NoDiscard]` attribute in function which fetches data from DB. This ensures that
db does not called unnecessarily.

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Data\ConnectedApp;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapInputName(SnakeCaseMapper::class)]
class ConnectionProviderData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $slug
) {}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\Sidebar;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{Data, DataCollection};
final class NavItemData extends Data
{
public function __construct(
public string $label,
public string $icon,
public string $active_pattern,
public ?string $route = null,
#[DataCollectionOf(NavSubItemData::class)]
public ?DataCollection $sub_menu = null,
) {}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\Sidebar;
use Spatie\LaravelData\Data;
final class NavSubItemData extends Data
{
public function __construct(
public string $label,
public string $route,
public string $active_pattern,
) {}
}

View File

@ -4,9 +4,8 @@
namespace App\Livewire\Forms;
use App\Data\ConnectedApp\StoreConnectionProviderRequestData;
use App\Data\ConnectedApp\ConnectionProviderData;
use App\Models\ConnectionProvider;
use App\Services\ConnectionProviderService;
use Closure;
use Illuminate\Support\Str;
use Livewire\Form;
@ -15,6 +14,11 @@ class StoreConnectionProviderForm extends Form
{
public string $name = '';
public function set(ConnectionProviderData $data): void
{
$this->name = $data->name;
}
public function rules(): array
{
return [
@ -39,14 +43,4 @@ function (string $attribute, mixed $value, Closure $fail): void {
],
];
}
public function save(): void
{
$this->validate();
$service = app(ConnectionProviderService::class);
$data = new StoreConnectionProviderRequestData(
name: $this->name,
);
$service->create($data);
}
}

View File

@ -4,14 +4,22 @@
namespace App\Services;
use App\Data\ConnectedApp\{ConnectionProviderData, StoreConnectionProviderRequestData};
use App\Models\ConnectionProvider;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use NoDiscard;
use Spatie\LaravelData\PaginatedDataCollection;
use Throwable;
class ConnectionProviderService
{
/**
* @return Collection<int, ConnectionProvider>
*/
#[NoDiscard]
public function search(string $value): Collection
{
return ConnectionProvider::query()
@ -19,4 +27,68 @@ public function search(string $value): Collection
->orWhereLike('name', "%{$value}%")
->get();
}
/**
* @throws Throwable
*/
public function create(StoreConnectionProviderRequestData $data): void
{
DB::transaction(
function () use ($data): void {
ConnectionProvider::query()->create($data->toArray());
}
);
}
/**
* @return PaginatedDataCollection<int, ConnectionProviderData>
*/
#[NoDiscard]
public function getAll(): PaginatedDataCollection
{
$data = ConnectionProvider::query()->select(['id', 'name', 'slug'])->paginate();
return ConnectionProviderData::collect($data, PaginatedDataCollection::class);
}
/**
* @throws InvalidArgumentException when id is invalid
* @throws Throwable when an error occurs during the update.
*/
public function update(string $slug, StoreConnectionProviderRequestData $data): void
{
DB::transaction(
fn () => ConnectionProvider::query()
->where('slug', $slug)
->update($data->toArray())
);
}
/**
* @throws Throwable when an error occurs during the deletion.
* @throws InvalidArgumentException when id is invalid
*/
public function delete(string $slug): void
{
DB::transaction(
fn () => ConnectionProvider::query()
->where('slug', $slug)
->delete()
);
}
/**
* @throws ModelNotFoundException<ConnectionProviderData> when the connected app is not found.
* @throws InvalidArgumentException when id is invalid.
*/
#[NoDiscard]
public function getProvider(string $slug): ConnectionProviderData
{
$data = ConnectionProvider::query()
->select(['id', 'name', 'slug'])
->where('slug', $slug)
->first();
return ConnectionProviderData::from($data);
}
}

View File

@ -1,6 +1,9 @@
<?php
use App\Data\Ui\Sidebar\NavItemData;
use App\Data\Ui\Sidebar\NavSubItemData;
use Livewire\Component;
use Spatie\LaravelData\DataCollection;
new class extends Component {
/** Collapsed = icons-only mode */
@ -17,29 +20,55 @@ public function toggle(): void
session(['sidebar_collapsed' => $this->collapsed]);
}
public function navItems(): array
/**
* @return DataCollection<int, NavItemData>
*/
public function navItems(): DataCollection
{
return [
[
'label' => 'Dashboard',
'icon' => 'lucide.house',
'route' => 'dashboard',
'active_pattern' => 'dashboard',
],
[
'label' => 'Users',
'icon' => 'lucide.users',
'route' => 'home',
'active_pattern' => 'home',
],
[
'label' => 'Apps',
'icon' => 'lucide.grid-2x2-plus',
'route' => 'connected-apps.index',
// Using a wildcard ensures the tab stays active on nested routes like connected-apps.edit
'active_pattern' => 'connected-apps.*',
],
];
return NavItemData::collect([
new NavItemData(
label: 'Dashboard',
icon: 'lucide.house',
active_pattern: 'dashboard',
route: 'dashboard',
),
new NavItemData(
label: 'Apps',
icon: 'lucide.grid-2x2-plus',
active_pattern: 'apps.*',
sub_menu: NavSubItemData::collect([
new NavSubItemData(
label: 'All Apps',
route: 'apps.index',
active_pattern: 'apps.index'
),
new NavSubItemData(
label: 'Providers',
route: 'apps.connectionProviders.index',
active_pattern: 'apps.connectionProviders.*'
)
], DataCollection::class)
),
new NavItemData(
label: 'Settings',
icon: 'lucide.settings',
active_pattern: 'settings.*',
sub_menu: NavSubItemData::collect([
new NavSubItemData(
label: 'Profile',
route: 'profile.edit',
active_pattern: 'settings.profile',
),
new NavSubItemData(
label: 'Security',
route: 'security.edit',
active_pattern: 'settings.security',
),
], DataCollection::class)
),
], DataCollection::class);
}
};
?>
@ -81,72 +110,115 @@ class="ml-3 text-sm font-semibold text-gray-900 whitespace-nowrap tracking-tight
</div>
<nav
class="flex-1 py-3 px-2 space-y-0.5 overflow-y-auto overflow-x-hidden"
class="flex-1 py-3 px-2 space-y-1 overflow-y-auto overflow-x-hidden"
aria-label="Main navigation"
>
@foreach ($this->navItems() as $item)
{{-- Check if the current route matches the pattern --}}
@php $isActive = request()->routeIs($item['active_pattern']); @endphp
@php $isActive = request()->routeIs($item->active_pattern); @endphp
<a
href="{{ route($item['route']) }}"
wire:navigate
@class([
'group relative flex items-center gap-3 px-2 py-2.5 rounded-lg',
'text-sm font-medium transition-colors duration-150',
'bg-gray-100 text-gray-900' => $isActive,
'text-gray-500 hover:bg-gray-50 hover:text-gray-800' => ! $isActive,
])
aria-current="{{ $isActive ? 'page' : 'false' }}"
>
{{-- Active pill indicator --}}
@if ($isActive)
<span
class="absolute left-0 inset-y-2 w-0.5 rounded-r-full bg-blue-600"
aria-hidden="true"
></span>
@endif
@if ($item->sub_menu)
{{-- ITEM WITH SUBMENU --}}
<div x-data="{ expanded: {{ $isActive ? 'true' : 'false' }} }">
<button
@click="expanded = !expanded; if(collapsed) collapsed = false"
@class([
'group relative w-full flex items-center justify-between px-2 py-2.5 rounded-lg',
'text-sm font-medium transition-colors duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400',
'bg-gray-100 text-gray-900' => $isActive,
'text-gray-500 hover:bg-gray-50 hover:text-gray-800' => ! $isActive,
])
aria-haspopup="true"
:aria-expanded="String(expanded)"
>
<div class="flex items-center gap-3">
<x-mary-icon :name="$item->icon" class="w-4 h-4 shrink-0"/>
<span
x-cloak
x-show="!collapsed"
class="whitespace-nowrap overflow-hidden leading-none"
>
{{ $item->label }}
</span>
</div>
<x-mary-icon :name="$item['icon']" class="w-4 h-4"/>
{{-- Chevron Arrow (Hidden when collapsed) --}}
<span x-cloak x-show="!collapsed" class="shrink-0 transition-transform duration-200"
:class="expanded ? 'rotate-90' : ''">
<x-mary-icon name="lucide.chevron-right" class="w-4 h-4"/>
</span>
{{-- Label hidden when collapsed --}}
<span
x-cloak
x-show="!collapsed"
x-transition:enter="transition-opacity duration-200 delay-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-75"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="whitespace-nowrap overflow-hidden leading-none"
{{-- Tooltip (Collapsed Mode) --}}
<span x-cloak x-show="collapsed" role="tooltip"
class="pointer-events-none absolute left-full ml-3 z-50 whitespace-nowrap rounded-md bg-gray-900 px-2.5 py-1.5 text-xs font-medium text-white shadow-xl opacity-0 group-hover:opacity-100 transition-opacity duration-150 delay-75">
{{ $item->label }}
<span
class="absolute right-full top-1/2 -translate-y-1/2 border-[5px] border-transparent border-r-gray-900"
aria-hidden="true"></span>
</span>
</button>
{{-- Submenu Dropdown --}}
<div x-cloak x-show="expanded && !collapsed" x-collapse>
<div class="mt-1 space-y-1 pl-9 pr-2">
@foreach ($item->sub_menu as $subItem)
@php $isSubActive = request()->routeIs($subItem->active_pattern); @endphp
<a
href="{{ route($subItem->route) }}"
wire:navigate
@class([
'block px-3 py-2 rounded-md text-sm font-medium transition-colors',
'bg-gray-100 text-blue-700' => $isSubActive,
'text-gray-500 hover:bg-gray-50 hover:text-gray-900' => ! $isSubActive,
])
>
{{ $subItem->label }}
</a>
@endforeach
</div>
</div>
</div>
@else
{{-- STANDARD ITEM --}}
<a
href="{{ route($item->route) }}"
wire:navigate
@class([
'group relative flex items-center gap-3 px-2 py-2.5 rounded-lg',
'text-sm font-medium transition-colors duration-150',
'bg-gray-100 text-gray-900' => $isActive,
'text-gray-500 hover:bg-gray-50 hover:text-gray-800' => ! $isActive,
])
aria-current="{{ $isActive ? 'page' : 'false' }}"
>
{{ $item['label'] }}
</span>
@if ($isActive)
<span class="absolute left-0 inset-y-2 w-0.5 rounded-r-full bg-blue-600"
aria-hidden="true"></span>
@endif
<x-mary-icon :name="$item->icon" class="w-4 h-4 shrink-0"/>
{{-- Tooltip only rendered & visible in collapsed mode --}}
<span
x-cloak
x-show="collapsed"
role="tooltip"
class="pointer-events-none absolute left-full ml-3 z-50
whitespace-nowrap rounded-md bg-gray-900 px-2.5 py-1.5
text-xs font-medium text-white shadow-xl
opacity-0 group-hover:opacity-100
transition-opacity duration-150 delay-75"
>
{{ $item['label'] }}
{{-- Arrow --}}
<span
class="absolute right-full top-1/2 -translate-y-1/2
border-[5px] border-transparent border-r-gray-900"
aria-hidden="true"
></span>
</span>
</a>
x-cloak
x-show="!collapsed"
class="whitespace-nowrap overflow-hidden leading-none"
>
{{ $item->label }}
</span>
{{-- Tooltip (Collapsed Mode) --}}
<span x-cloak x-show="collapsed" role="tooltip"
class="pointer-events-none absolute left-full ml-3 z-50 whitespace-nowrap rounded-md bg-gray-900 px-2.5 py-1.5 text-xs font-medium text-white shadow-xl opacity-0 group-hover:opacity-100 transition-opacity duration-150 delay-75">
{{ $item->label }}
<span
class="absolute right-full top-1/2 -translate-y-1/2 border-[5px] border-transparent border-r-gray-900"
aria-hidden="true"></span>
</span>
</a>
@endif
@endforeach
</nav>
{{-- Toggle Footer --}}
<div class="border-t border-gray-100 px-2 py-3 shrink-0">
<button
wire:click="toggle"
@ -159,29 +231,13 @@ class="group w-full flex items-center gap-3 px-2 py-2.5 rounded-lg
:aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
>
<span class="flex-shrink-0 flex items-center justify-center w-5 h-5" aria-hidden="true">
<svg
:class="collapsed ? 'rotate-180' : 'rotate-0'"
class="w-4 h-4 transition-transform duration-300 ease-in-out"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<svg :class="collapsed ? 'rotate-180' : 'rotate-0'"
class="w-4 h-4 transition-transform duration-300 ease-in-out" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/>
</svg>
</span>
<span
x-cloak
x-show="!collapsed"
x-transition:enter="transition-opacity duration-200 delay-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-75"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="whitespace-nowrap"
>
<span x-cloak x-show="!collapsed" class="whitespace-nowrap">
Collapse
</span>
</button>

View File

@ -10,7 +10,7 @@
<div>
<x-shared.card title="Connected Apps" icon="lucide-grid-2x2-plus" class="p-0">
<x-slot:actions>
<x-shared.button :link="route('connected-apps.create')">
<x-shared.button :link="route('apps.create')">
<div class="flex items-center gap-x-2">
<x-lucide-plus class="w-5 h-5"/>
{{ __('Connect App') }}

View File

@ -94,7 +94,7 @@ public function create()
public function goBack(): void
{
$this->redirectRoute('connected-apps.index', navigate: true);
$this->redirectRoute('apps.index', navigate: true);
}
};
?>

View File

@ -44,7 +44,7 @@ public function mount(int $id): void
$this->recordId = $id;
},
onError: function () {
$this->redirectRoute('connected-apps.index', navigate: true);
$this->redirectRoute('apps.index', navigate: true);
},
showSuccess: false
);
@ -98,7 +98,7 @@ public function save(): void
connectionStatusId: $this->form->statusId,
);
$this->service->update($this->recordId, $serviceData);
$this->redirectRoute('connected-apps.index', navigate: true);
$this->redirectRoute('apps.index', navigate: true);
},
);
}
@ -139,7 +139,7 @@ public function save(): void
/>
</div>
<x-slot:actions class="pr-2">
<x-mary-button :link="route('connected-apps.index')" wire:navigate>
<x-mary-button :link="route('apps.index')" wire:navigate>
{{ __('Cancel') }}
</x-mary-button>
<x-mary-button type="submit" spinner="save" class="btn-primary">

View File

@ -53,7 +53,7 @@ public function getConnectedApps(): PaginatedDataCollection
public function edit(int $appId): void
{
$this->redirectRoute('connected-apps.edit', ['id' => $appId], navigate: true);
$this->redirectRoute('apps.edit', ['id' => $appId], navigate: true);
}
public function delete(int $appId): void
@ -69,7 +69,7 @@ public function delete(int $appId): void
<div>
<x-shared.card title="Connected Apps" class="pb-2">
<x-slot:actions>
<x-mary-button :link="route('connected-apps.create')" wire:navigate icon="lucide.plus">Add App
<x-mary-button :link="route('apps.create')" wire:navigate icon="lucide.plus">Add App
</x-mary-button>
</x-slot:actions>
<x-mary-table
@ -86,9 +86,10 @@ public function delete(int $appId): void
@endscope
@scope('actions', $row)
<div class="flex">
<x-mary-button icon="lucide.edit" wire:click="edit({{$row->id}})" spinner="edit"
<x-mary-button icon="lucide.edit" wire:click="edit({{$row->id}})" spinner="edit({{$row->id}})"
class="mr-2 btn-sm text-gray-500"/>
<x-mary-button icon="lucide.trash" wire:click="delete({{$row->id}})" spinner="delete" variant="danger"
<x-mary-button icon="lucide.trash" wire:click="delete({{$row->id}})" spinner="delete({{$row->id}})"
variant="danger"
class="btn-sm btn-error btn-soft"/>
</div>
@endscope

View File

@ -1,11 +1,11 @@
<?php
use App\Concerns\HandlesOperations;
use App\Data\ConnectedApp\StoreConnectionProviderRequestData;
use App\Livewire\Forms\StoreConnectionProviderForm;
use App\Services\ConnectionProviderService;
use Laravel\Mcp\Server\Attributes\Title;
use Livewire\Component;
use Mary\Traits\Toast;
use Illuminate\Support\Facades\Log;
new
#[Title('Create Connection Provider')]
@ -13,14 +13,25 @@ class extends Component {
use HandlesOperations;
public StoreConnectionProviderForm $form;
protected ConnectionProviderService $service;
public function boot(ConnectionProviderService $service): void
{
$this->service = $service;
}
public function save(): void
{
$this->attempt(
action: function () {
$this->form->save();
$this->redirectRoute('dashboard', navigate: true);
}
$this->form->validate();
$data = new StoreConnectionProviderRequestData(
name: $this->form->name,
);
$this->service->create($data);
$this->form->reset();
$this->redirectRoute('apps.connectionProviders.index', navigate: true);
},
);
}
};

View File

@ -0,0 +1,60 @@
<?php
use App\Concerns\HandlesOperations;
use App\Data\ConnectedApp\StoreConnectionProviderRequestData;
use App\Livewire\Forms\StoreConnectionProviderForm;
use App\Services\ConnectionProviderService;
use Laravel\Mcp\Server\Attributes\Title;
use Livewire\Component;
use Mary\Traits\Toast;
use Illuminate\Support\Facades\Log;
new
#[Title('Edit Connection Provider')]
class extends Component {
use HandlesOperations;
public StoreConnectionProviderForm $form;
public string $currentProviderSlug;
protected ConnectionProviderService $service;
public function boot(ConnectionProviderService $service): void
{
$this->service = $service;
}
public function mount(string $slug): void
{
$this->currentProviderSlug = $slug;
$data = $this->service->getProvider($slug);
$this->form->set($data);
}
public function save(): void
{
$this->attempt(
action: function () {
$this->form->validate();
$data = new StoreConnectionProviderRequestData(
name: $this->form->name,
);
$this->service->update($this->currentProviderSlug, $data);
$this->redirectRoute('apps.connectionProviders.index', navigate: true);
},
);
}
};
?>
<div>
<x-shared.card :title="__('Edit Connection Provider')">
<x-mary-form :no-separator="true" class="p-4" wire:submit="save">
<x-mary-input wire:model.blur="form.name" :label="__('Name')"/>
<x-slot:actions>
<x-mary-button class="btn-primary" type="submit" spinner="save" wire:click="form.store"
:label="__('Save')"/>
</x-slot:actions>
</x-mary-form>
</x-shared.card>
</div>

View File

@ -0,0 +1,79 @@
<?php
use App\Concerns\HandlesOperations;
use App\Data\Ui\TableHeader;
use App\Services\ConnectionProviderService;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithPagination;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
new class extends Component {
use WithPagination, HandlesOperations;
protected ConnectionProviderService $service;
#[DataCollectionOf(TableHeader::class)]
public DataCollection $headers;
public function boot(ConnectionProviderService $service): void
{
$this->service = $service;
}
public function mount(): void
{
$this->initTableHeaders();
}
private function initTableHeaders(): void
{
$this->headers = TableHeader::collect([
new TableHeader(key: 'name', label: 'Name',),
], DataCollection::class);
}
#[Computed]
public function getAllProviders()
{
return $this->service->getAll();
}
public function edit(string $providerSlug): void
{
$this->redirectRoute('apps.connectionProviders.edit', ['slug' => $providerSlug]);
}
public function delete(string $providerSlug): void
{
$this->attempt(
action: fn() => $this->service->delete($providerSlug),
successMessage: "Connection Provider deleted!"
);
}
};
?>
<div>
<x-shared.card title="Connection Providers" class="pb-2">
<x-slot:actions>
<x-mary-button :link="route('apps.connectionProviders.create')" wire:navigate icon="lucide.plus">Add
Provider
</x-mary-button>
</x-slot:actions>
<x-mary-table :headers="$headers->toArray()" :rows="$this->getAllProviders->items()" with-pagination>
@scope('actions', $row)
<div class="flex">
<x-mary-button icon="lucide.edit" wire:click="edit('{{$row->slug}}')" spinner="edit('{{$row->slug}}')"
class="mr-2 btn-sm text-gray-500"/>
<x-mary-button icon="lucide.trash" wire:click="delete('{{$row->slug}}')"
spinner="delete('{{$row->slug}}')"
variant="danger"
class="btn-sm btn-error btn-soft"/>
</div>
@endscope
</x-mary-table>
</x-shared.card>
</div>

View File

@ -7,7 +7,7 @@
use Laravel\Fortify\Features;
Route::middleware(['auth'])->group(function (): void {
Route::redirect('settings', 'settings/profile');
Route::redirect('settings', 'settings/profile')->name('settings');
Route::livewire('settings/profile', Profile::class)->name('profile.edit');
});

View File

@ -9,14 +9,16 @@
Route::group(['middleware' => ['auth', 'verified']], function (): void {
Route::livewire('dashboard', 'pages::dashboard')->name('dashboard');
Route::prefix('connected-apps')->name('connected-apps.')->group(function (): void {
Route::prefix('apps')->name('apps.')->group(function (): void {
Route::livewire('create', 'pages::connected-apps.create')->name('create');
Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit');
Route::livewire('/', 'pages::connected-apps.index')->name('index');
});
Route::prefix('connection-providers')->name('connectionProviders.')->group(function (): void {
Route::livewire('create', 'pages::connecton-providers.create')->name('create');
Route::prefix('connection-providers')->name('connectionProviders.')->group(function (): void {
Route::livewire('create', 'pages::connecton-providers.create')->name('create');
Route::livewire('{slug}/edit', 'pages::connecton-providers.edit')->name('edit');
Route::livewire('index', 'pages::connecton-providers.index')->name('index');
});
});
});