From ae82732ea4fde31bd22528124758c3c648e9112b Mon Sep 17 00:00:00 2001 From: Anthony Navarro Date: Fri, 24 Apr 2026 16:42:21 -0700 Subject: [PATCH] Fix pagination for browse page and adjust page layout --- app/Livewire/OccurrenceBrowser.php | 12 +- .../pagination/pagination-bar.blade.php | 2 + .../pagination/pagination-selector.blade.php | 9 +- .../livewire/occurrence-browser.blade.php | 124 +++++++++++------- .../Livewire/OccurrenceBrowserTest.php | 53 +++++++- 5 files changed, 142 insertions(+), 58 deletions(-) diff --git a/app/Livewire/OccurrenceBrowser.php b/app/Livewire/OccurrenceBrowser.php index 32751d1..0bf435b 100644 --- a/app/Livewire/OccurrenceBrowser.php +++ b/app/Livewire/OccurrenceBrowser.php @@ -23,6 +23,9 @@ class OccurrenceBrowser extends Component public int $total = 0; + /** True when the last page returned a full perPage set, indicating more records likely exist. */ + public bool $hasMore = false; + public ?string $loadError = null; // Filter values stored after the last apply-filters event @@ -102,6 +105,7 @@ public function loadOccurrences(): void || $this->filterLatMax !== null; if (! $hasFilter) { + $this->hasMore = false; $this->dispatch('browser-data-loaded', occurrences: []); return; @@ -128,7 +132,10 @@ public function loadOccurrences(): void ); $collection = $service->getOccurrences($query); - $this->total = $collection->total; + $this->total = count($collection->items); + // PBDB does not return a total count, so we infer whether more records + // exist by checking if the page was full. + $this->hasMore = count($collection->items) >= $this->perPage; // Transform to snake_case arrays for Tabulator $rows = array_map(static fn ($dto) => [ @@ -147,6 +154,7 @@ public function loadOccurrences(): void $this->dispatch('browser-data-loaded', occurrences: $rows); } catch (ApiException $e) { + $this->hasMore = false; $this->loadError = $e->getMessage(); } } @@ -223,7 +231,7 @@ public function render(): View { return view('livewire.occurrence-browser', [ 'from' => $this->total > 0 ? $this->offset + 1 : 0, - 'to' => min($this->offset + $this->perPage, $this->total), + 'to' => $this->offset + $this->total, 'perPage' => $this->perPage, 'exportUrl' => $this->exportUrl(), ]); diff --git a/resources/views/components/pagination/pagination-bar.blade.php b/resources/views/components/pagination/pagination-bar.blade.php index 70869ff..487e2f4 100644 --- a/resources/views/components/pagination/pagination-bar.blade.php +++ b/resources/views/components/pagination/pagination-bar.blade.php @@ -9,6 +9,7 @@ 'perPage' => 25, 'perPageOptions' => [25, 50, 100], 'perPageModel' => 'perPage', // Livewire property name to wire:model.live + 'showTotal' => true, ])
@@ -20,6 +21,7 @@ :can-next="$canNext" :prev-action="$prevAction" :next-action="$nextAction" + :show-total="$showTotal" /> false, 'prevAction' => 'prevPage', // Livewire method name 'nextAction' => 'nextPage', // Livewire method name + 'showTotal' => true, // false when the API cannot provide a grand total ]) {{-- @@ -23,8 +24,12 @@ class="rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-mediu - @if ($total > 0) - Showing {{ number_format($from) }}–{{ number_format($to) }} of {{ number_format($total) }} + @if ($from > 0) + @if ($showTotal && $total > 0) + Showing {{ number_format($from) }}–{{ number_format($to) }} of {{ number_format($total) }} + @else + Showing {{ number_format($from) }}–{{ number_format($to) }} + @endif @else No results @endif diff --git a/resources/views/livewire/occurrence-browser.blade.php b/resources/views/livewire/occurrence-browser.blade.php index 9c2816c..c5d0ad7 100644 --- a/resources/views/livewire/occurrence-browser.blade.php +++ b/resources/views/livewire/occurrence-browser.blade.php @@ -1,61 +1,83 @@ -
- - {{-- ─── Filter Panel (collapsible on mobile) ─────────────────────── --}} -
-
-
-

Browse Occurrences

- -
+
+ + {{-- ─── Table + Toolbar ───────────────────────────────────────────────── --}} + {{-- order-first puts this above the filter panel on mobile --}} +
-
- + {{-- Toolbar --}} +
+
+ + @if ($from > 0) +

The PBDB API does not provide a total count. Results load {{ $perPage }} at a time.

+ @endif
+ + + Export CSV +
-
- {{-- ─── Toolbar ────────────────────────────────────────────────────── --}} -
- - - - {{-- Export --}} - - Export CSV - + {{-- Error state --}} + @if ($loadError) +
+ {{ $loadError }} +
+ @endif + + {{-- Tabulator table — flex-1 fills remaining column height on desktop --}} +
- {{-- ─── Error state ────────────────────────────────────────────────── --}} - @if ($loadError) -
- {{ $loadError }} + {{-- ─── Filter Panel ───────────────────────────────────────────────────── --}} + {{-- order-last puts this below the table on mobile, left column on desktop --}} + -
+
\ No newline at end of file diff --git a/tests/Feature/Livewire/OccurrenceBrowserTest.php b/tests/Feature/Livewire/OccurrenceBrowserTest.php index 76ca632..6e3385b 100644 --- a/tests/Feature/Livewire/OccurrenceBrowserTest.php +++ b/tests/Feature/Livewire/OccurrenceBrowserTest.php @@ -86,14 +86,61 @@ public function test_load_occurrences_dispatches_browser_data_loaded_event(): vo ->assertDispatched('browser-data-loaded'); } - public function test_total_is_set_from_collection_after_load(): void + public function test_total_reflects_count_of_returned_items_not_pbdb_records_found(): void { - $this->mockService(new OccurrenceCollection(items: [], total: 9876, offset: 0)); + $dto = $this->makeDto(); + // Even if OccurrenceCollection carries a large total from records_found, + // $this->total is set to count($collection->items) because PBDB does not + // actually return records_found in its API responses. + $this->mockService(new OccurrenceCollection(items: [$dto], total: 9876, offset: 0)); + + Livewire::test(OccurrenceBrowser::class) + ->set('filterBaseName', 'Dinosauria') + ->call('loadOccurrences') + ->assertSet('total', 1); + } + + public function test_has_more_is_true_when_page_is_full(): void + { + // Build exactly perPage (25) DTOs so the page is considered full. + $items = array_fill(0, 25, $this->makeDto()); + $this->mockService(new OccurrenceCollection(items: $items, total: 25, offset: 0)); + + Livewire::test(OccurrenceBrowser::class) + ->set('filterBaseName', 'Dinosauria') + ->call('loadOccurrences') + ->assertSet('hasMore', true); + } + + public function test_has_more_is_false_when_page_is_partial(): void + { + // Fewer than perPage (25) items — we are on the last page. + $items = array_fill(0, 10, $this->makeDto()); + $this->mockService(new OccurrenceCollection(items: $items, total: 10, offset: 0)); + + Livewire::test(OccurrenceBrowser::class) + ->set('filterBaseName', 'Dinosauria') + ->call('loadOccurrences') + ->assertSet('hasMore', false); + } + + public function test_has_more_is_false_when_no_filter_applied(): void + { + Livewire::test(OccurrenceBrowser::class) + ->call('loadOccurrences') + ->assertSet('hasMore', false); + } + + public function test_has_more_is_false_after_api_exception(): void + { + $this->mock(FossilOccurrenceServiceInterface::class) + ->shouldReceive('getOccurrences') + ->andThrow(new ApiException('Failure')); Livewire::test(OccurrenceBrowser::class) ->set('filterBaseName', 'Dinosauria') ->call('loadOccurrences') - ->assertSet('total', 9876); + ->assertSet('hasMore', false); } /**