diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f5c4d3f..32cf50c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,9 @@ name: tests on: push: - branches: [main] + branches: [1.x] pull_request: - branches: [main] + branches: [1.x] jobs: test: diff --git a/tests/Browser/TableLoadingBrowserTest.php b/tests/Browser/TableLoadingBrowserTest.php new file mode 100644 index 0000000..a28e37f --- /dev/null +++ b/tests/Browser/TableLoadingBrowserTest.php @@ -0,0 +1,449 @@ +searchUniqid = uniqid('search-'); + $this->mainUniqid = uniqid('main-'); + + $this->items = [ + ['id' => 1, 'name' => 'Product A', 'price' => 100.00, 'stock' => 10], + ['id' => 2, 'name' => 'Product B', 'price' => 200.50, 'stock' => 5], + ['id' => 3, 'name' => 'Product C', 'price' => 150.75, 'stock' => 15], + ['id' => 4, 'name' => 'Product D', 'price' => 80.00, 'stock' => 20], + ]; + } + + #[PartialRender('table-body', 'table-partial')] + public function sortBy(string $field): void + { + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + } + + #[PartialRender('table-body', 'table-partial')] + public function updatedSearch(): void + { + // Trigger re-render when search is updated + } + + public function getFilteredItems(): array + { + $items = $this->items; + + // Apply search filter + if (! empty($this->search)) { + $items = array_filter($items, function ($item) { + return str_contains(strtolower($item['name']), strtolower($this->search)); + }); + } + + // Apply sorting + usort($items, function ($a, $b) { + $aVal = $a[$this->sortField]; + $bVal = $b[$this->sortField]; + + if ($this->sortDirection === 'asc') { + return $aVal <=> $bVal; + } + + return $bVal <=> $aVal; + }); + + return $items; + } + + public function render() + { + return <<<'BLADE' +
+ + + +
+ + +
+ + + + + + + + + + + + + @include('table-body', ['__partial' => $this]) + +
+ + + + + +
+
+BLADE; + } +} + +class TableWithLoadingBrowserTest extends Component +{ + public bool $isLoading = false; + + public array $items = []; + + public string $loadingUniqid; + + public function mount(): void + { + $this->loadingUniqid = uniqid('loading-'); + $this->items = [ + ['id' => 1, 'name' => 'Item A'], + ['id' => 2, 'name' => 'Item B'], + ['id' => 3, 'name' => 'Item C'], + ]; + } + + #[PartialRender('loading-table-body', 'loading-partial')] + public function loadMore(): void + { + $count = count($this->items); + $this->items[] = ['id' => $count + 1, 'name' => 'Item '.chr(65 + $count)]; + } + + public function render() + { + return <<<'BLADE' +
+ + +
+ @include('loading-table-body', ['__partial' => $this]) +
+ + +
+BLADE; + } +} + +beforeEach(function () { + Livewire::component('browser-table-component', TableLoadingBrowserTest::class); + Livewire::component('browser-loading-component', TableWithLoadingBrowserTest::class); + + Route::get('/browser-table-test', fn () => Blade::render(' + + + + @livewireStyles + + + + @livewireScripts + + + + '))->middleware('web'); + + Route::get('/browser-loading-test', fn () => Blade::render(' + + + + @livewireStyles + + + + @livewireScripts + + + + '))->middleware('web'); +}); + +it('does not re-render search input when sorting columns', function () { + $page = $this->visit('/browser-table-test'); + + // Get initial search uniqid + $initialSearchUniqid = $page->text('#search-uniqid'); + $initialMainUniqid = $page->text('#main-uniqid'); + + // Verify initial state + expect($initialSearchUniqid)->not->toBeEmpty() + ->and($initialMainUniqid)->not->toBeEmpty(); + + // Click to sort by name + $page->click('#sort-name') + ->waitForEvent('networkidle'); + + // Search input should NOT be re-rendered (uniqid should be the same) + expect($page->text('#search-uniqid'))->toBe($initialSearchUniqid) + ->and($page->text('#main-uniqid'))->toBe($initialMainUniqid); + + // Click to sort by price + $page->click('#sort-price') + ->waitForEvent('networkidle'); + + // Search input should still NOT be re-rendered + expect($page->text('#search-uniqid'))->toBe($initialSearchUniqid) + ->and($page->text('#main-uniqid'))->toBe($initialMainUniqid); +}); + +it('preserves search input value when sorting', function () { + $page = $this->visit('/browser-table-test'); + + // Type in search input + $page->type('#search-input', 'Product A') + ->waitForEvent('networkidle'); + + // Get search input value + $searchValue = $page->script('() => document.querySelector("#search-input").value'); + expect($searchValue)->toBe('Product A'); + + // Click to sort by name + $page->click('#sort-name') + ->waitForEvent('networkidle'); + + // Search input value should be preserved + $searchValue = $page->script('() => document.querySelector("#search-input").value'); + expect($searchValue)->toBe('Product A'); +}); + +it('updates table content when sorting by different columns', function () { + $page = $this->visit('/browser-table-test'); + + $initialSearchUniqid = $page->text('#search-uniqid'); + $initialMainUniqid = $page->text('#main-uniqid'); + + // Click to sort by price + $page->click('#sort-price') + ->waitForEvent('networkidle'); + + // Verify table body was updated + $tableRenderId1 = $page->text('tbody .table-render-id'); + expect($tableRenderId1)->not->toBeEmpty(); + + // But search and main should NOT be updated + expect($page->text('#search-uniqid'))->toBe($initialSearchUniqid) + ->and($page->text('#main-uniqid'))->toBe($initialMainUniqid); + + // Click again to reverse sort + $page->click('#sort-price') + ->waitForEvent('networkidle'); + + // Table body should have a NEW render id (was re-rendered) + $tableRenderId2 = $page->text('tbody .table-render-id'); + expect($tableRenderId2)->not->toBe($tableRenderId1); + + // But search and main should STILL be the same + expect($page->text('#search-uniqid'))->toBe($initialSearchUniqid) + ->and($page->text('#main-uniqid'))->toBe($initialMainUniqid); +}); + +it('filters and sorts correctly together', function () { + $page = $this->visit('/browser-table-test'); + + $initialSearchUniqid = $page->text('#search-uniqid'); + + // Type in search input + $page->type('#search-input', 'Product') + ->waitForEvent('networkidle'); + + // All products should be visible (all contain "Product") + // Count visible tr elements (excluding the hidden render-id row) + $rowCount = $page->script('() => document.querySelectorAll("tbody tr[data-id]").length'); + expect($rowCount)->toBe(4); + + // Search uniqid should still be the same + expect($page->text('#search-uniqid'))->toBe($initialSearchUniqid); + + // Now sort by stock + $page->click('#sort-stock') + ->waitForEvent('networkidle'); + + // Check first product after sort (should be lowest stock) + $firstProduct = $page->text('tbody tr[data-id]:first-of-type td:first-child'); + expect($firstProduct)->not->toBeEmpty(); + + // Search input should still preserve its value and uniqid + $searchValue = $page->script('() => document.querySelector("#search-input").value'); + expect($searchValue)->toBe('Product') + ->and($page->text('#search-uniqid'))->toBe($initialSearchUniqid); +}); + +it('updates only table body when sorting, not the header', function () { + $page = $this->visit('/browser-table-test'); + + // Add data attribute to header to track if it re-renders + $page->script('() => document.querySelector("#table-header").setAttribute("data-test-id", "original-header")'); + + // Click to sort + $page->click('#sort-name') + ->waitForEvent('networkidle'); + + // Header should still have the same data attribute + $hasOriginalAttribute = $page->script('() => document.querySelector("#table-header").getAttribute("data-test-id") === "original-header"'); + expect($hasOriginalAttribute)->toBeTrue(); +}); + +it('shows loading state during data load', function () { + $page = $this->visit('/browser-loading-test'); + + $initialLoadingUniqid = $page->text('#loading-uniqid'); + + // Verify initial items exist + $initialCount = $page->script('() => document.querySelectorAll("tbody tr[data-id]").length'); + expect($initialCount)->toBeGreaterThan(0); + + // Click load more + $page->click('#load-more') + ->waitForEvent('networkidle'); + + // Loading uniqid should be the same (main container not re-rendered) + expect($page->text('#loading-uniqid'))->toBe($initialLoadingUniqid); + + // Verify table body uniqid changed (partial was re-rendered) + $tableBodyUniqid = $page->text('#table-body-uniqid'); + expect($tableBodyUniqid)->not->toBeEmpty(); +}); + +it('table partial contains only table body in payload', function () { + $page = $this->visit('/browser-table-test'); + + $page->script(<<<'JS' + () => { + window.__livewirePartialPayload = null; + Livewire.interceptMessage(({ message, onSuccess }) => { + onSuccess(({ payload }) => { + window.__livewirePartialPayload = payload.effects; + }); + }); + } + JS); + + $page->click('#sort-name') + ->waitForEvent('networkidle'); + + $page->assertScript('() => window.__livewirePartialPayload !== null'); + + $hasPartialFragments = $page->script(<<<'JS' + () => { + const effects = window.__livewirePartialPayload; + if (!effects) return false; + return !!(effects.partialFragments && Object.keys(effects.partialFragments).length > 0); + } + JS); + + $hasTablePartial = $page->script(<<<'JS' + () => { + const effects = window.__livewirePartialPayload; + if (!effects?.partialFragments) return false; + return 'table-partial' in effects.partialFragments; + } + JS); + + expect($hasPartialFragments)->toBeTrue() + ->and($hasTablePartial)->toBeTrue(); +}); + +it('search input maintains focus state across partial updates', function () { + $page = $this->visit('/browser-table-test'); + + // Focus on search input + $page->click('#search-input'); + + // Verify focus + $hasFocus = $page->script('() => document.activeElement.id === "search-input"'); + expect($hasFocus)->toBeTrue(); + + // Type and trigger search + $page->type('#search-input', 'A') + ->waitForEvent('networkidle'); + + // Focus should be maintained after partial update + $hasFocus = $page->script('() => document.activeElement.id === "search-input"'); + expect($hasFocus)->toBeTrue(); +}); + +it('preserves data attributes on search input when sorting', function () { + $page = $this->visit('/browser-table-test'); + + // Get initial data-uniqid attribute + $initialDataUniqid = $page->script('() => document.querySelector("#search-input").getAttribute("data-uniqid")'); + expect($initialDataUniqid)->not->toBeEmpty(); + + // Sort by name + $page->click('#sort-name') + ->waitForEvent('networkidle'); + + // Data attribute should be preserved + $currentDataUniqid = $page->script('() => document.querySelector("#search-input").getAttribute("data-uniqid")'); + expect($currentDataUniqid)->toBe($initialDataUniqid); + + // Sort by price + $page->click('#sort-price') + ->waitForEvent('networkidle'); + + // Data attribute should still be preserved + $currentDataUniqid = $page->script('() => document.querySelector("#search-input").getAttribute("data-uniqid")'); + expect($currentDataUniqid)->toBe($initialDataUniqid); +}); diff --git a/tests/views/loading-table-body.blade.php b/tests/views/loading-table-body.blade.php new file mode 100644 index 0000000..1459e34 --- /dev/null +++ b/tests/views/loading-table-body.blade.php @@ -0,0 +1,17 @@ +
+ + + @if($__partial->isLoading) +
Loading...
+ @endif + + + + @foreach($__partial->items as $item) + + + + @endforeach + +
{{ $item['name'] }}
+
diff --git a/tests/views/table-body.blade.php b/tests/views/table-body.blade.php new file mode 100644 index 0000000..9f611c1 --- /dev/null +++ b/tests/views/table-body.blade.php @@ -0,0 +1,10 @@ +@foreach($__partial->getFilteredItems() as $item) + + {{ $item['name'] }} + ${{ number_format($item['price'], 2) }} + {{ $item['stock'] }} + +@endforeach + + {{ uniqid('tbody-') }} +