feature: implement ConnectedApp management with creation and editing support

- Added `ConnectedAppData` DTO for structured data representation.
- Implemented Livewire components for ConnectedApp list (`index`) and edit (`edit`).
- Integrated `ConnectedAppService` with CRUD operations.
- Updated routing to include ConnectedApp management paths.
- Enhanced UI with reusable components and dynamic dropdowns for protocols, providers, and statuses.
This commit is contained in:
= 2026-05-14 14:03:53 +00:00
parent 7bff74a039
commit c0086ddb6a
13 changed files with 342 additions and 72 deletions

View File

@ -13,12 +13,14 @@ trait HandlesOperations
{ {
use Toast; use Toast;
public function attempt(callable $action, ?callable $onError = null, string $successMessage = 'Success !', string $errorMessage = 'Something gone wrong!'): void public function attempt(callable $action, ?callable $onError = null, string $successMessage = 'Success !', string $errorMessage = 'Something gone wrong!', bool $showSuccess = true): void
{ {
try { try {
// call the action callback // call the action callback
$action(); $action();
$this->success($successMessage); if ($showSuccess) {
$this->success($successMessage);
}
} catch (ValidationException $exception) { } catch (ValidationException $exception) {
/** /**
@ -36,7 +38,6 @@ public function attempt(callable $action, ?callable $onError = null, string $suc
} }
$this->error($errorMessage); $this->error($errorMessage);
Log::error($exception->getMessage()); Log::error($exception->getMessage());
Log::error($exception->getTraceAsString());
} }
} }
} }

View File

@ -15,7 +15,6 @@ public function __construct(
public string $name, public string $name,
public int $connectionProviderId, public int $connectionProviderId,
public int $connectionProtocolId, public int $connectionProtocolId,
public ?string $connectionService = null,
public ?string $slug = null, public ?string $slug = null,
public ?int $connectionStatusId = null, public ?int $connectionStatusId = null,
) {} ) {}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Data\ConnectedApp;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Data;
final class ConnectedAppData extends Data
{
public function __construct(
public int $id,
public string $name,
#[MapInputName('connection_protocol_id')]
public int $protocolId,
#[MapInputName('connection_provider_id')]
public int $providerId,
#[MapInputName('connection_status_id')]
public int $statusId,
) {}
}

3
app/Data/Ui/README.md Normal file
View File

@ -0,0 +1,3 @@
This folder includes DTO that are shared to UI. This DTO should not contains any sensitive data, like model Id if the
UI
doesn't need it.

View File

@ -6,11 +6,6 @@
Validations that require the db, should not be live (realtime), Validations that require the db, should not be live (realtime),
they should be done on the only once, preferably after the form is submitted. they should be done on the only once, preferably after the form is submitted.
Form can be used to both create and update data. Name should start with: Forms should not be used to create, update or any database operation.
- `Store` for both cases - preferred
- `Create` for creating a new record
- `Update` for updating an existing record
When handle both cases, use separate `save` and `update` methods.
Livewire form docs: https://livewire.laravel.com/docs/4.x/forms Livewire form docs: https://livewire.laravel.com/docs/4.x/forms

View File

@ -4,7 +4,7 @@
namespace App\Livewire\Forms; namespace App\Livewire\Forms;
use App\Data\ConnectedApp\ConnectAppRequest; use App\Data\ConnectedApp\ConnectedAppData;
use App\Services\ConnectedAppService; use App\Services\ConnectedAppService;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Form; use Livewire\Form;
@ -23,18 +23,22 @@ class StoreConnectedAppFrom extends Form
#[Validate('required|numeric|min:1|exists:connection_statuses,id')] #[Validate('required|numeric|min:1|exists:connection_statuses,id')]
public int $statusId = 0; public int $statusId = 0;
public function save(): void protected ConnectedAppService $service;
{
$this->validate();
$serviceData = new ConnectAppRequest( public function boot(ConnectedAppService $service): void
name: $this->name, {
connectionProviderId: $this->providerId, $this->service = $service;
connectionProtocolId: $this->protocolId, }
slug: str($this->name)->slug()->toString(),
connectionStatusId: $this->statusId, /**
); * This sets the form to edit mode, after fetching the connected app of
app(ConnectedAppService::class)->create($serviceData); * the given id.
$this->reset(); */
public function set(ConnectedAppData $data): void
{
$this->name = $data->name;
$this->protocolId = $data->protocolId;
$this->providerId = $data->providerId;
$this->statusId = $data->statusId;
} }
} }

View File

@ -4,11 +4,13 @@
namespace App\Services; namespace App\Services;
use App\Data\ConnectedApp\ConnectAppRequest; use App\Data\ConnectedApp\{ConnectAppRequest, ConnectedAppData};
use App\Data\Ui\ConnectedApps\ConnectedAppData; use App\Data\Ui\ConnectedApps\ConnectedAppData as UiConnectedAppData;
use App\Models\ConnectedApp; use App\Models\ConnectedApp;
use http\Exception\InvalidArgumentException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\PaginatedDataCollection;
use Throwable; use Throwable;
final class ConnectedAppService final class ConnectedAppService
@ -27,13 +29,37 @@ public function create(ConnectAppRequest $data): void
); );
} }
/** public function update(int $id, ConnectAppRequest $data): void
* @return DataCollection<int, ConnectedAppData>
*/
public function getAll(): DataCollection
{ {
$data = ConnectedApp::with('provider', 'protocol', 'status')->get(); if (0 === $id) {
throw new InvalidArgumentException('Invalid id');
}
DB::transaction(
fn () => ConnectedApp::query()
->where('id', $id)
->update($data->toArray())
);
}
return ConnectedAppData::collect($data, DataCollection::class); /**
* @return PaginatedDataCollection<int, UiConnectedAppData>
*/
public function getAll(): PaginatedDataCollection
{
$data = ConnectedApp::with('provider:name,id', 'protocol:id,name', 'status:id,name')
->paginate();
return UiConnectedAppData::collect($data, PaginatedDataCollection::class);
}
/**
* @throws ModelNotFoundException<ConnectedApp>
*/
public function getConnectedApp(int $id): ConnectedAppData
{
$data = ConnectedApp::with('provider:id', 'protocol:id', 'status:id')
->findOrFail($id);
return ConnectedAppData::from($data);
} }
} }

View File

@ -4,20 +4,11 @@
namespace App\Services; namespace App\Services;
use App\Data\ConnectedApp\StoreConnectionProviderRequestData;
use App\Models\ConnectionProvider; use App\Models\ConnectionProvider;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ConnectionProviderService class ConnectionProviderService
{ {
public function create(StoreConnectionProviderRequestData $requestData): void
{
DB::transaction(function () use ($requestData): void {
ConnectionProvider::query()->create($requestData->toArray());
});
}
/** /**
* @return Collection<int, ConnectionProvider> * @return Collection<int, ConnectionProvider>
*/ */

View File

@ -118,15 +118,22 @@ @plugin "daisyui/theme" {
/* Theme toggle */ /* Theme toggle */
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
/** /*pagination style*/
* Paginator - Traditional style .mary-table-pagination {
* Because Laravel defaults does not match well the design of daisyUI. button, span {
*/ @apply text-xs border-none
}
.mary-table-pagination span[aria-current="page"] > span { button {
@apply bg-primary text-base-100 @apply cursor-pointer
}
span[aria-current="page"] > span {
@apply bg-gray-200!
}
button, span[aria-current="page"] > span, span[aria-disabled="true"] span {
@apply py-1 px-2 bg-gray-100
}
} }
.mary-table-pagination button {
@apply cursor-pointer
}

View File

@ -1,18 +1,13 @@
<?php <?php
use Livewire\Component; use Livewire\Component;
use Livewire\Attributes\On;
new class extends Component { new class extends Component {
/** Collapsed = icons-only mode */ /** Collapsed = icons-only mode */
public bool $collapsed = false; public bool $collapsed = false;
/** Active nav key — passed as a prop from the parent layout */ public function mount(): void
public string $active = 'dashboard';
public function mount(string $active = 'dashboard'): void
{ {
$this->active = $active;
$this->collapsed = session('sidebar_collapsed', false); $this->collapsed = session('sidebar_collapsed', false);
} }
@ -26,16 +21,23 @@ public function navItems(): array
{ {
return [ return [
[ [
'key' => 'dashboard',
'label' => 'Dashboard', 'label' => 'Dashboard',
'icon' => 'lucide-house', 'icon' => 'lucide.house',
'route' => 'home', 'route' => 'dashboard',
'active_pattern' => 'dashboard',
], ],
[ [
'key' => 'users',
'label' => 'Users', 'label' => 'Users',
'icon' => 'lucide-users', 'icon' => 'lucide.users',
'route' => 'home', '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.*',
], ],
]; ];
} }
@ -50,7 +52,6 @@ class="relative flex flex-col h-screen bg-white border-r border-gray-100
> >
<div class="flex items-center h-20 px-2 border-b border-gray-100 shrink-0"> <div class="flex items-center h-20 px-2 border-b border-gray-100 shrink-0">
<a <a
href="{{ route('dashboard') }}" href="{{ route('dashboard') }}"
wire:navigate wire:navigate
@ -60,9 +61,7 @@ class="shrink-0 flex items-center justify-center w-8 h-8
focus-visible:ring-2 focus-visible:ring-cyan-400" focus-visible:ring-2 focus-visible:ring-cyan-400"
aria-label="Go to dashboard" aria-label="Go to dashboard"
> >
<span class="w-4 h-4"> <x-mary-icon name='lucide.shield-cog' class="w-6 h-6"/>
@svg('lucide-shield-cog')
</span>
</a> </a>
<span <span
@ -86,10 +85,11 @@ class="flex-1 py-3 px-2 space-y-0.5 overflow-y-auto overflow-x-hidden"
aria-label="Main navigation" aria-label="Main navigation"
> >
@foreach ($this->navItems() as $item) @foreach ($this->navItems() as $item)
@php $isActive = $active === $item['key']; @endphp {{-- Check if the current route matches the pattern --}}
@php $isActive = request()->routeIs($item['active_pattern']); @endphp
<a <a
href="" href="{{ route($item['route']) }}"
wire:navigate wire:navigate
@class([ @class([
'group relative flex items-center gap-3 px-2 py-2.5 rounded-lg', 'group relative flex items-center gap-3 px-2 py-2.5 rounded-lg',
@ -107,9 +107,7 @@ class="absolute left-0 inset-y-2 w-0.5 rounded-r-full bg-blue-600"
></span> ></span>
@endif @endif
<span class="w-4 h-4"> <x-mary-icon :name="$item['icon']" class="w-4 h-4"/>
@svg($item['icon'])
</span>
{{-- Label hidden when collapsed --}} {{-- Label hidden when collapsed --}}
<span <span
@ -188,5 +186,4 @@ class="whitespace-nowrap"
</span> </span>
</button> </button>
</div> </div>
</aside> </aside>

View File

@ -0,0 +1,147 @@
<?php
use App\Concerns\HandlesOperations;
use App\Data\ConnectedApp\ConnectAppRequest;
use App\Data\ConnectedApp\ConnectionStatusData;
use App\Enums\ConnectionStatusEnum;
use App\Livewire\Forms\StoreConnectedAppFrom;
use App\Models\ConnectionProtocol;
use App\Models\ConnectionProvider;
use App\Models\ConnectionStatus;
use App\Services\ConnectedAppService;
use App\Services\ConnectionProtocolService;
use App\Services\ConnectionProviderService;
use App\Services\ConnectionStatusService;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Lazy;
use Livewire\Component;
use Livewire\Attributes\Title;
use Mary\Traits\Toast;
use Spatie\LaravelData\DataCollection;
new
#[Lazy]
#[Title('Edit a connected app')]
class extends Component {
use HandlesOperations;
public StoreConnectedAppFrom $form;
protected ConnectedAppService $service;
public int $recordId = 0;
public function boot(ConnectedAppService $service): void
{
$this->service = $service;
}
public function mount(int $id): void
{
$this->attempt(
action: function () use ($id) {
$record = $this->service->getConnectedApp($id);
$this->form->set($record);
$this->recordId = $id;
},
onError: function () {
$this->redirectRoute('connected-apps.index', navigate: true);
},
showSuccess: false
);
}
#[Computed]
public function providers()
{
return ConnectionProvider::query()->get();
}
#[Computed]
public function protocols(): Collection
{
$protocolService = app(ConnectionProtocolService::class);
return $protocolService->getAll()->map(function (ConnectionProtocol $protocol) {
$protocol->name = strtoupper($protocol->name);
return $protocol;
});
}
/**
* @returns DataCollection<int, ConnectionStatus>
*/
#[Computed]
public function connectionStatuses(): DataCollection
{
$statusService = app(ConnectionStatusService::class);
return $statusService->getAll();
}
public function searchProvider(string $name): void
{
$providerService = app(ConnectionProviderService::class);
$this->providers = $providerService->search($name);
}
public function save(): void
{
$this->attempt(
action: function () {
$this->form->validate();
$serviceData = new ConnectAppRequest(
name: $this->form->name,
connectionProviderId: $this->form->providerId,
connectionProtocolId: $this->form->protocolId,
slug: str($this->form->name)->slug()->toString(),
connectionStatusId: $this->form->statusId,
);
$this->service->update($this->recordId, $serviceData);
$this->redirectRoute('connected-apps.index', navigate: true);
},
);
}
};
?>
<div>
<x-shared.card :title="__('Edit an connected app')" class="">
<form wire:submit="save" class="p-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<x-input wire:model.live.blur="form.name" required :label="__('Name')"/>
<x-choices
required
wire:model="form.protocolId"
:label="__('Protocol')"
:single="true"
:options="$this->protocols"
placeholder="Select"
/>
<x-choices required
wire:model="form.providerId"
:label="__('Provider')"
placeholder="Search ..."
:single="true"
:options="$this->providers"
searchable
search-function="searchProvider"
debounce="300"
/>
<x-choices
required
wire:model="form.statusId"
:label="__('Status')"
:single="true"
:options="$this->connectionStatuses->toArray()"
placeholder="Select"
/>
<div class="md:col-span-2 flex gap-x-4 font-medium justify-end">
<x-shared.button>Cancel</x-shared.button>
<x-shared.button type="submit"
class="bg-blue-600 text-blue-50 hover:bg-blue-700 border-blue-600 min-w-20 flex justify-center items-center">
Save
</x-shared.button>
</div>
</form>
</x-shared.card>
</div>

View File

@ -0,0 +1,76 @@
<?php
use App\Data\Ui\ConnectedApps\ConnectedAppData;
use App\Data\Ui\TableHeader;
use App\Services\ConnectedAppService;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithPagination;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
use Spatie\LaravelData\PaginatedDataCollection;
new class extends Component {
use WithPagination;
#[DataCollectionOf(TableHeader::class)]
public DataCollection $headers;
public function mount(): void
{
$this->initTableHeaders();
}
private function initTableHeaders(): void
{
$this->headers = TableHeader::collect([
new TableHeader(key: 'name', label: 'Name',),
new TableHeader(key: 'provider', label: 'Provider'),
new TableHeader(key: 'protocol', label: 'Protocol'),
new TableHeader(key: 'status', label: 'Status'),
], DataCollection::class);
}
/**
* @return PaginatedDataCollection<int, ConnectedAppData>
*/
#[Computed]
public function getConnectedApps(): PaginatedDataCollection
{
$service = app(ConnectedAppService::class);
return $service->getAll();
}
public function edit(int $appId): void
{
$this->redirectRoute('connected-apps.edit', ['id' => $appId], navigate: true);
}
};
?>
<div>
<x-shared.card title="Connected Apps" class="pb-2">
<x-slot:actions>
<x-mary-button icon="lucide.plus">Add App</x-mary-button>
</x-slot:actions>
<x-mary-table
:headers="$headers->toArray()"
:rows="$this->getConnectedApps->items()"
with-pagination
>
@scope('cell_protocol', $row)
{{$row->protocol->name}}
@endscope
@scope('cell_status', $row)
<x-shared.connection-status :status="$row->status"/>
@endscope
@scope('actions', $row)
<div class="flex">
<x-mary-button icon="lucide.edit" wire:click="edit({{$row->id}})" class="mr-2 btn-sm text-gray-500"/>
<x-mary-button icon="lucide.trash" variant="danger" class="btn-sm btn-error btn-soft"/>
</div>
@endscope
</x-mary-table>
</x-shared.card>
</div>

View File

@ -11,9 +11,11 @@
Route::prefix('connected-apps')->name('connected-apps.')->group(function (): void { Route::prefix('connected-apps')->name('connected-apps.')->group(function (): void {
Route::livewire('create', 'pages::connected-apps.create')->name('create'); 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('users.')->group(function (): void { Route::prefix('connection-providers')->name('connectionProviders.')->group(function (): void {
Route::livewire('create', 'pages::connecton-providers.create')->name('create'); Route::livewire('create', 'pages::connecton-providers.create')->name('create');
}); });
}); });