mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/main' into next
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user