From 76d3386a4d69f894258210ea265717a8fb8cd778 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:37:03 +0200 Subject: [PATCH] refactor(forms): unify searchable and multi-select listboxes Replace datalist and searchable-listbox components with the shared listbox and migrate form fields. Deduplicate superseded Livewire updates to prevent repeated array update hooks, with regression coverage. --- app/View/Components/Forms/Datalist.php | 78 ----- resources/js/app.js | 2 + resources/js/livewire-update-dedupe.js | 26 ++ resources/js/livewire-update-dedupe.test.js | 36 ++ .../views/components/forms/datalist.blade.php | 309 ------------------ .../views/components/forms/listbox.blade.php | 165 ++++++++-- .../forms/searchable-listbox.blade.php | 140 -------- .../sqlite/connect-application.blade.php | 4 +- .../new/github-private-repository.blade.php | 4 +- .../new/gitlab-private-repository.blade.php | 14 +- .../server/docker-registries/login.blade.php | 21 +- .../server/new/by-digital-ocean.blade.php | 15 +- .../livewire/server/new/by-hetzner.blade.php | 43 ++- .../livewire/server/new/by-vultr.blade.php | 13 +- .../partials/localhost-general.blade.php | 6 +- .../views/livewire/server/show.blade.php | 6 +- .../views/livewire/settings/index.blade.php | 8 +- tests/Feature/ListboxComponentTest.php | 128 ++++++++ .../SearchableListboxComponentTest.php | 22 -- .../Server/HetznerServerCreationTest.php | 14 +- tests/Feature/ServerDockerRegistriesTest.php | 1 + tests/Unit/DatalistComponentTest.php | 68 ---- 22 files changed, 402 insertions(+), 721 deletions(-) delete mode 100644 app/View/Components/Forms/Datalist.php create mode 100644 resources/js/livewire-update-dedupe.js create mode 100644 resources/js/livewire-update-dedupe.test.js delete mode 100644 resources/views/components/forms/datalist.blade.php delete mode 100644 resources/views/components/forms/searchable-listbox.blade.php create mode 100644 tests/Feature/ListboxComponentTest.php delete mode 100644 tests/Feature/SearchableListboxComponentTest.php delete mode 100644 tests/Unit/DatalistComponentTest.php diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php deleted file mode 100644 index b0f85c8cbb..0000000000 --- a/app/View/Components/Forms/Datalist.php +++ /dev/null @@ -1,78 +0,0 @@ -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'); - } -} diff --git a/resources/js/app.js b/resources/js/app.js index 46e4a33dd2..dde95415a6 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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 diff --git a/resources/js/livewire-update-dedupe.js b/resources/js/livewire-update-dedupe.js new file mode 100644 index 0000000000..11cac894c7 --- /dev/null +++ b/resources/js/livewire-update-dedupe.js @@ -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} updates + * @returns {Record} + */ +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); + }); +} diff --git a/resources/js/livewire-update-dedupe.test.js b/resources/js/livewire-update-dedupe.test.js new file mode 100644 index 0000000000..3b60b66ada --- /dev/null +++ b/resources/js/livewire-update-dedupe.test.js @@ -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] }); +}); diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php deleted file mode 100644 index 76e46b70ae..0000000000 --- a/resources/views/components/forms/datalist.blade.php +++ /dev/null @@ -1,309 +0,0 @@ -
- @if ($label) - - @endif - - @if ($multiple) - {{-- Multiple Selection Mode with Alpine.js --}} -
- - {{-- Unified Input Container with Tags Inside --}} -
- - {{-- Selected Tags Inside Input --}} - - - {{-- Search Input (Borderless, Inside Container) --}} - -
- - {{-- Dropdown Options --}} -
- - - - -
- - {{-- Hidden datalist for options --}} - - {{ $slot }} - -
- @else - {{-- Single Selection Mode with Alpine.js --}} -
- - {{-- Hidden input for form validation --}} - - - {{-- Input Container --}} -
- - {{-- Display Selected Value or Search Input --}} -
- - -
- - {{-- Dropdown Arrow --}} - -
- - {{-- Dropdown Options --}} -
- - - - -
- - {{-- Hidden datalist for options --}} - - {{ $slot }} - -
- @endif - - @error($modelBinding) - - @enderror -
diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index 639a776a75..2f382fd1c2 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -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
@@ -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()"> @if ($portal)