Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-07 13:37:32 +00:00
22 changed files with 402 additions and 721 deletions
-78
View File
@@ -1,78 +0,0 @@
<?php
namespace App\View\Components\Forms;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
class Datalist extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
/**
* Create a new component instance.
*/
public function __construct(
public ?string $id = null,
public ?string $name = null,
public ?string $label = null,
public ?string $helper = null,
public bool $required = false,
public bool $disabled = false,
public bool $readonly = false,
public bool $multiple = false,
public string|bool $instantSave = false,
public ?string $value = null,
public ?string $placeholder = null,
public bool $autofocus = false,
public string $defaultClass = 'input',
public ?string $canGate = null,
public mixed $canResource = null,
public bool $autoDisable = true,
) {
// Handle authorization-based disabling
if ($this->canGate && $this->canResource && $this->autoDisable) {
$hasPermission = Gate::allows($this->canGate, $this->canResource);
if (! $hasPermission) {
$this->disabled = true;
$this->instantSave = false; // Disable instant save for unauthorized users
}
}
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
// Store original ID for wire:model binding (property name)
$this->modelBinding = $this->id;
if (is_null($this->id)) {
$this->id = new_public_id();
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
// Generate unique HTML ID by adding random suffix
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
$uniqueSuffix = new_public_id();
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
}
if (is_null($this->name)) {
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
return view('components.forms.datalist');
}
}
+2
View File
@@ -4,6 +4,7 @@ import { initializeSettingsSidebarAccordionComponent } from './settings-sidebar-
import { initializeTerminalComponent } from './terminal.js';
import './traffic-globe.js';
import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js';
import { registerLivewireUpdateDedupe } from './livewire-update-dedupe.js';
import { parseSubmitAction } from './modal-confirmation.js';
// Used by the modal-confirmation Blade component to call its submitAction.
@@ -11,6 +12,7 @@ window.parseModalSubmitAction = parseSubmitAction;
document.addEventListener('livewire:init', () => {
registerLivewireRequestFailureHandler(window.Livewire);
registerLivewireUpdateDedupe(window.Livewire);
});
// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate
+26
View File
@@ -0,0 +1,26 @@
/**
* Livewire 3 sends a nested diff (`ids.0`) next to a queued whole-property
* update (`ids`) when an array changes through `.live` entangle or `$set`,
* so `updatedIds()` runs once per key. Livewire 4 drops the superseded nested
* keys in `mergeQueuedUpdates`; this backports that fix.
*
* @param {Record<string, unknown>} updates
* @returns {Record<string, unknown>}
*/
export function removeSupersededUpdates(updates) {
const keys = Object.keys(updates);
keys.forEach((key) => {
if (keys.some((parent) => key.startsWith(`${parent}.`))) {
delete updates[key];
}
});
return updates;
}
export function registerLivewireUpdateDedupe(Livewire) {
Livewire.hook('commit', ({ commit }) => {
removeSupersededUpdates(commit.updates);
});
}
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerLivewireUpdateDedupe, removeSupersededUpdates } from './livewire-update-dedupe.js';
test('drops nested keys that a whole-property update replaces', () => {
assert.deepEqual(
removeSupersededUpdates({ 'ids.0': 1, ids: [1] }),
{ ids: [1] },
);
assert.deepEqual(
removeSupersededUpdates({ 'ids.1': 3, 'ids.2': '__rm__', ids: [1, 3] }),
{ ids: [1, 3] },
);
});
test('keeps nested keys without a parent update and similarly named properties', () => {
assert.deepEqual(
removeSupersededUpdates({ 'form.name': 'a', idsExtra: [2], ids: [1] }),
{ 'form.name': 'a', idsExtra: [2], ids: [1] },
);
});
test('cleans the payload in the Livewire commit hook', () => {
let commitHook = null;
registerLivewireUpdateDedupe({
hook(name, callback) {
assert.equal(name, 'commit');
commitHook = callback;
},
});
const commit = { updates: { 'ids.0': 1, ids: [1] } };
commitHook({ commit });
assert.deepEqual(commit.updates, { ids: [1] });
});
@@ -1,309 +0,0 @@
<div class="w-full">
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neutral-600' : '' }}">
{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
@if ($helper)
<x-helper :helper="$helper" />
@endif
</label>
@endif
@if ($multiple)
{{-- Multiple Selection Mode with Alpine.js --}}
<div x-data="{
open: false,
search: '',
selected: @entangle($modelBinding).live,
options: [],
filteredOptions: [],
init() {
this.options = Array.from(this.$refs.datalist.querySelectorAll('option')).map(opt => {
// Try to parse as integer, fallback to string
let value = opt.value;
const intValue = parseInt(value, 10);
if (!isNaN(intValue) && intValue.toString() === value) {
value = intValue;
}
return {
value: value,
text: opt.textContent.trim()
};
});
this.filteredOptions = this.options;
// Ensure selected is always an array
if (!Array.isArray(this.selected)) {
this.selected = [];
}
},
filterOptions() {
if (!this.search) {
this.filteredOptions = this.options;
return;
}
const searchLower = this.search.toLowerCase();
this.filteredOptions = this.options.filter(opt =>
opt.text.toLowerCase().includes(searchLower)
);
},
toggleOption(value) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
this.selected = [];
}
const index = this.selected.indexOf(value);
if (index > -1) {
this.selected.splice(index, 1);
} else {
this.selected.push(value);
}
this.search = '';
this.filterOptions();
// Focus input after selection
this.$refs.searchInput.focus();
},
removeOption(value, event) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
this.selected = [];
return;
}
// Prevent triggering container click
event.stopPropagation();
const index = this.selected.indexOf(value);
if (index > -1) {
this.selected.splice(index, 1);
}
},
isSelected(value) {
// Ensure selected is an array
if (!Array.isArray(this.selected)) {
return false;
}
return this.selected.includes(value);
},
getSelectedText(value) {
const option = this.options.find(opt => opt.value == value);
return option ? option.text : value;
}
}" @click.outside="open = false" class="relative">
{{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text px-1 text-black dark:text-white"
:style="(() => {
const isDark = document.documentElement.classList.contains('dark');
const accent = isDark ? '#fcd452' : '#6b16ed';
const border = isDark ? '#242424' : '#e5e5e5';
return focused
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
: 'box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px ' + border + ';';
})()"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" wire:loading.class="opacity-50"
wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]">
{{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value">
<button type="button" @click.stop="removeOption(value, $event)"
:disabled="{{ $disabled ? 'true' : 'false' }}"
class="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs bg-coolgray-200 dark:bg-coolgray-700 rounded whitespace-nowrap {{ $disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400' }}"
aria-label="Remove">
<span x-text="getSelectedText(value)" class="max-w-[200px] truncate"></span>
</button>
</template>
{{-- Search Input (Borderless, Inside Container) --}}
<input type="text" x-model="search" x-ref="searchInput" @input="filterOptions()" @focus="open = true"
@keydown.escape="open = false" :placeholder="(Array.isArray(selected) && selected.length > 0) ? '' :
{{ json_encode($placeholder ?: 'Search...') }}" @required($required) @readonly($readonly)
@disabled($disabled) @if ($autofocus) autofocus @endif
class="flex-1 min-w-[120px] text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-black dark:text-white" />
</div>
{{-- Dropdown Options --}}
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-dropdown max-h-60 overflow-auto scrollbar">
<template x-if="filteredOptions.length === 0">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
No options found
</div>
</template>
<template x-for="option in filteredOptions" :key="option.value">
<div @click="toggleOption(option.value)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-3"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': isSelected(option.value) }">
<span class="relative flex size-[18px] shrink-0">
<input type="checkbox" :checked="isSelected(option.value)"
class="peer absolute inset-0 m-0 h-full w-full appearance-none opacity-0"
tabindex="-1">
<span
class="pointer-events-none absolute inset-0 rounded-[5px] border border-neutral-300 bg-white transition-colors peer-checked:border-coollabs peer-checked:bg-coollabs dark:border-white/[0.14] dark:bg-white/[0.045] dark:peer-checked:border-warning dark:peer-checked:bg-warning"></span>
<svg class="pointer-events-none absolute inset-0 m-auto size-3 scale-75 text-white opacity-0 transition-[opacity,transform] peer-checked:scale-100 peer-checked:opacity-100 dark:text-black"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<span class="text-sm flex-1" x-text="option.text"></span>
</div>
</template>
</div>
{{-- Hidden datalist for options --}}
<datalist x-ref="datalist" style="display: none;">
{{ $slot }}
</datalist>
</div>
@else
{{-- Single Selection Mode with Alpine.js --}}
<div x-data="{
open: false,
search: '',
selected: @entangle(($attributes->whereStartsWith('wire:model')->first() ? $attributes->wire('model')->value() : $modelBinding)).live,
options: [],
filteredOptions: [],
init() {
this.options = Array.from(this.$refs.datalist.querySelectorAll('option')).map(opt => {
// Skip disabled options
if (opt.disabled) {
return null;
}
// Try to parse as integer, fallback to string
let value = opt.value;
const intValue = parseInt(value, 10);
if (!isNaN(intValue) && intValue.toString() === value) {
value = intValue;
}
return {
value: value,
text: opt.textContent.trim()
};
}).filter(opt => opt !== null);
this.filteredOptions = this.options;
},
filterOptions() {
if (!this.search) {
this.filteredOptions = this.options;
return;
}
const searchLower = this.search.toLowerCase();
this.filteredOptions = this.options.filter(opt =>
opt.text.toLowerCase().includes(searchLower)
);
},
selectOption(value) {
this.selected = value;
this.search = '';
this.open = false;
this.filterOptions();
},
openDropdown() {
if ({{ $disabled ? 'true' : 'false' }}) return;
this.open = true;
this.$nextTick(() => {
if (this.$refs.searchInput) {
this.$refs.searchInput.focus();
}
});
},
getSelectedText() {
if (!this.selected || this.selected === 'default') return '';
const option = this.options.find(opt => opt.value == this.selected);
return option ? option.text : this.selected;
},
isDefaultValue() {
return !this.selected || this.selected === 'default' || this.selected === '';
}
}" @click.outside="open = false" class="relative">
{{-- Hidden input for form validation --}}
<input type="hidden" :value="selected" @required($required) />
{{-- Input Container --}}
<div @click="openDropdown()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text text-black dark:text-white"
:style="(() => {
const isDark = document.documentElement.classList.contains('dark');
const accent = isDark ? '#fcd452' : '#6b16ed';
const border = isDark ? '#242424' : '#e5e5e5';
return focused
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
: 'box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px ' + border + ';';
})()"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" wire:loading.class="opacity-50" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]">
{{-- Display Selected Value or Search Input --}}
<div class="flex-1 flex items-center min-w-0 px-1">
<template x-if="!isDefaultValue() && !open">
<span class="text-sm flex-1 truncate text-black dark:text-white px-2"
x-text="getSelectedText()"></span>
</template>
<input type="text" x-show="isDefaultValue() || open" x-model="search" x-ref="searchInput"
@input="filterOptions()" @focus="open = true" @keydown.escape="open = false"
:placeholder="{{ json_encode($placeholder ?: 'Search...') }}" @readonly($readonly)
@disabled($disabled) @if ($autofocus) autofocus @endif
class="flex-1 text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-black dark:text-white px-2" />
</div>
{{-- Dropdown Arrow --}}
<button type="button" @click.stop="open = !open" :disabled="{{ $disabled ? 'true' : 'false' }}"
class="shrink-0 text-neutral-400 px-2 {{ $disabled ? 'cursor-not-allowed' : 'cursor-pointer' }}">
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{{-- Dropdown Options --}}
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-dropdown max-h-60 overflow-auto scrollbar">
<template x-if="filteredOptions.length === 0">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
No options found
</div>
</template>
<template x-for="option in filteredOptions" :key="option.value">
<div @click="selectOption(option.value)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': selected == option.value }">
<span class="text-sm" x-text="option.text"></span>
</div>
</template>
</div>
{{-- Hidden datalist for options --}}
<datalist x-ref="datalist" style="display: none;">
{{ $slot }}
</datalist>
</div>
@endif
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
</div>
@@ -19,6 +19,10 @@
'canGate' => null,
'canResource' => null,
'autoDisable' => true,
'multiple' => false, // true = the bound value is an array of option values
'searchable' => false, // true = a search field filters the options
'searchPlaceholder' => 'Search…',
'searchEmptyText' => 'No matching options',
])
@php
@@ -28,6 +32,28 @@
$triggerId = ($htmlId ?? $id).'-trigger';
$panelId = ($htmlId ?? $id).'-panel';
// A multiple listbox also shows item errors, for example a rule on `ids.*`.
$errorMessage = $wire && $id && isset($errors)
? ($errors->first($id) ?: ($multiple ? $errors->first($id.'.*') : null))
: null;
if ($multiple) {
// Server-rendered trigger text; Alpine keeps it current after init.
if (! $wire) {
$value = is_array($value) ? array_values($value) : [];
}
$selectedValues = $wire ? data_get($__livewire ?? null, $id) : $value;
$selectedValues = is_array($selectedValues) ? array_map('strval', $selectedValues) : [];
$selectedLabels = collect($options)
->filter(fn ($option) => empty($option['header']) && in_array((string) $option['value'], $selectedValues, true))
->pluck('label');
$multipleLabel = match (true) {
$selectedLabels->isEmpty() => $placeholder,
$selectedLabels->count() <= 2 => $selectedLabels->implode(', '),
default => $selectedLabels->count().' selected',
};
}
@endphp
<div class="w-full min-w-0">
@@ -55,21 +81,75 @@
positioned: false,
saving: false,
options: @js(array_values($options)),
@if ($searchable)
query: '',
init() {
this.$watch('open', (open) => open ? this.$nextTick(() => this.$refs.search?.focus()) : this.query = '');
},
get visibleOptions() {
const query = this.query.toLowerCase().trim();
if (query === '') return this.options;
return this.options.filter((option) => !option.header && [option.label, option.value, option.description]
.some((text) => String(text ?? '').toLowerCase().includes(query)));
},
chooseOnlyMatch() {
const matches = this.visibleOptions.filter((option) => !option.disabled);
if (matches.length === 1) this.choose(matches[0]);
},
@else
get visibleOptions() {
return this.options;
},
@endif
value: @if (!$wire) @js($value) @elseif ($live && ! $onChange) @entangle($id).live @else @entangle($id) @endif,
@if ($multiple)
get selected() {
return Array.isArray(this.value) ? this.value : [];
},
isSelected(option) {
return this.selected.some((value) => String(value) === String(option.value));
},
get selectedLabels() {
return this.options.filter((option) => !option.header && this.isSelected(option)).map((option) => option.label);
},
get current() {
const labels = this.selectedLabels;
if (labels.length === 0) return @js($placeholder);
return labels.length <= 2 ? labels.join(', ') : `${labels.length} selected`;
},
get title() {
return this.selectedLabels.join(', ') || @js($placeholder);
},
@else
isSelected(option) {
return String(option.value) === String(this.value);
},
get current() {
const found = this.options.find((option) => String(option.value) === String(this.value));
return found ? found.label : @js($placeholder);
},
get title() {
return this.current;
},
@endif
async choose(option) {
if (option.header || option.disabled || this.saving) return;
this.open = false;
if (String(option.value) === String(this.value)) return;
this.value = option.value;
this.$dispatch('listbox-change', { value: option.value });
@if ($multiple)
{{-- Replace the whole array: an in-place splice makes Livewire send two updates for one click. --}}
const next = this.isSelected(option)
? this.selected.filter((value) => String(value) !== String(option.value))
: [...this.selected, option.value];
@else
this.open = false;
if (String(option.value) === String(this.value)) return;
const next = option.value;
@endif
this.value = next;
this.$dispatch('listbox-change', { value: next });
@if ($onChange && is_array($onChangeArgs))
this.saving = true;
try {
await this.$wire.{{ $onChange }}(...@js($onChangeArgs), option.value);
await this.$wire.{{ $onChange }}(...@js($onChangeArgs), next);
} finally {
this.saving = false;
}
@@ -123,12 +203,12 @@
{{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@if ($preserveValue) wire:ignore @endif
@click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()"
@click.outside="document.getElementById(@js($panelId))?.contains($event.target) || (open = false)" @keydown.escape="open = false" @resize.window="open && positionPanel()"
@scroll.window.capture="open && positionPanel()">
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
<span class="listbox-trigger-label" x-text="current"></span>
:aria-expanded="open" @if ($tooltip) :title="title" @endif>
<span class="listbox-trigger-label" x-text="current">{{ $multiple ? $multipleLabel : '' }}</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
@@ -136,7 +216,7 @@
</button>
@if ($portal)
<template x-teleport="body">
<div id="{{ $panelId }}" class="listbox-panel"
<div id="{{ $panelId }}" class="listbox-panel{{ $searchable ? ' searchable-listbox-panel' : '' }}"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-transition:enter="transition ease-out duration-100"
@@ -145,12 +225,27 @@
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
<div x-show="options.length === 0"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox"
@if ($multiple) aria-multiselectable="true" @endif>
@if ($searchable)
<div class="searchable-listbox-search">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-3 size-3 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-ref="search" type="search" x-model="query" autocomplete="off"
placeholder="{{ $searchPlaceholder }}" class="searchable-listbox-search-input"
@keydown.enter.prevent="chooseOnlyMatch()" @keydown.escape.stop="open = false" />
</div>
<div class="searchable-listbox-options">
@endif
<div x-show="visibleOptions.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
@if ($searchable)
<span x-text="options.length === 0 ? @js($emptyText) : @js($searchEmptyText)"></span>
@else
{{ $emptyText }}
@endif
</div>
<template x-for="option in options" :key="String(option.value)">
<template x-for="option in visibleOptions" :key="String(option.value)">
<div>
{{-- Non-selectable group header (options with header: true). Each header is the
only child of its x-for wrapper, so `first:` can't target it — the divider
@@ -162,13 +257,13 @@
<template x-if="!option.header">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
:aria-selected="isSelected(option)" @click="choose(option)">
<span class="flex min-w-0 flex-col">
<span class="truncate" x-text="option.label"></span>
<span x-show="option.description" x-text="option.description"
class="text-xs whitespace-normal text-neutral-500 dark:text-fg-dim"></span>
</span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
<svg x-show="isSelected(option)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
@@ -177,21 +272,39 @@
</template>
</div>
</template>
@if ($searchable)
</div>
@endif
</div>
</template>
@else
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak
<div x-ref="panel" class="listbox-panel{{ $searchable ? ' searchable-listbox-panel' : '' }}" x-show="open" x-cloak
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]" role="listbox">
<div x-show="options.length === 0"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]" role="listbox"
@if ($multiple) aria-multiselectable="true" @endif>
@if ($searchable)
<div class="searchable-listbox-search">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-3 size-3 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-ref="search" type="search" x-model="query" autocomplete="off"
placeholder="{{ $searchPlaceholder }}" class="searchable-listbox-search-input"
@keydown.enter.prevent="chooseOnlyMatch()" @keydown.escape.stop="open = false" />
</div>
<div class="searchable-listbox-options">
@endif
<div x-show="visibleOptions.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
@if ($searchable)
<span x-text="options.length === 0 ? @js($emptyText) : @js($searchEmptyText)"></span>
@else
{{ $emptyText }}
@endif
</div>
<template x-for="option in options" :key="String(option.value)">
<template x-for="option in visibleOptions" :key="String(option.value)">
<div>
{{-- Non-selectable group header (options with header: true). --}}
<template x-if="option.header">
@@ -201,13 +314,13 @@
<template x-if="!option.header">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
:aria-selected="isSelected(option)" @click="choose(option)">
<span class="flex min-w-0 flex-col">
<span class="truncate" x-text="option.label"></span>
<span x-show="option.description" x-text="option.description"
class="text-xs whitespace-normal text-neutral-500 dark:text-fg-dim"></span>
</span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
<svg x-show="isSelected(option)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
@@ -216,7 +329,15 @@
</template>
</div>
</template>
@if ($searchable)
</div>
@endif
</div>
@endif
</div>
@if ($errorMessage)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $errorMessage }}</span>
</label>
@endif
</div>
@@ -1,140 +0,0 @@
@props([
'id' => null,
'label' => null,
'helper' => null,
'required' => false,
'options' => [], // list of ['value' => ..., 'label' => ..., 'disabled' => bool]
'placeholder' => 'Select…',
'searchPlaceholder' => 'Search…',
'emptyText' => 'No matching options',
'live' => false,
'onChange' => null, // optional $wire method to call after a selection
'wire' => true, // false = purely client-side value (no Livewire binding)
'value' => null, // initial value when wire=false
'disabled' => false,
])
<div class="w-full min-w-0">
@if ($label)
{{--
Keep helper outside the label so taps do not open the listbox trigger.
Margin lives only on this wrapper (mb-0 on the label) so spacing matches
plain text labels and is not doubled by .application-settings-form label rules.
--}}
{{-- Fixed h-4 matches the helper icon so side-by-side fields align with or without a helper. --}}
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-trigger" class="mb-0! flex items-center gap-1.5 leading-4">
{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
</label>
@if ($helper)
<x-helper :helper="$helper" />
@endif
</div>
@endif
<div class="relative min-w-0" x-data="{
open: false,
query: '',
saving: false,
options: @js(array_values($options)),
value: @if (!$wire) @js($value) @elseif ($live && ! $onChange) @entangle($id).live @else @entangle($id) @endif,
get current() {
const found = this.options.find((option) => String(option.value) === String(this.value));
return found ? found.label : @js($placeholder);
},
get filtered() {
const query = this.query.toLowerCase().trim();
if (query === '') {
return this.options;
}
return this.options.filter((option) => {
const label = String(option.label ?? '').toLowerCase();
const value = String(option.value ?? '').toLowerCase();
return label.includes(query) || value.includes(query);
});
},
toggle() {
if (@js((bool) $disabled)) {
return;
}
this.open = !this.open;
if (this.open) {
this.$nextTick(() => this.$refs.search?.focus());
} else {
this.query = '';
}
},
close() {
this.open = false;
this.query = '';
},
async choose(option) {
if (this.saving || option.disabled) {
return;
}
this.close();
if (String(option.value) === String(this.value)) {
return;
}
this.value = option.value;
@if ($onChange)
this.saving = true;
try {
await this.$wire.{{ $onChange }}();
} finally {
this.saving = false;
}
@endif
}
}" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }"
{{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@click.outside="close()" @keydown.escape.window="open && close()">
<button id="{{ $id }}-trigger" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled)
{{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" :title="current">
<span class="listbox-trigger-label" x-text="current"></span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg>
</button>
<div class="listbox-panel searchable-listbox-panel" x-show="open" x-cloak role="listbox"
@click.stop>
<div class="searchable-listbox-search">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-3 size-3 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-ref="search" type="search" x-model="query" autocomplete="off"
placeholder="{{ $searchPlaceholder }}"
class="searchable-listbox-search-input"
@keydown.enter.prevent="filtered.length === 1 && choose(filtered[0])"
@keydown.escape.stop="close()" />
</div>
<div class="searchable-listbox-options">
<template x-for="option in filtered" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
<p x-show="filtered.length === 0" class="searchable-listbox-empty">
{{ $emptyText }}
</p>
</div>
</div>
</div>
</div>
@@ -38,9 +38,9 @@
@else
<div class="flex flex-col gap-4">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.searchable-listbox id="applicationUuid" label="Application" required
<x-forms.listbox searchable id="applicationUuid" label="Application" required
:options="$applicationOptions" placeholder="Select an application"
searchPlaceholder="Search applications…" emptyText="No matching applications"
searchPlaceholder="Search applications…" searchEmptyText="No matching applications"
helper="Docker Compose applications mount the volume from their compose file. Coolify shows the lines to add." />
<x-forms.input id="mountPath" label="Mount path" required placeholder="/var/lib/sqlite"
canGate="update" :canResource="$database" x-on:keydown.enter.prevent="$wire.connect()"
@@ -72,7 +72,7 @@
<div class="application-settings-section-body">
@if ($repositories->isNotEmpty())
<div class="flex items-end gap-2">
<x-forms.searchable-listbox id="selected_repository_id" label="Repository" required live
<x-forms.listbox searchable id="selected_repository_id" label="Repository" required live
searchPlaceholder="Search repositories…"
:options="$repositories->map(fn ($repository) => [
'value' => data_get($repository, 'id'),
@@ -105,7 +105,7 @@
</div>
<div class="application-settings-section-body space-y-5">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.searchable-listbox id="selected_branch_name" label="Branch" required
<x-forms.listbox searchable id="selected_branch_name" label="Branch" required
searchPlaceholder="Search branches…"
:options="$branches->map(fn ($branch) => [
'value' => data_get($branch, 'name'),
@@ -70,14 +70,12 @@
<div class="application-settings-section-body">
@if ($repositories->isNotEmpty())
<div class="flex items-end gap-2">
<x-forms.datalist class="w-full" label="Repository"
placeholder="Search repositories…" wire:model.live="selected_project_id">
@foreach ($repositories as $repo)
<option value="{{ data_get($repo, 'id') }}">
{{ data_get($repo, 'path_with_namespace') }}
</option>
@endforeach
</x-forms.datalist>
<x-forms.listbox id="selected_project_id" label="Repository" searchable live
placeholder="Select a repository" searchPlaceholder="Search repositories…"
:options="$repositories->map(fn ($repository) => [
'value' => data_get($repository, 'id'),
'label' => data_get($repository, 'path_with_namespace'),
])->values()->all()" />
<x-forms.button :showLoadingIndicator="false" wire:click.prevent="loadBranches"
wire:target="loadBranches,selected_project_id">
<x-loading-on-button wire:loading.delay
@@ -1,23 +1,10 @@
<div>
<form wire:submit="login" class="flex flex-col gap-3">
@if ($server === null)
<div>
<p class="mb-1.5 text-sm font-medium">Servers <x-highlighted text="*" /></p>
@if ($serverOptions->isEmpty())
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">No reachable server to log in to.</p>
@else
<div class="flex max-h-48 flex-col overflow-y-auto">
@foreach ($serverOptions as $option)
<x-forms.checkbox id="registry-login-server-{{ $option->uuid }}" :domValue="$option->uuid"
wire:model="selectedServers" wire:key="registry-login-server-{{ $option->uuid }}"
:label="e($option->name)" fullWidth />
@endforeach
</div>
@endif
@error('selectedServers')
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@enderror
</div>
<x-forms.listbox id="selectedServers" label="Servers" required multiple searchable portal
:placeholder="$serverOptions->isEmpty() ? 'No reachable server to log in to' : 'Select servers'"
searchPlaceholder="Search servers…" :disabled="$serverOptions->isEmpty()"
:options="$serverOptions->map(fn ($option) => ['value' => $option->uuid, 'label' => $option->name])->values()->all()" />
@endif
@if ($editRegistry === null)
<x-forms.listbox id="provider" label="Provider" live portal :options="$providerOptions" />
@@ -133,16 +133,11 @@
@endif
<div class="flex flex-col gap-4">
<x-forms.datalist label="Extra SSH keys" id="selectedDigitalOceanSshKeyIds"
helper="Existing keys from the DigitalOcean account." :multiple="true"
:disabled="count($digitalOceanSshKeys) === 0"
:placeholder="count($digitalOceanSshKeys) ? 'Search SSH keys' : 'No account keys found'">
@foreach ($digitalOceanSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] ?? $sshKey['fingerprint'] }}
</option>
@endforeach
</x-forms.datalist>
<x-forms.listbox label="Extra SSH keys" id="selectedDigitalOceanSshKeyIds"
helper="Existing keys from the DigitalOcean account." multiple searchable
:disabled="count($digitalOceanSshKeys) === 0" searchPlaceholder="Search SSH keys…"
:placeholder="count($digitalOceanSshKeys) ? 'Select SSH keys' : 'No account keys found'"
:options="collect($digitalOceanSshKeys)->map(fn ($sshKey) => ['value' => $sshKey['id'], 'label' => $sshKey['name'] ?? $sshKey['fingerprint']])->values()->all()" />
<div class="grid gap-3 lg:grid-cols-2">
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6" fullWidth />
@@ -132,32 +132,27 @@
@endif
<div class="flex flex-col gap-4">
<x-forms.datalist label="Extra SSH keys" id="selectedHetznerSshKeyIds"
helper="Existing keys from the Hetzner account." :multiple="true"
:disabled="count($hetznerSshKeys) === 0"
:placeholder="count($hetznerSshKeys) ? 'Search SSH keys' : 'No account keys found'">
@foreach ($hetznerSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">{{ $sshKey['name'] }}</option>
@endforeach
</x-forms.datalist>
<x-forms.listbox label="Extra SSH keys" id="selectedHetznerSshKeyIds"
helper="Existing keys from the Hetzner account." multiple searchable
:disabled="count($hetznerSshKeys) === 0" searchPlaceholder="Search SSH keys…"
:placeholder="count($hetznerSshKeys) ? 'Select SSH keys' : 'No account keys found'"
:options="collect($hetznerSshKeys)->map(fn ($sshKey) => ['value' => $sshKey['id'], 'label' => $sshKey['name']])->values()->all()" />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.datalist label="Firewalls" id="selectedHetznerFirewallIds"
:multiple="true" :disabled="count($hetznerFirewalls) === 0"
:placeholder="count($hetznerFirewalls) ? 'Search firewalls' : 'No firewalls found'">
@foreach ($hetznerFirewalls as $firewall)
<option value="{{ $firewall['id'] }}">{{ $firewall['name'] }}</option>
@endforeach
</x-forms.datalist>
<x-forms.datalist label="Private networks" id="selectedHetznerNetworkIds"
:multiple="true" :disabled="count($this->availableNetworks) === 0"
:placeholder="count($this->availableNetworks) ? 'Search networks' : 'No compatible networks'">
@foreach ($this->availableNetworks as $network)
<option value="{{ $network['id'] }}">
{{ $network['name'] }} · {{ $network['ip_range'] }}
</option>
@endforeach
</x-forms.datalist>
<x-forms.listbox label="Firewalls" id="selectedHetznerFirewallIds"
multiple searchable :disabled="count($hetznerFirewalls) === 0"
searchPlaceholder="Search firewalls…"
:placeholder="count($hetznerFirewalls) ? 'Select firewalls' : 'No firewalls found'"
:options="collect($hetznerFirewalls)->map(fn ($firewall) => ['value' => $firewall['id'], 'label' => $firewall['name']])->values()->all()" />
<x-forms.listbox label="Private networks" id="selectedHetznerNetworkIds"
multiple searchable :disabled="count($this->availableNetworks) === 0"
searchPlaceholder="Search networks…"
:placeholder="count($this->availableNetworks) ? 'Select networks' : 'No compatible networks'"
:options="collect($this->availableNetworks)->map(fn ($network) => [
'value' => $network['id'],
'label' => $network['name'],
'description' => $network['ip_range'],
])->values()->all()" />
</div>
<div class="grid gap-3 lg:grid-cols-3">
@@ -131,14 +131,11 @@
@endif
<div class="flex flex-col gap-4">
<x-forms.datalist label="Extra SSH keys" id="selectedVultrSshKeyIds"
helper="Existing keys from the Vultr account." :multiple="true"
:disabled="count($vultrSshKeys) === 0"
:placeholder="count($vultrSshKeys) ? 'Search SSH keys' : 'No account keys found'">
@foreach ($vultrSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">{{ $sshKey['name'] }}</option>
@endforeach
</x-forms.datalist>
<x-forms.listbox label="Extra SSH keys" id="selectedVultrSshKeyIds"
helper="Existing keys from the Vultr account." multiple searchable
:disabled="count($vultrSshKeys) === 0" searchPlaceholder="Search SSH keys…"
:placeholder="count($vultrSshKeys) ? 'Select SSH keys' : 'No account keys found'"
:options="collect($vultrSshKeys)->map(fn ($sshKey) => ['value' => $sshKey['id'], 'label' => $sshKey['name']])->values()->all()" />
<div class="grid gap-3 lg:grid-cols-2">
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6" fullWidth />
@@ -92,13 +92,13 @@
id="connectionTimeout" label="Connection timeout"
helper="Seconds to wait before an SSH connection fails." min="1" max="300"
required :disabled="$isValidating" />
<x-forms.searchable-listbox id="serverTimezone" label="Server timezone"
<x-forms.listbox searchable id="serverTimezone" label="Server timezone"
helper="Used for backup schedules, cron jobs, and displayed timestamps."
searchPlaceholder="Search timezones" emptyText="No matching timezone"
searchPlaceholder="Search timezones" searchEmptyText="No matching timezone"
:options="collect($this->timezones)->map(fn ($timezone) => [
'value' => $timezone,
'label' => $timezone,
])->all()" :disabled="$isValidating || !auth()->user()->can('update', $server)" />
])->all()" :disabled="$isValidating" canGate="update" :canResource="$server" />
<x-forms.input canGate="update" :canResource="$server"
placeholder="https://example.com" id="wildcardDomain" label="Wildcard domain"
helper="New resources can receive generated subdomains from this domain."
@@ -241,13 +241,13 @@
id="connectionTimeout" label="Connection timeout"
helper="Seconds to wait before an SSH connection fails." min="1" max="300"
required :disabled="$isValidating" />
<x-forms.searchable-listbox id="serverTimezone" label="Server timezone"
<x-forms.listbox searchable id="serverTimezone" label="Server timezone"
helper="Used for backups, cron jobs, and displayed timestamps."
searchPlaceholder="Search timezones" emptyText="No matching timezone"
searchPlaceholder="Search timezones" searchEmptyText="No matching timezone"
:options="collect($this->timezones)->map(fn ($timezone) => [
'value' => $timezone,
'label' => $timezone,
])->all()" :disabled="$isValidating || !auth()->user()->can('update', $server)" />
])->all()" :disabled="$isValidating" canGate="update" :canResource="$server" />
@if (!$isSwarmWorker && $serverRole !== 'build')
<x-forms.input canGate="update" :canResource="$server"
placeholder="https://example.com" id="wildcardDomain" label="Wildcard domain"
@@ -35,15 +35,15 @@
<x-forms.input canGate="update" :canResource="$settings" id="instance_name" label="Name"
placeholder="Coolify" helper="Custom name for this Coolify instance." />
{{-- Use searchable-listbox so the label row (h-4) and control height match
{{-- Use a searchable listbox so the label row (h-4) and control height match
sibling x-forms.input fields (Name). onChange auto-saves like before. --}}
<x-forms.searchable-listbox id="instance_timezone" label="Instance timezone"
<x-forms.listbox searchable id="instance_timezone" label="Instance timezone"
helper="Timezone used for update checks and the automatic update schedule."
searchPlaceholder="Search timezones" emptyText="No matching timezone"
searchPlaceholder="Search timezones" searchEmptyText="No matching timezone"
onChange="submit" :options="collect($this->timezones)->map(fn ($timezone) => [
'value' => $timezone,
'label' => $timezone,
])->all()" :disabled="! auth()->user()->can('update', $settings)" />
])->all()" canGate="update" :canResource="$settings" />
</div>
</x-application.settings-section>
+128
View File
@@ -0,0 +1,128 @@
<?php
use Illuminate\Support\Facades\Blade;
use Livewire\Component;
use Livewire\Livewire;
function multipleListboxTestComponent(): Component
{
return new class extends Component
{
public array $ids = [];
public function mount(array $ids = []): void
{
$this->ids = $ids;
}
public function save(): void
{
$this->validate([
'ids' => 'required|array',
'ids.*' => 'integer|in:1,2,3',
], [
'ids.required' => 'Pick at least one server.',
'ids.*.in' => 'Unknown server selected.',
]);
}
public function render(): string
{
return <<<'BLADE'
<div>
<x-forms.listbox id="ids" label="Servers" :multiple="true" placeholder="Pick servers" :options="[
['value' => 'group', 'label' => 'Group', 'header' => true],
['value' => 1, 'label' => 'Alpha'],
['value' => 2, 'label' => 'Beta'],
['value' => 3, 'label' => 'Gamma'],
]" />
</div>
BLADE;
}
};
}
test('multiple listbox shows the placeholder when nothing is selected', function (mixed $ids) {
Livewire::test(multipleListboxTestComponent(), ['ids' => $ids])
->assertSeeHtml('aria-multiselectable="true"')
->assertSeeHtml('x-text="current">Pick servers</span>');
})->with([[[]], [['group']]]);
test('multiple listbox joins up to two selected labels in the trigger', function () {
// Ids that arrive as strings must match integer option values.
Livewire::test(multipleListboxTestComponent(), ['ids' => ['2', 1]])
->assertSeeHtml('x-text="current">Alpha, Beta</span>');
});
test('multiple listbox shows a count when more than two options are selected', function () {
Livewire::test(multipleListboxTestComponent(), ['ids' => [1, 2, 3]])
->assertSeeHtml('x-text="current">3 selected</span>');
});
test('client-side multiple listbox treats a non-array value as empty', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="tags" :wire="false" :multiple="true" value="1" placeholder="No tags"
:options="[['value' => 1, 'label' => 'One']]" />
BLADE);
expect($html)
->toContain('x-text="current">No tags</span>')
->toMatch('/value:\\s*\\[\\]\\s*,/');
});
test('single listbox output has no multiple-selection attributes', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="mode" :wire="false" value="a" :options="[
['value' => 'a', 'label' => 'A'],
['value' => 'b', 'label' => 'B'],
]" />
BLADE);
expect($html)
->not->toContain('aria-multiselectable')
->not->toContain('get selected()')
->toContain('x-text="current"></span>')
->toMatch("/value:\\s*'a'\\s*,/");
});
test('searchable listbox renders a search field with its placeholders', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="server" :wire="false" searchable searchPlaceholder="Search servers"
searchEmptyText="No matching server" :options="[['value' => 1, 'label' => 'One']]" />
BLADE);
expect($html)
->toContain('type="search"')
->toContain('placeholder="Search servers"')
->toContain('No matching server');
});
test('listbox without searchable renders no search field', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.listbox id="server" :wire="false" :options="[['value' => 1, 'label' => 'One']]" />
BLADE);
expect($html)->not->toContain('type="search"');
});
test('multiple listbox shows the array validation error', function () {
Livewire::test(multipleListboxTestComponent())
->call('save')
->assertHasErrors(['ids' => 'required'])
->assertSee('Pick at least one server.');
});
test('multiple listbox shows item validation errors', function () {
Livewire::test(multipleListboxTestComponent(), ['ids' => [1, 9]])
->call('save')
->assertHasErrors(['ids.1' => 'in'])
->assertSee('Unknown server selected.');
});
test('multiple listbox shows no error when the selection is valid', function () {
Livewire::test(multipleListboxTestComponent(), ['ids' => [1, 2]])
->call('save')
->assertHasNoErrors()
->assertDontSee('Pick at least one server.')
->assertDontSee('Unknown server selected.');
});
@@ -1,22 +0,0 @@
<?php
use Illuminate\Support\Facades\Blade;
test('searchable listbox renders its label, search field, and options', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.searchable-listbox id="serverTimezone" label="Server timezone"
searchPlaceholder="Search timezones" emptyText="No matching timezone"
:options="[
['value' => 'UTC', 'label' => 'UTC'],
['value' => 'Europe/Berlin', 'label' => 'Europe/Berlin'],
['value' => 'America/New_York', 'label' => 'America/New_York'],
]" :wire="false" value="UTC" />
BLADE);
expect($html)
->toContain('Server timezone')
->toContain('Search timezones')
->toContain('No matching timezone')
->toContain('Berlin')
->toContain('New_York');
});
@@ -207,7 +207,19 @@ describe('Boarding Flow Integration', function () {
->assertSee('Adds 20% to the provider server price.');
});
test('shows the cloud init script name only when saving the script', function () {
test('lists account SSH keys and firewalls as listbox options and shows the selection', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->set('hetznerSshKeys', [['id' => 11, 'name' => 'deploy-key']])
->set('hetznerFirewalls', [['id' => 38, 'name' => 'web-firewall'], ['id' => 39, 'name' => 'db-firewall']])
->set('selectedHetznerFirewallIds', [38])
->assertSee('deploy-key')
->assertSee('db-firewall')
->assertSeeHtml('x-text="current">web-firewall</span>')
->assertSeeHtml('x-text="current">Select SSH keys</span>');
});
test('shows the cloud init script name only when saving the script', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->set('show_cloud_init_script', true)
@@ -698,6 +698,7 @@ it('logs in to several servers at once from the overview page', function () {
->set('password', 'token')
->call('login')
->assertHasErrors('selectedServers')
->assertSee('Choose at least one server.')
->set('password', 'token')
->set('selectedServers', [$this->server->uuid, $secondServer->uuid])
->call('login')
-68
View File
@@ -1,68 +0,0 @@
<?php
use App\View\Components\Forms\Datalist;
it('renders with default properties', function () {
$component = new Datalist;
expect($component->required)->toBeFalse()
->and($component->disabled)->toBeFalse()
->and($component->readonly)->toBeFalse()
->and($component->multiple)->toBeFalse()
->and($component->instantSave)->toBeFalse()
->and($component->defaultClass)->toBe('input');
});
it('uses provided id', function () {
$component = new Datalist(id: 'test-datalist');
expect($component->id)->toBe('test-datalist');
});
it('accepts multiple selection mode', function () {
$component = new Datalist(multiple: true);
expect($component->multiple)->toBeTrue();
});
it('accepts instantSave parameter', function () {
$component = new Datalist(instantSave: 'customSave');
expect($component->instantSave)->toBe('customSave');
});
it('accepts placeholder', function () {
$component = new Datalist(placeholder: 'Select an option...');
expect($component->placeholder)->toBe('Select an option...');
});
it('accepts autofocus', function () {
$component = new Datalist(autofocus: true);
expect($component->autofocus)->toBeTrue();
});
it('accepts disabled state', function () {
$component = new Datalist(disabled: true);
expect($component->disabled)->toBeTrue();
});
it('accepts readonly state', function () {
$component = new Datalist(readonly: true);
expect($component->readonly)->toBeTrue();
});
it('accepts authorization properties', function () {
$component = new Datalist(
canGate: 'update',
canResource: 'resource',
autoDisable: false
);
expect($component->canGate)->toBe('update')
->and($component->canResource)->toBe('resource')
->and($component->autoDisable)->toBeFalse();
});