From 6ddde416cf8edaf4b42bcecf71b0e62de2c8bd0e Mon Sep 17 00:00:00 2001 From: = Date: Wed, 13 May 2026 09:49:57 +0000 Subject: [PATCH] feature: introduce Choices component and service management entities - Added `Choices` component for dropdowns with search and multi-selection functionality. - Implemented foundational classes for service connection: - `ConnectServiceData` - `ConnectedServiceFactory` - `ConnectedServicesService` - Added `ConnectServiceForm` Livewire component for managing service connections. - Introduced shared utility traits (`HasAttributeHelpers`) and error components. - Added components for generic use: `Input` and `ListItem`. --- app/Concerns/UI/HasAttributeHelpers.php | 75 +++ .../ConnectService/ConnectServiceData.php | 19 + app/Factories/ConnectedServiceFactory.php | 34 ++ app/Livewire/Forms/ConnectServiceFrom.php | 41 ++ app/Models/ConnectedService.php | 27 +- app/Models/ServiceStatus.php | 4 +- app/Services/ConnectedServicesService.php | 23 + app/View/Components/Choices.php | 430 ++++++++++++++++++ app/View/Components/Input.php | 62 +++ app/View/Components/ListItem.php | 83 ++++ composer.json | 4 +- database/seeders/ServiceProviderSeeder.php | 32 ++ pint.json | 2 - .../⚡connected-services.blade.php | 2 +- .../views/components/shared/error.blade.php | 12 + .../connected-services/⚡create.blade.php | 68 +++ routes/web.php | 4 + 17 files changed, 915 insertions(+), 7 deletions(-) create mode 100644 app/Concerns/UI/HasAttributeHelpers.php create mode 100644 app/Data/ConnectService/ConnectServiceData.php create mode 100644 app/Factories/ConnectedServiceFactory.php create mode 100644 app/Livewire/Forms/ConnectServiceFrom.php create mode 100644 app/Services/ConnectedServicesService.php create mode 100644 app/View/Components/Choices.php create mode 100644 app/View/Components/Input.php create mode 100644 app/View/Components/ListItem.php create mode 100644 database/seeders/ServiceProviderSeeder.php create mode 100644 resources/views/components/shared/error.blade.php create mode 100644 resources/views/pages/connected-services/⚡create.blade.php diff --git a/app/Concerns/UI/HasAttributeHelpers.php b/app/Concerns/UI/HasAttributeHelpers.php new file mode 100644 index 0000000..4959ce1 --- /dev/null +++ b/app/Concerns/UI/HasAttributeHelpers.php @@ -0,0 +1,75 @@ +attributes->has($attribute); + } + + /** + * Get the error field name. + * If the parent class has an error field name (passed via props or defined), it will be returned. + * Otherwise, the model name will be returned. + */ + public function errorFieldName(): ?string + { + return $this->errorField ?? $this->modelName(); + } + + /** + * This function will return the model name from the wire:model attribute. + * This is useful as we use this to bind wire:model props passed from high level + * components (ex: input) to the internal html components (ex: input tag). + */ + public function modelName(): ?string + { + return $this->attributes->whereStartsWith('wire:model')->first(); + } + + /** + * Check if the input is required. + */ + public function isRequired(): bool + { + return $this->attributes->has('required') && true === $this->attributes->get('required'); + } + + /** + * Check if the input is disabled. + */ + public function isDisabled(): bool + { + return $this->attributes->has('disabled') && true === $this->attributes->get('disabled'); + } + + /** + * Check if the input is readonly. + */ + public function isReadonly(): bool + { + return $this->attributes->has('readonly') && true === $this->attributes->get('readonly'); + } + + /** + * Check if the input is autofocus. + */ + public function isAutofocus(): bool + { + return $this->attributes->has('autofocus') && true === $this->attributes->get('autofocus'); + } +} diff --git a/app/Data/ConnectService/ConnectServiceData.php b/app/Data/ConnectService/ConnectServiceData.php new file mode 100644 index 0000000..fcc04d3 --- /dev/null +++ b/app/Data/ConnectService/ConnectServiceData.php @@ -0,0 +1,19 @@ + $data->name, + 'service_provider_id' => $data->serviceProviderId, + 'service_protocol_id' => $data->serviceProtocolId, + 'logo_url' => $data->logoUrl, + 'slug' => $data->slug, + 'service_status_id' => $data->statusId, + ]; + + // Check if the staus is filled, if not select disconnected by default + if (null === $data->statusId) { + $status = ServiceStatus::query() + ->where('name', '=', ConnectionStatus::Disconnected->value) + ->pluck('id'); + $result['service_status_id'] = $status[0]; + } + + return $result; + } +} diff --git a/app/Livewire/Forms/ConnectServiceFrom.php b/app/Livewire/Forms/ConnectServiceFrom.php new file mode 100644 index 0000000..0aed1a2 --- /dev/null +++ b/app/Livewire/Forms/ConnectServiceFrom.php @@ -0,0 +1,41 @@ +validate(); + + $serviceData = new ConnectServiceData( + name: $this->name, + serviceProviderId: $this->providerId, + serviceProtocolId: $this->protocolId, + logoUrl: $this->logoUrl, + slug: str($this->name)->slug()->toString(), + ); + + app(ConnectedServicesService::class)->create($serviceData); + $this->reset(); + } +} diff --git a/app/Models/ConnectedService.php b/app/Models/ConnectedService.php index 3095f94..6aa4ae7 100644 --- a/app/Models/ConnectedService.php +++ b/app/Models/ConnectedService.php @@ -4,6 +4,31 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasOne; -final class ConnectedService extends Model {} +#[Fillable([ + 'name', + 'slug', + 'service_protocol_id', + 'service_provider_id', + 'service_status_id', +])] +class ConnectedService extends Model +{ + public function protocol(): HasOne + { + return $this->hasOne(ServiceProtocol::class, 'id', 'service_protocol_id'); + } + + public function provider(): HasOne + { + return $this->hasOne(ServiceProvider::class, 'id', 'service_provider_id'); + } + + public function status(): HasOne + { + return $this->hasOne(ServiceStatus::class, 'id', 'service_status_id'); + } +} diff --git a/app/Models/ServiceStatus.php b/app/Models/ServiceStatus.php index a7e7c70..259d564 100644 --- a/app/Models/ServiceStatus.php +++ b/app/Models/ServiceStatus.php @@ -4,6 +4,8 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Model; -final class ServiceStatus extends Model {} +#[Table('service_statuses')] +class ServiceStatus extends Model {} diff --git a/app/Services/ConnectedServicesService.php b/app/Services/ConnectedServicesService.php new file mode 100644 index 0000000..cfb709b --- /dev/null +++ b/app/Services/ConnectedServicesService.php @@ -0,0 +1,23 @@ +createDataFromDto($data); + DB::transaction( + fn () => ConnectedService::create( + $data + ) + ); + } +} diff --git a/app/View/Components/Choices.php b/app/View/Components/Choices.php new file mode 100644 index 0000000..fae0267 --- /dev/null +++ b/app/View/Components/Choices.php @@ -0,0 +1,430 @@ +uuid = 'component'.md5(serialize($this)).$id; + + if (($this->allowAll || $this->compact) && ($this->single || $this->searchable)) { + throw new Exception('`allow-all` and `compact` does not work combined with `single` or `searchable`.'); + } + } + + public function getOptionValue($option): mixed + { + $value = data_get($option, $this->optionValue); + + if ($this->valuesAsString) { + return "'$value'"; + } + + if ($this->escapeValues) { + $value = addslashes($value); + + return "'$value'"; + } + + return is_numeric($value) && ! str($value)->startsWith('0') ? $value : "'$value'"; + } + + public function render(): View|Closure|string + { + return <<<'HTML' +
+
+
+ {{-- STANDARD LABEL --}} + @if($label && !$inline) + + {{ $label }} + + @if($attributes->get('required')) + * + @endif + + + @endif + + + + {{-- ERROR --}} + @if(!$omitError) + + @endif + + {{-- HINT --}} + @if($hint) +
{{ $hint }}
+ @endif +
+ + {{-- OPTIONS LIST --}} +
+
+ + {{-- PROGRESS --}} + @if(!$noProgress) + + @endif + + {{-- SELECT ALL --}} + @if($allowAll) +
+
{{ $allowAllText }}
+
{{ $removeAllText }}
+
+ @endif + + {{-- NO RESULTS --}} +
+ {{ $noResultText }} +
+ + @forelse($options as $option) +
+ {{-- ITEM SLOT --}} + @if($item) + {{ $item($option) }} + @else + + @endif + + {{-- SELECTION SLOT --}} + @if($selection) + + @endif +
+ @empty +
No results found.
+ @endforelse +
+
+
+
+ HTML; + } +} diff --git a/app/View/Components/Input.php b/app/View/Components/Input.php new file mode 100644 index 0000000..c8b38c2 --- /dev/null +++ b/app/View/Components/Input.php @@ -0,0 +1,62 @@ +uuid = 'component'.md5(serialize($this)).$id; + } + + /** + * Get the view / contents that represent the component. + */ + public function render(): View|Closure|string + { + return <<<'BLADE' +
+
+ @if($label) + + {{$label}} + @if($hasAttribute('required')) + * + @endif + + @endif + merge([ + 'type' => 'text', + 'class' => 'rounded-xl border border-gray-300 px-4 py-2' + ]) + }} + /> + +
+
+ BLADE; + } +} diff --git a/app/View/Components/ListItem.php b/app/View/Components/ListItem.php new file mode 100644 index 0000000..0cd204e --- /dev/null +++ b/app/View/Components/ListItem.php @@ -0,0 +1,83 @@ +uuid = 'component'.md5(serialize($this)).$id; + } + + public function render(): View|Closure|string + { + return <<<'HTML' +
+
class([ + "flex justify-start items-center gap-4 px-3 py-3", + "hover:bg-gray-200" => !$noHover, + "cursor-pointer" => $link + ]) + }} + > + + + + + + @if($actions) + @if($link && !Str::of($actions)->contains([':click', '@click' , 'href'])) + + @endif +
attributes->class(["flex items-center gap-3 mary-hideable"]) }}> + {{ $actions }} +
+ + @if($link && !Str::of($actions)->contains([':click', '@click' , 'href'])) +
+ @endif + @endif +
+ +
+ HTML; + } +} diff --git a/composer.json b/composer.json index 97ddf3c..0a4cf8e 100644 --- a/composer.json +++ b/composer.json @@ -58,10 +58,10 @@ "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], "lint": [ - "pint --parallel --dirty" + "pint --parallel" ], "lint:check": [ - "pint --parallel --dirty --test" + "pint --parallel --test" ], "ci:check": [ "Composer\\Config::disableProcessTimeout", diff --git a/database/seeders/ServiceProviderSeeder.php b/database/seeders/ServiceProviderSeeder.php new file mode 100644 index 0000000..a972365 --- /dev/null +++ b/database/seeders/ServiceProviderSeeder.php @@ -0,0 +1,32 @@ + 'Azure FD', + 'slug' => 'azure-fd', + ], [ + 'name' => 'Keka HR', + 'slug' => 'keka-hr', + ], + [ + 'name' => 'Oneclick', + 'slug' => 'oneclick', + ], + ]; + ServiceProvider::upsert($data, ['slug'], ['name']); + } +} diff --git a/pint.json b/pint.json index 3af04fc..84302a9 100644 --- a/pint.json +++ b/pint.json @@ -2,7 +2,6 @@ "preset": "laravel", "rules": { "declare_strict_types": true, - "final_class": true, "fully_qualified_strict_types": true, "group_import": true, "single_import_per_statement": false, @@ -37,7 +36,6 @@ "no_superfluous_elseif": true, "no_useless_else": true, "nullable_type_declaration_for_default_null_value": true, - "strict_param": true, "date_time_immutable": true, "mb_str_functions": true, diff --git a/resources/views/components/dashboard/connected-services/⚡connected-services.blade.php b/resources/views/components/dashboard/connected-services/⚡connected-services.blade.php index f0bbe13..b7dc452 100644 --- a/resources/views/components/dashboard/connected-services/⚡connected-services.blade.php +++ b/resources/views/components/dashboard/connected-services/⚡connected-services.blade.php @@ -10,7 +10,7 @@
- +
{{ __('Add service') }} diff --git a/resources/views/components/shared/error.blade.php b/resources/views/components/shared/error.blade.php new file mode 100644 index 0000000..fe66314 --- /dev/null +++ b/resources/views/components/shared/error.blade.php @@ -0,0 +1,12 @@ +@props(['errorFieldName', 'firstErrorOnly' => false]) +
+ @if($errors->has($errorFieldName)) + @foreach($errors->get($errorFieldName) as $message) + @foreach(Arr::wrap($message) as $line) +
{{ $line }}
+ @break($firstErrorOnly) + @endforeach + @break($firstErrorOnly) + @endforeach + @endif +
diff --git a/resources/views/pages/connected-services/⚡create.blade.php b/resources/views/pages/connected-services/⚡create.blade.php new file mode 100644 index 0000000..7b811bb --- /dev/null +++ b/resources/views/pages/connected-services/⚡create.blade.php @@ -0,0 +1,68 @@ +get(); + } + + #[Computed] + public function protocols() + { + return ServiceProtocol::query()->get()->map(function (ServiceProtocol $protocol) { + $protocol->name = ucfirst($protocol->name); + return $protocol; + }); + } + + public function save() + { + $this->form->save(); + } +}; +?> + +
+ +
+ + + + +
+ Cancel + Save & Add Another + + Save + +
+ +
+
diff --git a/routes/web.php b/routes/web.php index f5bb7ea..4657935 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,10 @@ Route::group(['middleware' => ['auth', 'verified']], function (): void { Route::livewire('dashboard', 'pages::dashboard')->name('dashboard'); + + Route::prefix('connected-services')->name('connected-services.')->group(function (): void { + Route::livewire('create', 'pages::connected-services.create')->name('create'); + }); }); require __DIR__.'/settings.php';