From 4a4ed78e3cb6e97a5a3cffc65ce7a8997fcbfd2b Mon Sep 17 00:00:00 2001 From: luanfreitasdev Date: Wed, 26 Aug 2026 08:44:39 -0300 Subject: [PATCH] Enhance Livewire partials handling and add tests for inline filters --- resources/js/partials.js | 317 ++++++++++-------- tests/Browser/TableLoadingBrowserTest.php | 64 ++++ tests/Feature/PartialsTest.php | 46 +++ .../views/table-inline-filters-body.blade.php | 9 + 4 files changed, 294 insertions(+), 142 deletions(-) create mode 100644 tests/views/table-inline-filters-body.blade.php diff --git a/resources/js/partials.js b/resources/js/partials.js index 0e79c96..4127d94 100644 --- a/resources/js/partials.js +++ b/resources/js/partials.js @@ -2,8 +2,8 @@ import {findClosestLivewireComponent, isComponentRootEl, isntElement} from "./ut document.addEventListener('livewire:init', () => { Livewire.interceptMessage(({message, onSuccess}) => { - onSuccess(({payload}) => { - queueMicrotask(() => { + onSuccess(({payload, onMorph}) => { + const applyPartials = () => { if (payload.effects?.html) { return } @@ -11,156 +11,189 @@ document.addEventListener('livewire:init', () => { const partialFragments = payload.effects?.partialFragments ?? {} for (const [name, html] of Object.entries(partialFragments)) { - - let els = Array.from( - message.component.el.querySelectorAll( - `[wire\\:partial="${name}"]`, - ), - ).filter( - (el) => - findClosestLivewireComponent(el).id === - message.component.id, - ) - - if (!els.length) { - continue - } - - let el = els[0] - - const getIgnoreKey = (node) => { - return node.getAttribute('wire:partial.ignore') || - node.getAttribute('wire:key') || - node.id - } - - const applyMarkers = (node) => { - if (node.hasAttribute('wire:partial.ignore')) { - let key = getIgnoreKey(node) - if (key && !node.previousSibling?.nodeValue?.includes(`START:PARTIAL-IGNORE:${key}`)) { - node.before(document.createComment(`START:PARTIAL-IGNORE:${key}`)) - node.after(document.createComment(`END:PARTIAL-IGNORE:${key}`)) - } - } - node.querySelectorAll('[wire\\:partial\\.ignore]').forEach(child => { - let key = getIgnoreKey(child) - if (key && !child.previousSibling?.nodeValue?.includes(`START:PARTIAL-IGNORE:${key}`)) { - child.before(document.createComment(`START:PARTIAL-IGNORE:${key}`)) - child.after(document.createComment(`END:PARTIAL-IGNORE:${key}`)) - } - }) + try { + morphPartial(message, name, html) + } catch (error) { + console.error(`[powergrid-partials] Failed to morph "${name}"`, error) } + } + } - const capturedContent = new Map() - - const captureContent = (node) => { - if (node.hasAttribute('wire:partial.ignore')) { - let key = getIgnoreKey(node) - if (key) { - capturedContent.set(key, node.innerHTML) - } - } - node.querySelectorAll('[wire\\:partial\\.ignore]').forEach(child => { - let key = getIgnoreKey(child) - if (key) { - capturedContent.set(key, child.innerHTML) - } - }) - } + if (typeof onMorph === 'function') { + onMorph(applyPartials) + } else { + queueMicrotask(applyPartials) + } + }) + }) +}) - captureContent(el) - applyMarkers(el) +function wrapperTagName(el) { + const parent = el.parentElement + const tag = parent ? parent.tagName.toLowerCase() : 'div' + const customElement = window.customElements?.get(tag) + + return customElement?.name || tag +} + +function morphPartial(message, name, html) { + let els = Array.from( + message.component.el.querySelectorAll( + `[wire\\:partial="${name}"]`, + ), + ).filter( + (el) => + findClosestLivewireComponent(el).id === + message.component.id, + ) + + if (!els.length) { + return + } + + let el = els[0] + + const getIgnoreKey = (node) => { + return node.getAttribute('wire:partial.ignore') || + node.getAttribute('wire:key') || + node.id + } + + const applyMarkers = (node) => { + if (node.hasAttribute('wire:partial.ignore')) { + let key = getIgnoreKey(node) + if (key && !node.previousSibling?.nodeValue?.includes(`START:PARTIAL-IGNORE:${key}`)) { + node.before(document.createComment(`START:PARTIAL-IGNORE:${key}`)) + node.after(document.createComment(`END:PARTIAL-IGNORE:${key}`)) + } + } + node.querySelectorAll('[wire\\:partial\\.ignore]').forEach(child => { + let key = getIgnoreKey(child) + if (key && !child.previousSibling?.nodeValue?.includes(`START:PARTIAL-IGNORE:${key}`)) { + child.before(document.createComment(`START:PARTIAL-IGNORE:${key}`)) + child.after(document.createComment(`END:PARTIAL-IGNORE:${key}`)) + } + }) + } + + const capturedContent = new Map() + + const captureContent = (node) => { + if (node.hasAttribute('wire:partial.ignore')) { + let key = getIgnoreKey(node) + if (key) { + capturedContent.set(key, node.innerHTML) + } + } + node.querySelectorAll('[wire\\:partial\\.ignore]').forEach(child => { + let key = getIgnoreKey(child) + if (key) { + capturedContent.set(key, child.innerHTML) + } + }) + } - let wrapperTag = el.parentElement - ? el.parentElement.tagName.toLowerCase() - : 'div' + captureContent(el) + applyMarkers(el) - let wrapper = document.createElement(wrapperTag) + let wrapper = document.createElement(wrapperTagName(el)) - wrapper.innerHTML = html - wrapper.__livewire = message.component + wrapper.innerHTML = html + wrapper.__livewire = message.component - let to = wrapper.firstElementChild + let to = wrapper.firstElementChild - to.__livewire = message.component + if (!to) { + return + } - applyMarkers(to) + to.__livewire = message.component - to.querySelectorAll('[wire\\:partial\\.ignore]').forEach(node => { - let key = getIgnoreKey(node) - if (key && node.innerHTML.includes(``)) { - node.innerHTML = capturedContent.get(key) - } - }) + applyMarkers(to) - if (to.hasAttribute('wire:partial.ignore')) { - let key = getIgnoreKey(to) - if (key && to.innerHTML.includes(``)) { - to.innerHTML = capturedContent.get(key) - } - } + to.querySelectorAll('[wire\\:partial\\.ignore]').forEach(node => { + let key = getIgnoreKey(node) + if (key && node.innerHTML.includes(``)) { + node.innerHTML = capturedContent.get(key) + } + }) - window.Alpine.morph(el, to, { - updating: (el, toEl, childrenOnly, skip) => { - if (isntElement(el)) { - return - } - - if (el.__livewire_replace === true) { - el.innerHTML = toEl.innerHTML - } - - if (el.__livewire_replace_self === true) { - el.outerHTML = toEl.outerHTML - - return skip() - } - - if (el.hasAttribute('wire:partial.ignore')) { - return skip() - } - - if (el.__livewire_ignore === true) { - return skip() - } - - if (el.__livewire_ignore_self === true) { - childrenOnly() - } - - if ( - isComponentRootEl(el) && - el.getAttribute('wire:id') !== - message.component.id - ) { - return skip() - } - - if (isComponentRootEl(el)) { - toEl.__livewire = message.component - } - }, - - key: (el) => { - if (isntElement(el)) { - return - } - - if (el.hasAttribute(`wire:key`)) { - return el.getAttribute(`wire:key`) - } - - if (el.hasAttribute(`wire:id`)) { - return el.getAttribute(`wire:id`) - } - - return el.id - }, - - lookahead: false, - }) - } - }) - }) + if (to.hasAttribute('wire:partial.ignore')) { + let key = getIgnoreKey(to) + if (key && to.innerHTML.includes(``)) { + to.innerHTML = capturedContent.get(key) + } + } + + Livewire.trigger?.('morph', {el, toEl: to, component: message.component}) + + window.Alpine.morph(el, to, { + updating: (el, toEl, childrenOnly, skip) => { + if (isntElement(el)) { + return + } + + if (el.__livewire_replace === true) { + el.innerHTML = toEl.innerHTML + } + + if (el.__livewire_replace_self === true) { + el.outerHTML = toEl.outerHTML + + return skip() + } + + if (el.hasAttribute('wire:partial.ignore')) { + return skip() + } + + if (el.__livewire_ignore === true || el.hasAttribute('wire:ignore')) { + return skip() + } + + if (el.__livewire_ignore_self === true || el.hasAttribute('wire:ignore.self')) { + childrenOnly() + } + + if ( + isComponentRootEl(el) && + el.getAttribute('wire:id') !== + message.component.id + ) { + return skip() + } + + if (isComponentRootEl(el)) { + toEl.__livewire = message.component + } + }, + + added: (addedEl) => { + if (isntElement(addedEl)) { + return + } + + Livewire.trigger?.('morph.added', {el: addedEl}) + }, + + key: (el) => { + if (isntElement(el)) { + return + } + + if (el.hasAttribute(`wire:key`)) { + return el.getAttribute(`wire:key`) + } + + if (el.hasAttribute(`wire:id`)) { + return el.getAttribute(`wire:id`) + } + + return el.id + }, + + lookahead: false, }) -}) + + Livewire.trigger?.('morphed', {el, component: message.component}) +} diff --git a/tests/Browser/TableLoadingBrowserTest.php b/tests/Browser/TableLoadingBrowserTest.php index 9797140..1ab0619 100644 --- a/tests/Browser/TableLoadingBrowserTest.php +++ b/tests/Browser/TableLoadingBrowserTest.php @@ -177,9 +177,43 @@ public function render() } } +class TableInlineFiltersBrowserTest extends Component +{ + public int $pageNumber = 1; + + public string $filterUniqid; + + public function mount(): void + { + $this->filterUniqid = uniqid('filter-'); + } + + public function nextPage(): void + { + $this->pageNumber++; + + partials($this)->partial('table-body', 'table-inline-filters-body'); + } + + public function render() + { + return <<<'BLADE' +
+ + + @include('table-inline-filters-body', ['__partial' => $this]) + +
+ +
+ BLADE; + } +} + beforeEach(function () { Livewire::component('browser-table-component', TableLoadingBrowserTest::class); Livewire::component('browser-loading-component', TableWithLoadingBrowserTest::class); + Livewire::component('browser-inline-filters-component', TableInlineFiltersBrowserTest::class); Route::get('/browser-table-test', fn () => Blade::render(' @@ -218,6 +252,23 @@ public function render() '))->middleware('web'); + + Route::get('/browser-inline-filters-test', fn () => Blade::render(' + + + + @livewireStyles + + + + @livewireScripts + + + + '))->middleware('web'); }); it('does not re-render search input when sorting columns', function () { @@ -521,3 +572,16 @@ public function render() expect($partialSize)->toBeGreaterThan(0) ->and($fullSize)->toBeGreaterThan($partialSize); }); + +it('updates tbody rows while preserving an ignored inline-filter row', function () { + $page = $this->visit('/browser-inline-filters-test') + ->assertSeeIn('#page-cell', 'page-1'); + + $filterBefore = $page->text('#filter-cell'); + + $page->click('#next-page') + ->waitForEvent('networkidle') + ->assertSeeIn('#page-cell', 'page-2'); + + expect($page->text('#filter-cell'))->toBe($filterBefore); +}); diff --git a/tests/Feature/PartialsTest.php b/tests/Feature/PartialsTest.php index 91bb282..2a8cbc6 100644 --- a/tests/Feature/PartialsTest.php +++ b/tests/Feature/PartialsTest.php @@ -35,3 +35,49 @@ public function render() ->call('increment') ->assertSet('count', 1); }); + +class TableIgnorePartialsTest extends Component +{ + public int $count = 0; + + public function increment(): void + { + $this->count++; + + partials($this)->partial('table-body', << + keep + {$this->count} + +HTML); + } + + public function render() + { + return <<<'BLADE' +
+ + + + + +
keep
{{ $count }}
+ +
+ BLADE; + } +} + +it('emits tbody partialFragments without a full html effect', function () { + $test = Livewire::test(TableIgnorePartialsTest::class) + ->call('increment'); + + expect(data_get($test->effects, 'html'))->toBeNull(); + + $html = data_get($test->effects, 'partialFragments.table-body'); + + expect($html)->toBeString() + ->toContain('wire:partial="table-body"') + ->toContain('PARTIAL:IGNORE:filters') + ->toContain('id="count">1'); +}); diff --git a/tests/views/table-inline-filters-body.blade.php b/tests/views/table-inline-filters-body.blade.php new file mode 100644 index 0000000..6d5c950 --- /dev/null +++ b/tests/views/table-inline-filters-body.blade.php @@ -0,0 +1,9 @@ + + {{ $__partial->filterUniqid }} + + + page-{{ $__partial->pageNumber }} +