feature: add Livewire configuration and extend ConnectedApp management with delete, create, and enhanced UI integration

- Introduced `livewire.php` for customizable Livewire configuration.
- Enhanced ConnectedApp management by adding delete functionality and dynamic navigation handling (`goBack()`).
- Updated forms (`create`, `edit`) with `maryUI` components (`mary-form`, `mary-button`, `mary-input`, `mary-choices`).
- Consolidated provider imports and refactored UI for reusability and improved styling.
- Improved exception handling and validation rules in `ConnectedAppService` and Livewire components.
This commit is contained in:
= 2026-05-15 05:45:31 +00:00
parent c0086ddb6a
commit 092ee0083e
6 changed files with 441 additions and 85 deletions

View File

@ -7,9 +7,9 @@
use App\Data\ConnectedApp\{ConnectAppRequest, ConnectedAppData}; use App\Data\ConnectedApp\{ConnectAppRequest, ConnectedAppData};
use App\Data\Ui\ConnectedApps\ConnectedAppData as UiConnectedAppData; 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\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Spatie\LaravelData\PaginatedDataCollection; use Spatie\LaravelData\PaginatedDataCollection;
use Throwable; use Throwable;
@ -18,7 +18,7 @@ final class ConnectedAppService
/** /**
* Create a new connected app. * Create a new connected app.
* *
* @throws Throwable * @throws Throwable when an error occurs during the creation.
*/ */
public function create(ConnectAppRequest $data): void public function create(ConnectAppRequest $data): void
{ {
@ -29,9 +29,13 @@ public function create(ConnectAppRequest $data): void
); );
} }
/**
* @throws InvalidArgumentException when id is invalid
* @throws Throwable when an error occurs during the update.
*/
public function update(int $id, ConnectAppRequest $data): void public function update(int $id, ConnectAppRequest $data): void
{ {
if (0 === $id) { if ($id <= 0) {
throw new InvalidArgumentException('Invalid id'); throw new InvalidArgumentException('Invalid id');
} }
DB::transaction( DB::transaction(
@ -41,6 +45,23 @@ public function update(int $id, ConnectAppRequest $data): void
); );
} }
/**
* @throws Throwable when an error occurs during the deletion.
* @throws InvalidArgumentException when id is invalid
*/
public function delete(int $id): void
{
if ($id <= 0) {
throw new InvalidArgumentException('Invalid id');
}
DB::transaction(
fn () => ConnectedApp::query()
->where('id', $id)
->delete()
);
}
/** /**
* @return PaginatedDataCollection<int, UiConnectedAppData> * @return PaginatedDataCollection<int, UiConnectedAppData>
*/ */
@ -53,10 +74,14 @@ public function getAll(): PaginatedDataCollection
} }
/** /**
* @throws ModelNotFoundException<ConnectedApp> * @throws ModelNotFoundException<ConnectedApp> when the connected app is not found.
* @throws InvalidArgumentException when id is invalid.
*/ */
public function getConnectedApp(int $id): ConnectedAppData public function getConnectedApp(int $id): ConnectedAppData
{ {
if ($id <= 0) {
throw new InvalidArgumentException('Invalid id');
}
$data = ConnectedApp::with('provider:id', 'protocol:id', 'status:id') $data = ConnectedApp::with('provider:id', 'protocol:id', 'status:id')
->findOrFail($id); ->findOrFail($id);

284
config/livewire.php Normal file
View File

@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts::app.sidebar',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => true, // Options: true, false
'with' => [
'js' => false,
'css' => false,
'test' => false,
],
],
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => true,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
'max_calls' => 50, // Maximum method calls per request
'max_components' => 200, // Maximum components per batch request
],
];

View File

@ -1,27 +1,30 @@
<?php <?php
use App\Concerns\HandlesOperations;
use App\Data\ConnectedApp\ConnectAppRequest;
use App\Data\ConnectedApp\ConnectionStatusData; use App\Data\ConnectedApp\ConnectionStatusData;
use App\Enums\ConnectionStatusEnum; use App\Enums\ConnectionStatusEnum;
use App\Livewire\Forms\StoreConnectedAppFrom; use App\Livewire\Forms\StoreConnectedAppFrom;
use App\Models\ConnectionProtocol; use App\Models\{ConnectionProtocol, ConnectionProvider, ConnectionStatus};
use App\Models\ConnectionProvider; use App\Services\{ConnectedAppService, ConnectionProtocolService, ConnectionProviderService, ConnectionStatusService};
use App\Models\ConnectionStatus;
use App\Services\ConnectionProtocolService;
use App\Services\ConnectionProviderService;
use App\Services\ConnectionStatusService;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed; use Livewire\Attributes\{Computed, Title};
use Livewire\Component; use Livewire\Component;
use Livewire\Attributes\Title;
use Mary\Traits\Toast; use Mary\Traits\Toast;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
new new
#[Title('Create a service')] #[Title('Create a service')]
class extends Component { class extends Component {
use Toast; use HandlesOperations;
public StoreConnectedAppFrom $form; public StoreConnectedAppFrom $form;
protected ConnectedAppService $appService;
public function boot(ConnectedAppService $appService): void
{
$this->appService = $appService;
}
#[Computed] #[Computed]
public function providers() public function providers()
@ -63,25 +66,44 @@ public function searchProvider(string $name)
$this->providers = $providerService->search($name); $this->providers = $providerService->search($name);
} }
public function save() public function save(bool $goBack = true): void
{ {
try { $this->form->validate();
$this->form->save(); $data = new ConnectAppRequest(
$this->success('New App connected '); name: $this->form->name,
return to_route('dashboard'); connectionProviderId: $this->form->providerId,
} catch (Throwable $e) { connectionProtocolId: $this->form->protocolId,
$this->error('Something gone wrong. Try again'); slug: str($this->form->name)->slug()->toString(),
\Illuminate\Support\Facades\Log::error('Error while submitting Create a connected app', [$e->getMessage()]); connectionStatusId: $this->form->statusId
);
$this->attempt(
action: function () use ($data, $goBack) {
$this->appService->create($data);
$this->form->reset();
if ($goBack) {
$this->goBack();
} }
} }
);
}
public function create()
{
}
public function goBack(): void
{
$this->redirectRoute('connected-apps.index', navigate: true);
}
}; };
?> ?>
<div> <div>
<x-shared.card :title="__('Create a service')" class=""> <x-shared.card :title="__('Create a service')" class="">
<form wire:submit="save" class="p-4 grid grid-cols-1 gap-4 md:grid-cols-2"> <x-mary-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-mary-input wire:model.live.blur="form.name" required :label="__('Name')"/>
<x-choices <x-mary-choices
required required
wire:model="form.protocolId" wire:model="form.protocolId"
:label="__('Protocol')" :label="__('Protocol')"
@ -89,7 +111,7 @@ public function save()
:options="$this->protocols" :options="$this->protocols"
placeholder="Select" placeholder="Select"
/> />
<x-choices required <x-mary-choices required
wire:model="form.providerId" wire:model="form.providerId"
:label="__('Provider')" :label="__('Provider')"
placeholder="Search ..." placeholder="Search ..."
@ -99,7 +121,7 @@ public function save()
search-function="searchProvider" search-function="searchProvider"
debounce="300" debounce="300"
/> />
<x-choices <x-mary-choices
required required
wire:model="form.statusId" wire:model="form.statusId"
:label="__('Status')" :label="__('Status')"
@ -108,13 +130,13 @@ public function save()
placeholder="Select" placeholder="Select"
/> />
<div class="md:col-span-2 flex gap-x-4 font-medium justify-end"> <div class="md:col-span-2 flex gap-x-4 font-medium justify-end">
<x-shared.button>Cancel</x-shared.button> <x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button>
<x-shared.button type="submit">Save & Add Another</x-shared.button> <x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>
<x-shared.button type="submit" <x-mary-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"> class="btn-primary">
Save Save
</x-shared.button> </x-mary-button>
</div> </div>
</form> </x-mary-form>
</x-shared.card> </x-shared.card>
</div> </div>

View File

@ -106,10 +106,12 @@ public function save(): void
?> ?>
<div> <div>
<x-shared.card :title="__('Edit an connected app')" class=""> <x-shared.card :title="__('Edit an connected app')">
<form wire:submit="save" class="p-4 grid grid-cols-1 gap-4 md:grid-cols-2"> <x-mary-form wire:submit="save" class="pb-2">
<x-input wire:model.live.blur="form.name" required :label="__('Name')"/> <div class="px-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<x-choices
<x-mary-input wire:model.live.blur="form.name" required :label="__('Name')"/>
<x-mary-choices
required required
wire:model="form.protocolId" wire:model="form.protocolId"
:label="__('Protocol')" :label="__('Protocol')"
@ -117,7 +119,7 @@ public function save(): void
:options="$this->protocols" :options="$this->protocols"
placeholder="Select" placeholder="Select"
/> />
<x-choices required <x-mary-choices required
wire:model="form.providerId" wire:model="form.providerId"
:label="__('Provider')" :label="__('Provider')"
placeholder="Search ..." placeholder="Search ..."
@ -127,7 +129,7 @@ public function save(): void
search-function="searchProvider" search-function="searchProvider"
debounce="300" debounce="300"
/> />
<x-choices <x-mary-choices
required required
wire:model="form.statusId" wire:model="form.statusId"
:label="__('Status')" :label="__('Status')"
@ -135,13 +137,15 @@ public function save(): void
:options="$this->connectionStatuses->toArray()" :options="$this->connectionStatuses->toArray()"
placeholder="Select" 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> </div>
</form> <x-slot:actions class="pr-2">
<x-mary-button :link="route('connected-apps.index')" wire:navigate>
{{ __('Cancel') }}
</x-mary-button>
<x-mary-button type="submit" spinner="save" class="btn-primary">
{{ __('Save') }}
</x-mary-button>
</x-slot:actions>
</x-mary-form>
</x-shared.card> </x-shared.card>
</div> </div>

View File

@ -1,21 +1,32 @@
<?php <?php
use App\Concerns\HandlesOperations;
use App\Data\Ui\ConnectedApps\ConnectedAppData; use App\Data\Ui\ConnectedApps\ConnectedAppData;
use App\Data\Ui\TableHeader; use App\Data\Ui\TableHeader;
use App\Services\ConnectedAppService; use App\Services\ConnectedAppService;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
use Spatie\LaravelData\PaginatedDataCollection; use Spatie\LaravelData\PaginatedDataCollection;
new class extends Component { new
use WithPagination; #[Title('Connected Apps')]
class extends Component {
use WithPagination, HandlesOperations;
protected ConnectedAppService $service;
#[DataCollectionOf(TableHeader::class)] #[DataCollectionOf(TableHeader::class)]
public DataCollection $headers; public DataCollection $headers;
public function boot(ConnectedAppService $service): void
{
$this->service = $service;
}
public function mount(): void public function mount(): void
{ {
$this->initTableHeaders(); $this->initTableHeaders();
@ -37,21 +48,29 @@ private function initTableHeaders(): void
#[Computed] #[Computed]
public function getConnectedApps(): PaginatedDataCollection public function getConnectedApps(): PaginatedDataCollection
{ {
$service = app(ConnectedAppService::class); return $this->service->getAll();
return $service->getAll();
} }
public function edit(int $appId): void public function edit(int $appId): void
{ {
$this->redirectRoute('connected-apps.edit', ['id' => $appId], navigate: true); $this->redirectRoute('connected-apps.edit', ['id' => $appId], navigate: true);
} }
public function delete(int $appId): void
{
$this->attempt(
action: fn() => $this->service->delete($appId),
successMessage: 'The app has been deleted !'
);
}
}; };
?> ?>
<div> <div>
<x-shared.card title="Connected Apps" class="pb-2"> <x-shared.card title="Connected Apps" class="pb-2">
<x-slot:actions> <x-slot:actions>
<x-mary-button icon="lucide.plus">Add App</x-mary-button> <x-mary-button :link="route('connected-apps.create')" wire:navigate icon="lucide.plus">Add App
</x-mary-button>
</x-slot:actions> </x-slot:actions>
<x-mary-table <x-mary-table
:headers="$headers->toArray()" :headers="$headers->toArray()"
@ -67,8 +86,10 @@ public function edit(int $appId): void
@endscope @endscope
@scope('actions', $row) @scope('actions', $row)
<div class="flex"> <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.edit" wire:click="edit({{$row->id}})" spinner="edit"
<x-mary-button icon="lucide.trash" variant="danger" class="btn-sm btn-error btn-soft"/> class="mr-2 btn-sm text-gray-500"/>
<x-mary-button icon="lucide.trash" wire:click="delete({{$row->id}})" spinner="delete" variant="danger"
class="btn-sm btn-error btn-soft"/>
</div> </div>
@endscope @endscope
</x-mary-table> </x-mary-table>

View File

@ -6,7 +6,7 @@
use Livewire\Component; use Livewire\Component;
new new
#[Layout('layouts.app')] #[Layout('layouts.app.sidebar')]
#[Title('Dashboard')] #[Title('Dashboard')]
class extends Component { class extends Component {
}; };