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);
}
/**