= 6ddde416cf 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`.
2026-05-13 09:52:03 +00:00

431 lines
22 KiB
PHP

<?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;
}
}