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`.
This commit is contained in:
parent
77b6008150
commit
6ddde416cf
75
app/Concerns/UI/HasAttributeHelpers.php
Normal file
75
app/Concerns/UI/HasAttributeHelpers.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Concerns\UI;
|
||||||
|
|
||||||
|
use Illuminate\View\ComponentAttributeBag;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This trait is used to add some helpers methods to components.
|
||||||
|
*
|
||||||
|
* @property ComponentAttributeBag $attributes
|
||||||
|
*/
|
||||||
|
trait HasAttributeHelpers
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if the element has the given attribute.
|
||||||
|
*/
|
||||||
|
public function hasAttribute(string $attribute): bool
|
||||||
|
{
|
||||||
|
return $this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Data/ConnectService/ConnectServiceData.php
Normal file
19
app/Data/ConnectService/ConnectServiceData.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\ConnectService;
|
||||||
|
|
||||||
|
use Spatie\LaravelData\Data;
|
||||||
|
|
||||||
|
class ConnectServiceData extends Data
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public int $serviceProviderId,
|
||||||
|
public int $serviceProtocolId,
|
||||||
|
public ?string $connectionService = null,
|
||||||
|
public ?string $logoUrl = null,
|
||||||
|
public ?string $slug = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
34
app/Factories/ConnectedServiceFactory.php
Normal file
34
app/Factories/ConnectedServiceFactory.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Factories;
|
||||||
|
|
||||||
|
use App\Data\ConnectService\ConnectServiceData;
|
||||||
|
use App\Enums\ConnectionStatus;
|
||||||
|
use App\Models\ServiceStatus;
|
||||||
|
|
||||||
|
final class ConnectedServiceFactory
|
||||||
|
{
|
||||||
|
public function createDataFromDto(ConnectServiceData $data): array
|
||||||
|
{
|
||||||
|
$result = [
|
||||||
|
'name' => $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Livewire/Forms/ConnectServiceFrom.php
Normal file
41
app/Livewire/Forms/ConnectServiceFrom.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Forms;
|
||||||
|
|
||||||
|
use App\Data\ConnectService\ConnectServiceData;
|
||||||
|
use App\Services\ConnectedServicesService;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Form;
|
||||||
|
|
||||||
|
class ConnectServiceFrom extends Form
|
||||||
|
{
|
||||||
|
#[Validate('required|string|max:255|min:3')]
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
#[Validate('required|numeric|min:1')]
|
||||||
|
public int $providerId = 0;
|
||||||
|
|
||||||
|
#[Validate('required|numeric|min:1')]
|
||||||
|
public int $protocolId = 0;
|
||||||
|
|
||||||
|
#[Validate('nullable|string|max:255|min:3')]
|
||||||
|
public ?string $logoUrl = null;
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,31 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Table;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
final class ServiceStatus extends Model {}
|
#[Table('service_statuses')]
|
||||||
|
class ServiceStatus extends Model {}
|
||||||
|
|||||||
23
app/Services/ConnectedServicesService.php
Normal file
23
app/Services/ConnectedServicesService.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Data\ConnectService\ConnectServiceData;
|
||||||
|
use App\Factories\ConnectedServiceFactory;
|
||||||
|
use App\Models\ConnectedService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class ConnectedServicesService
|
||||||
|
{
|
||||||
|
public function create(ConnectServiceData $data): void
|
||||||
|
{
|
||||||
|
$data = app(ConnectedServiceFactory::class)->createDataFromDto($data);
|
||||||
|
DB::transaction(
|
||||||
|
fn () => ConnectedService::create(
|
||||||
|
$data
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
430
app/View/Components/Choices.php
Normal file
430
app/View/Components/Choices.php
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\View\Components;
|
||||||
|
|
||||||
|
use App\Concerns\UI\HasAttributeHelpers;
|
||||||
|
use Closure;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\View\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This allows dropdowns with search, multiple selections, fully inline with server.
|
||||||
|
* This is code from MaryUI Choices component, modified to work with our usecase.
|
||||||
|
* https://github.com/robsontenorio/mary/blob/main/src/View/Components/Choices.php
|
||||||
|
*/
|
||||||
|
final class Choices extends Component
|
||||||
|
{
|
||||||
|
use HasAttributeHelpers;
|
||||||
|
|
||||||
|
public string $uuid;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public ?string $id = null,
|
||||||
|
public ?string $label = null,
|
||||||
|
public ?string $hint = null,
|
||||||
|
public ?string $hintClass = 'fieldset-label',
|
||||||
|
public ?string $icon = null,
|
||||||
|
public ?string $iconRight = null,
|
||||||
|
public ?bool $inline = false,
|
||||||
|
public ?bool $clearable = false,
|
||||||
|
public ?string $prefix = null,
|
||||||
|
public ?string $suffix = null,
|
||||||
|
public ?bool $searchable = false,
|
||||||
|
public ?bool $noProgress = false,
|
||||||
|
public ?bool $single = false,
|
||||||
|
public ?bool $compact = false,
|
||||||
|
public ?string $compactText = 'selected',
|
||||||
|
public ?bool $allowAll = false,
|
||||||
|
public ?string $debounce = '250ms',
|
||||||
|
public ?int $minChars = 0,
|
||||||
|
public ?string $allowAllText = 'Select all',
|
||||||
|
public ?string $removeAllText = 'Remove all',
|
||||||
|
public ?string $searchFunction = 'search',
|
||||||
|
public ?string $optionValue = 'id',
|
||||||
|
public ?string $optionLabel = 'name',
|
||||||
|
public ?string $optionSubLabel = '',
|
||||||
|
public ?bool $valuesAsString = false,
|
||||||
|
public ?bool $escapeValues = false,
|
||||||
|
public ?string $height = 'max-h-64',
|
||||||
|
public Collection|array $options = new Collection(),
|
||||||
|
public ?string $noResultText = 'No results found.',
|
||||||
|
|
||||||
|
// Validations
|
||||||
|
public ?string $errorField = null,
|
||||||
|
public ?string $errorClass = 'text-error',
|
||||||
|
public ?bool $omitError = false,
|
||||||
|
public ?bool $firstErrorOnly = false,
|
||||||
|
|
||||||
|
// Slots
|
||||||
|
public mixed $item = null,
|
||||||
|
public mixed $selection = null,
|
||||||
|
public mixed $prepend = null,
|
||||||
|
public mixed $append = null
|
||||||
|
) {
|
||||||
|
$this->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'
|
||||||
|
<div x-data="{ focused: false, selection: @entangle($attributes->wire('model')) }">
|
||||||
|
<div
|
||||||
|
@click.outside = "clear()"
|
||||||
|
@keyup.esc = "clear()"
|
||||||
|
|
||||||
|
x-data="{
|
||||||
|
options: {{ json_encode($options) }},
|
||||||
|
isSingle: {{ json_encode($single) }},
|
||||||
|
isSearchable: {{ json_encode($searchable) }},
|
||||||
|
isReadonly: {{ json_encode($isReadonly()) }},
|
||||||
|
isDisabled: {{ json_encode($isDisabled()) }},
|
||||||
|
isRequired: {{ json_encode($isRequired()) }},
|
||||||
|
minChars: {{ $minChars }},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Fix weird issue when navigating back
|
||||||
|
document.addEventListener('livewire:navigating', () => {
|
||||||
|
let elements = document.querySelectorAll('.choices-element');
|
||||||
|
elements.forEach(el => el.remove());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
get selectedOptions() {
|
||||||
|
return this.isSingle
|
||||||
|
? this.options.filter(i => i.{{ $optionValue }} == this.selection)
|
||||||
|
: this.selection.map(i => this.options.filter(o => o.{{ $optionValue }} == i)[0])
|
||||||
|
},
|
||||||
|
get noResults() {
|
||||||
|
if (!this.isSearchable || this.$refs.searchInput.value == '') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.isSingle
|
||||||
|
? (this.selection && this.options.length == 1) || (!this.selection && this.options.length == 0)
|
||||||
|
: this.options.length <= this.selection.length
|
||||||
|
},
|
||||||
|
get isAllSelected() {
|
||||||
|
return this.options.length == this.selection.length
|
||||||
|
},
|
||||||
|
get isSelectionEmpty() {
|
||||||
|
return this.isSingle
|
||||||
|
? this.selection == null || this.selection == ''
|
||||||
|
: this.selection.length == 0
|
||||||
|
},
|
||||||
|
selectAll() {
|
||||||
|
this.selection = this.options.map(i => i.{{ $optionValue }})
|
||||||
|
this.dispatchChangeEvent({ value: this.selection })
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
this.focused = false;
|
||||||
|
this.$refs.searchInput.value = ''
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.clear();
|
||||||
|
this.isSingle
|
||||||
|
? this.selection = null
|
||||||
|
: this.selection = []
|
||||||
|
|
||||||
|
this.dispatchChangeEvent({ value: this.selection})
|
||||||
|
},
|
||||||
|
focus() {
|
||||||
|
if (this.isReadonly || this.isDisabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.focused = true
|
||||||
|
this.$refs.searchInput.focus()
|
||||||
|
},
|
||||||
|
resize() {
|
||||||
|
$nextTick(() => $refs.searchInput.style.width = ($refs.searchInput.value.length + 1) * 0.55 + 'rem')
|
||||||
|
},
|
||||||
|
isActive(id) {
|
||||||
|
return this.isSingle
|
||||||
|
? this.selection == id
|
||||||
|
: this.selection.includes(id)
|
||||||
|
},
|
||||||
|
toggle(id, keepOpen = false) {
|
||||||
|
if (this.isReadonly || this.isDisabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSingle) {
|
||||||
|
this.selection = id
|
||||||
|
this.focused = false
|
||||||
|
} else {
|
||||||
|
this.selection.includes(id)
|
||||||
|
? this.selection = this.selection.filter(i => i != id)
|
||||||
|
: this.selection.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchChangeEvent({ value: this.selection })
|
||||||
|
|
||||||
|
this.$refs.searchInput.value = ''
|
||||||
|
|
||||||
|
if (!keepOpen) {
|
||||||
|
this.$refs.searchInput.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
search(value, event) {
|
||||||
|
if (value.length < this.minChars) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent search for this keys
|
||||||
|
if (event && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Shift', 'CapsLock', 'Tab',
|
||||||
|
'Control', 'Alt', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call search function from parent component
|
||||||
|
// `search(value)` or `search(value, extra1, extra2 ...)`
|
||||||
|
@this.{{ str_contains($searchFunction, '(')
|
||||||
|
? preg_replace('/\((.*?)\)/', '(value, $1)', $searchFunction)
|
||||||
|
: $searchFunction . '(value)'
|
||||||
|
}}.then(()=> this.resize())
|
||||||
|
},
|
||||||
|
dispatchChangeEvent(detail) {
|
||||||
|
this.$refs.searchInput.dispatchEvent(new CustomEvent('change-selection', { bubbles: true, detail }))
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
|
||||||
|
@keydown.up="$focus.previous()"
|
||||||
|
@keydown.down="$focus.next()"
|
||||||
|
>
|
||||||
|
<fieldset class="flex flex-col py-0">
|
||||||
|
{{-- STANDARD LABEL --}}
|
||||||
|
@if($label && !$inline)
|
||||||
|
<legend class="text-sm font-medium text-gray-900 mb-2 ml-1">
|
||||||
|
{{ $label }}
|
||||||
|
|
||||||
|
@if($attributes->get('required'))
|
||||||
|
<span class="text-red-400">*</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</legend>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<label @class(["floating-label" => $label && $inline])>
|
||||||
|
{{-- FLOATING LABEL--}}
|
||||||
|
@if ($label && $inline)
|
||||||
|
<span class="font-semibold">{{ $label }}</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div @class(["w-full", "join" => $prepend || $append])>
|
||||||
|
{{-- PREPEND --}}
|
||||||
|
@if($prepend)
|
||||||
|
{{ $prepend }}
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- THE LABEL THAT HOLDS THE INPUT --}}
|
||||||
|
<label
|
||||||
|
x-ref="container"
|
||||||
|
|
||||||
|
@if($isDisabled())
|
||||||
|
disabled
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(!$isDisabled() && !$isReadonly())
|
||||||
|
@click="focus()"
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{
|
||||||
|
$attributes->whereStartsWith('class')->class([
|
||||||
|
"flex items-center border border-gray-300 px-4 py-2 rounded-xl w-full min-h-[var(--size)] h-auto ps-2.5",
|
||||||
|
"join-item" => $prepend || $append,
|
||||||
|
"border-dashed" => $attributes->has("readonly") && $attributes->get("readonly") == true,
|
||||||
|
"!select-error" => $errorFieldName() && $errors->has($errorFieldName()) && !$omitError
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{{-- PREFIX --}}
|
||||||
|
@if($prefix)
|
||||||
|
<span class="label">{{ $prefix }}</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="w-full py-0.5 min-h-3 content-center text-wrap">
|
||||||
|
|
||||||
|
{{-- SELECTED OPTIONS --}}
|
||||||
|
<span wire:key="selected-options-{{ $uuid }}">
|
||||||
|
@if($compact)
|
||||||
|
<div class="badge badge-soft">
|
||||||
|
<span class="font-black" x-text="selectedOptions.length"></span> {{ $compactText }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<template x-for="(option, index) in selectedOptions" :key="index">
|
||||||
|
<span class="choices-element cursor-pointer bg-gray-100 rounded-full px-4 py-0.5">
|
||||||
|
{{-- SELECTION SLOT --}}
|
||||||
|
@if($selection)
|
||||||
|
<span x-html="document.getElementById('selection-{{ $uuid . '-\' + option.'. $optionValue }}).innerHTML"></span>
|
||||||
|
@else
|
||||||
|
<span x-text="option?.{{ $optionLabel }}"></span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(!$isDisabled() && !$isReadonly())
|
||||||
|
<lucide-x @click="toggle(option.{{ $optionValue }})" x-show="!isReadonly && !isDisabled && !isSingle" class="w-4 h-4 hover:text-error" />
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{{-- PLACEHOLDER --}}
|
||||||
|
<span :class="(focused || !isSelectionEmpty) && 'hidden'" class="text-gray-400">
|
||||||
|
{{ $attributes->get('placeholder') }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{{-- INPUT SEARCH --}}
|
||||||
|
<input
|
||||||
|
x-ref="searchInput"
|
||||||
|
@input="focus(); resize();"
|
||||||
|
@keydown.arrow-down.prevent="focus()"
|
||||||
|
:required="isRequired && isSelectionEmpty"
|
||||||
|
class="w-1 !inline-block outline-hidden"
|
||||||
|
|
||||||
|
{{ $attributes->whereStartsWith('@') }}
|
||||||
|
|
||||||
|
@if($isReadonly() || $isDisabled() || ! $searchable)
|
||||||
|
readonly
|
||||||
|
@else
|
||||||
|
@focus="focus()"
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($isDisabled())
|
||||||
|
disabled
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($searchable)
|
||||||
|
@keydown.debounce.{{ $debounce }}="search($el.value, $event)"
|
||||||
|
@endif
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- CLEAR ICON --}}
|
||||||
|
@if($clearable && !$isReadonly() && !$isDisabled())
|
||||||
|
<x-lucide-x @click="reset()" x-show="!isSelectionEmpty" class="cursor-pointer w-4 h-4 opacity-40"/>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ICON RIGHT --}}
|
||||||
|
@if($iconRight)
|
||||||
|
<span class="pointer-events-none w-4 h-4 opacity-40">
|
||||||
|
@svg("lucide-{$iconRight}", 'w-4 h-4')
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<x-lucide-chevron-down class="w-4 h-4 opacity-40" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- SUFFIX --}}
|
||||||
|
@if($suffix)
|
||||||
|
<span class="label">{{ $suffix }}</span>
|
||||||
|
@endif
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{{-- APPEND --}}
|
||||||
|
@if($append)
|
||||||
|
{{ $append }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{{-- ERROR --}}
|
||||||
|
@if(!$omitError)
|
||||||
|
<x-shared.error :errorFieldName="$errorFieldName()" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- HINT --}}
|
||||||
|
@if($hint)
|
||||||
|
<div class="{{ $hintClass }}" x-classes="fieldset-label">{{ $hint }}</div>
|
||||||
|
@endif
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{{-- OPTIONS LIST --}}
|
||||||
|
<div x-cloak x-show="focused" class="relative" wire:key="options-list-main-{{ $uuid }}">
|
||||||
|
<div
|
||||||
|
wire:key="options-list-{{ $uuid }}"
|
||||||
|
class="{{ $height }} w-full absolute z-10 shadow-xl bg-gray-50 border border-gray-300 rounded-lg cursor-pointer overflow-y-auto"
|
||||||
|
x-anchor.bottom-start="$refs.container"
|
||||||
|
>
|
||||||
|
|
||||||
|
{{-- PROGRESS --}}
|
||||||
|
@if(!$noProgress)
|
||||||
|
<progress wire:loading wire:target="{{ preg_replace('/\((.*?)\)/', '', $searchFunction) }}" class="progress absolute top-0 h-0.5"></progress>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- SELECT ALL --}}
|
||||||
|
@if($allowAll)
|
||||||
|
<div
|
||||||
|
wire:key="allow-all-{{ rand() }}"
|
||||||
|
class="font-bold border border-base-200 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
<div x-show="!isAllSelected" @click="selectAll()" class="p-3 underline decoration-wavy decoration-info">{{ $allowAllText }}</div>
|
||||||
|
<div x-show="isAllSelected" @click="reset()" class="p-3 underline decoration-wavy decoration-error">{{ $removeAllText }}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- NO RESULTS --}}
|
||||||
|
<div
|
||||||
|
x-show="noResults"
|
||||||
|
wire:key="no-results-{{ rand() }}"
|
||||||
|
class="p-3 decoration-wavy decoration-warning underline font-bold border border-s-4 border-s-warning border-b-base-200"
|
||||||
|
>
|
||||||
|
{{ $noResultText }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@forelse($options as $option)
|
||||||
|
<div
|
||||||
|
wire:key="option-{{ data_get($option, $optionValue) }}"
|
||||||
|
@click="toggle({{ $getOptionValue($option) }}, true)"
|
||||||
|
@keydown.enter="toggle({{ $getOptionValue($option) }}, true)"
|
||||||
|
:class="isActive({{ $getOptionValue($option) }}) && ''"
|
||||||
|
class="border border-gray-200 focus:bg-base-200 focus:outline-none"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{{-- ITEM SLOT --}}
|
||||||
|
@if($item)
|
||||||
|
{{ $item($option) }}
|
||||||
|
@else
|
||||||
|
<x-list-item :item="$option" :value="$optionLabel" :sub-value="$optionSubLabel" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- SELECTION SLOT --}}
|
||||||
|
@if($selection)
|
||||||
|
<span id="selection-{{ $uuid }}-{{ data_get($option, $optionValue) }}" class="hidden">
|
||||||
|
{{ $selection($option) }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="p-3 text-center text-sm">No results found.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/View/Components/Input.php
Normal file
62
app/View/Components/Input.php
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\View\Components;
|
||||||
|
|
||||||
|
use App\Concerns\UI\HasAttributeHelpers;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\View\Component;
|
||||||
|
|
||||||
|
final class Input extends Component
|
||||||
|
{
|
||||||
|
use HasAttributeHelpers;
|
||||||
|
|
||||||
|
public string $uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new component instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?string $label = null,
|
||||||
|
public ?string $id = null,
|
||||||
|
) {
|
||||||
|
$this->uuid = 'component'.md5(serialize($this)).$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the view / contents that represent the component.
|
||||||
|
*/
|
||||||
|
public function render(): View|Closure|string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div>
|
||||||
|
<fieldset class="flex flex-col">
|
||||||
|
@if($label)
|
||||||
|
<legend class="text-sm font-medium text-gray-900 mb-2 ml-1">
|
||||||
|
{{$label}}
|
||||||
|
@if($hasAttribute('required'))
|
||||||
|
<span class="text-red-400">*</span>
|
||||||
|
@endif
|
||||||
|
</legend>
|
||||||
|
@endif
|
||||||
|
<input
|
||||||
|
id="{{$uuid}}"
|
||||||
|
"
|
||||||
|
placeholder="{{$attributes->get('placeholder') ?? ''}}"
|
||||||
|
@if($isAutofocus()) autofocus @endif
|
||||||
|
{{
|
||||||
|
$attributes->merge([
|
||||||
|
'type' => 'text',
|
||||||
|
'class' => 'rounded-xl border border-gray-300 px-4 py-2'
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<x-shared.error :errorFieldName="$errorFieldName()" />
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/View/Components/ListItem.php
Normal file
83
app/View/Components/ListItem.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\View\Components;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\View\Component;
|
||||||
|
|
||||||
|
class ListItem extends Component
|
||||||
|
{
|
||||||
|
public string $uuid;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public object|array $item,
|
||||||
|
public ?string $id = null,
|
||||||
|
public string $value = 'name',
|
||||||
|
public ?string $subValue = '',
|
||||||
|
public ?bool $noSeparator = false,
|
||||||
|
public ?bool $noHover = false,
|
||||||
|
public ?string $link = null,
|
||||||
|
public ?string $fallbackAvatar = null,
|
||||||
|
|
||||||
|
// Slots
|
||||||
|
public mixed $actions = null,
|
||||||
|
) {
|
||||||
|
$this->uuid = 'component'.md5(serialize($this)).$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View|Closure|string
|
||||||
|
{
|
||||||
|
return <<<'HTML'
|
||||||
|
<div wire:key="{{ $uuid }}">
|
||||||
|
<div
|
||||||
|
{{ $attributes->class([
|
||||||
|
"flex justify-start items-center gap-4 px-3 py-3",
|
||||||
|
"hover:bg-gray-200" => !$noHover,
|
||||||
|
"cursor-pointer" => $link
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<div class="flex-1 overflow-hidden whitespace-nowrap text-ellipsis truncate mary-hideable">
|
||||||
|
@if($link)
|
||||||
|
<a href="{{ $link }}" wire:navigate>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div @if(!is_string($value)) {{ $value->attributes->class(["font-semibold truncate"]) }} @else class="font-semibold truncate" @endif>
|
||||||
|
{{ is_string($value) ? data_get($item, $value) : $value }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div @if(!is_string($subValue)) {{ $subValue->attributes->class(["text-base-content/50 text-sm truncate"]) }} @else class="text-base-content/50 text-sm truncate" @endif>
|
||||||
|
{{ is_string($subValue) ? data_get($item, $subValue) : $subValue }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($link)
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ACTION -->
|
||||||
|
@if($actions)
|
||||||
|
@if($link && !Str::of($actions)->contains([':click', '@click' , 'href']))
|
||||||
|
<a href="{{ $link }}" wire:navigate>
|
||||||
|
@endif
|
||||||
|
<div {{ $actions->attributes->class(["flex items-center gap-3 mary-hideable"]) }}>
|
||||||
|
{{ $actions }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($link && !Str::of($actions)->contains([':click', '@click' , 'href']))
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
"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": [
|
"lint": [
|
||||||
"pint --parallel --dirty"
|
"pint --parallel"
|
||||||
],
|
],
|
||||||
"lint:check": [
|
"lint:check": [
|
||||||
"pint --parallel --dirty --test"
|
"pint --parallel --test"
|
||||||
],
|
],
|
||||||
"ci:check": [
|
"ci:check": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
|||||||
32
database/seeders/ServiceProviderSeeder.php
Normal file
32
database/seeders/ServiceProviderSeeder.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\ServiceProvider;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class ServiceProviderSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
[
|
||||||
|
'name' => 'Azure FD',
|
||||||
|
'slug' => 'azure-fd',
|
||||||
|
], [
|
||||||
|
'name' => 'Keka HR',
|
||||||
|
'slug' => 'keka-hr',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Oneclick',
|
||||||
|
'slug' => 'oneclick',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
ServiceProvider::upsert($data, ['slug'], ['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@
|
|||||||
"preset": "laravel",
|
"preset": "laravel",
|
||||||
"rules": {
|
"rules": {
|
||||||
"declare_strict_types": true,
|
"declare_strict_types": true,
|
||||||
"final_class": true,
|
|
||||||
"fully_qualified_strict_types": true,
|
"fully_qualified_strict_types": true,
|
||||||
"group_import": true,
|
"group_import": true,
|
||||||
"single_import_per_statement": false,
|
"single_import_per_statement": false,
|
||||||
@ -37,7 +36,6 @@
|
|||||||
"no_superfluous_elseif": true,
|
"no_superfluous_elseif": true,
|
||||||
"no_useless_else": true,
|
"no_useless_else": true,
|
||||||
"nullable_type_declaration_for_default_null_value": true,
|
"nullable_type_declaration_for_default_null_value": true,
|
||||||
|
|
||||||
"strict_param": true,
|
"strict_param": true,
|
||||||
"date_time_immutable": true,
|
"date_time_immutable": true,
|
||||||
"mb_str_functions": true,
|
"mb_str_functions": true,
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<x-shared.card title="Connected Services" icon="lucide-grid-2x2-plus" class="p-0">
|
<x-shared.card title="Connected Services" icon="lucide-grid-2x2-plus" class="p-0">
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-shared.button>
|
<x-shared.button :link="route('connected-services.create')">
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
<x-lucide-plus class="w-5 h-5"/>
|
<x-lucide-plus class="w-5 h-5"/>
|
||||||
{{ __('Add service') }}
|
{{ __('Add service') }}
|
||||||
|
|||||||
12
resources/views/components/shared/error.blade.php
Normal file
12
resources/views/components/shared/error.blade.php
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@props(['errorFieldName', 'firstErrorOnly' => false])
|
||||||
|
<div class="min-h-2.5 mt-2">
|
||||||
|
@if($errors->has($errorFieldName))
|
||||||
|
@foreach($errors->get($errorFieldName) as $message)
|
||||||
|
@foreach(Arr::wrap($message) as $line)
|
||||||
|
<div class="text-xs text-red-400">{{ $line }}</div>
|
||||||
|
@break($firstErrorOnly)
|
||||||
|
@endforeach
|
||||||
|
@break($firstErrorOnly)
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
68
resources/views/pages/connected-services/⚡create.blade.php
Normal file
68
resources/views/pages/connected-services/⚡create.blade.php
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Forms\ConnectServiceFrom;
|
||||||
|
use App\Models\ServiceProtocol;
|
||||||
|
use App\Models\ServiceProvider;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
|
||||||
|
new
|
||||||
|
#[Title('Create a service')]
|
||||||
|
class extends Component {
|
||||||
|
public ConnectServiceFrom $form;
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function providers()
|
||||||
|
{
|
||||||
|
return ServiceProvider::query()->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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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-input wire:model.live.blur="form.name" required :label="__('Name')"/>
|
||||||
|
<x-input wire:model.live.blur="form.logoUrl" :label="__('Logo Link')"/>
|
||||||
|
<x-choices
|
||||||
|
required
|
||||||
|
wire:model.live="form.protocolId"
|
||||||
|
:label="__('Protocol')"
|
||||||
|
:single="true"
|
||||||
|
:options="$this->protocols"
|
||||||
|
placeholder="Select"
|
||||||
|
/>
|
||||||
|
<x-choices required
|
||||||
|
wire:model.live="form.providerId"
|
||||||
|
:label="__('Provider')"
|
||||||
|
placeholder="Search ..."
|
||||||
|
:single="true"
|
||||||
|
:options="$this->providers"
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<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">Save & Add Another</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>
|
||||||
@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
Route::group(['middleware' => ['auth', 'verified']], function (): void {
|
Route::group(['middleware' => ['auth', 'verified']], function (): void {
|
||||||
Route::livewire('dashboard', 'pages::dashboard')->name('dashboard');
|
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';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user