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:
parent
7bff74a039
commit
c0086ddb6a
@ -13,12 +13,14 @@ trait HandlesOperations
|
||||
{
|
||||
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 {
|
||||
// call the action callback
|
||||
$action();
|
||||
if ($showSuccess) {
|
||||
$this->success($successMessage);
|
||||
}
|
||||
} catch (ValidationException $exception) {
|
||||
|
||||
/**
|
||||
@ -36,7 +38,6 @@ public function attempt(callable $action, ?callable $onError = null, string $suc
|
||||
}
|
||||
$this->error($errorMessage);
|
||||
Log::error($exception->getMessage());
|
||||
Log::error($exception->getTraceAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,6 @@ public function __construct(
|
||||
public string $name,
|
||||
public int $connectionProviderId,
|
||||
public int $connectionProtocolId,
|
||||
public ?string $connectionService = null,
|
||||
public ?string $slug = null,
|
||||
public ?int $connectionStatusId = null,
|
||||
) {}
|
||||
|
||||
22
app/Data/ConnectedApp/ConnectedAppData.php
Normal file
22
app/Data/ConnectedApp/ConnectedAppData.php
Normal 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
3
app/Data/Ui/README.md
Normal 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.
|
||||
@ -6,11 +6,6 @@
|
||||
Validations that require the db, should not be live (realtime),
|
||||
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
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Livewire\Forms;
|
||||
|
||||
use App\Data\ConnectedApp\ConnectAppRequest;
|
||||
use App\Data\ConnectedApp\ConnectedAppData;
|
||||
use App\Services\ConnectedAppService;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Form;
|
||||
@ -23,18 +23,22 @@ class StoreConnectedAppFrom extends Form
|
||||
#[Validate('required|numeric|min:1|exists:connection_statuses,id')]
|
||||
public int $statusId = 0;
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate();
|
||||
protected ConnectedAppService $service;
|
||||
|
||||
$serviceData = new ConnectAppRequest(
|
||||
name: $this->name,
|
||||
connectionProviderId: $this->providerId,
|
||||
connectionProtocolId: $this->protocolId,
|
||||
slug: str($this->name)->slug()->toString(),
|
||||
connectionStatusId: $this->statusId,
|
||||
);
|
||||
app(ConnectedAppService::class)->create($serviceData);
|
||||
$this->reset();
|
||||
public function boot(ConnectedAppService $service): void
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* This sets the form to edit mode, after fetching the connected app of
|
||||
* the given id.
|
||||
*/
|
||||
public function set(ConnectedAppData $data): void
|
||||
{
|
||||
$this->name = $data->name;
|
||||
$this->protocolId = $data->protocolId;
|
||||
$this->providerId = $data->providerId;
|
||||
$this->statusId = $data->statusId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,11 +4,13 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\ConnectedApp\ConnectAppRequest;
|
||||
use App\Data\Ui\ConnectedApps\ConnectedAppData;
|
||||
use App\Data\ConnectedApp\{ConnectAppRequest, ConnectedAppData};
|
||||
use App\Data\Ui\ConnectedApps\ConnectedAppData as UiConnectedAppData;
|
||||
use App\Models\ConnectedApp;
|
||||
use http\Exception\InvalidArgumentException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\LaravelData\DataCollection;
|
||||
use Spatie\LaravelData\PaginatedDataCollection;
|
||||
use Throwable;
|
||||
|
||||
final class ConnectedAppService
|
||||
@ -27,13 +29,37 @@ public function create(ConnectAppRequest $data): void
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DataCollection<int, ConnectedAppData>
|
||||
*/
|
||||
public function getAll(): DataCollection
|
||||
public function update(int $id, ConnectAppRequest $data): void
|
||||
{
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,20 +4,11 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\ConnectedApp\StoreConnectionProviderRequestData;
|
||||
use App\Models\ConnectionProvider;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ConnectionProviderService
|
||||
{
|
||||
public function create(StoreConnectionProviderRequestData $requestData): void
|
||||
{
|
||||
DB::transaction(function () use ($requestData): void {
|
||||
ConnectionProvider::query()->create($requestData->toArray());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ConnectionProvider>
|
||||
*/
|
||||
|
||||
@ -118,15 +118,22 @@ @plugin "daisyui/theme" {
|
||||
/* Theme toggle */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/**
|
||||
* Paginator - Traditional style
|
||||
* Because Laravel defaults does not match well the design of daisyUI.
|
||||
*/
|
||||
|
||||
.mary-table-pagination span[aria-current="page"] > span {
|
||||
@apply bg-primary text-base-100
|
||||
/*pagination style*/
|
||||
.mary-table-pagination {
|
||||
button, span {
|
||||
@apply text-xs border-none
|
||||
}
|
||||
|
||||
.mary-table-pagination button {
|
||||
button {
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,13 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
new class extends Component {
|
||||
/** Collapsed = icons-only mode */
|
||||
public bool $collapsed = false;
|
||||
|
||||
/** Active nav key — passed as a prop from the parent layout */
|
||||
public string $active = 'dashboard';
|
||||
|
||||
public function mount(string $active = 'dashboard'): void
|
||||
public function mount(): void
|
||||
{
|
||||
$this->active = $active;
|
||||
$this->collapsed = session('sidebar_collapsed', false);
|
||||
}
|
||||
|
||||
@ -26,16 +21,23 @@ public function navItems(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'key' => 'dashboard',
|
||||
'label' => 'Dashboard',
|
||||
'icon' => 'lucide-house',
|
||||
'route' => 'home',
|
||||
'icon' => 'lucide.house',
|
||||
'route' => 'dashboard',
|
||||
'active_pattern' => 'dashboard',
|
||||
],
|
||||
[
|
||||
'key' => 'users',
|
||||
'label' => 'Users',
|
||||
'icon' => 'lucide-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.*',
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -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">
|
||||
|
||||
<a
|
||||
href="{{ route('dashboard') }}"
|
||||
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"
|
||||
aria-label="Go to dashboard"
|
||||
>
|
||||
<span class="w-4 h-4">
|
||||
@svg('lucide-shield-cog')
|
||||
</span>
|
||||
<x-mary-icon name='lucide.shield-cog' class="w-6 h-6"/>
|
||||
</a>
|
||||
|
||||
<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"
|
||||
>
|
||||
@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
|
||||
href=""
|
||||
href="{{ route($item['route']) }}"
|
||||
wire:navigate
|
||||
@class([
|
||||
'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>
|
||||
@endif
|
||||
|
||||
<span class="w-4 h-4">
|
||||
@svg($item['icon'])
|
||||
</span>
|
||||
<x-mary-icon :name="$item['icon']" class="w-4 h-4"/>
|
||||
|
||||
{{-- Label — hidden when collapsed --}}
|
||||
<span
|
||||
@ -188,5 +186,4 @@ class="whitespace-nowrap"
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
147
resources/views/pages/connected-apps/⚡edit.blade.php
Normal file
147
resources/views/pages/connected-apps/⚡edit.blade.php
Normal 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>
|
||||
76
resources/views/pages/connected-apps/⚡index.blade.php
Normal file
76
resources/views/pages/connected-apps/⚡index.blade.php
Normal 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>
|
||||
@ -11,9 +11,11 @@
|
||||
|
||||
Route::prefix('connected-apps')->name('connected-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('users.')->group(function (): void {
|
||||
Route::prefix('connection-providers')->name('connectionProviders.')->group(function (): void {
|
||||
Route::livewire('create', 'pages::connecton-providers.create')->name('create');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user