Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions app/Livewire/OccurrenceBrowser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,6 +105,7 @@ public function loadOccurrences(): void
|| $this->filterLatMax !== null;

if (! $hasFilter) {
$this->hasMore = false;
$this->dispatch('browser-data-loaded', occurrences: []);

return;
Expand All @@ -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) => [
Expand All @@ -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();
}
}
Expand Down Expand Up @@ -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(),
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
'perPage' => 25,
'perPageOptions' => [25, 50, 100],
'perPageModel' => 'perPage', // Livewire property name to wire:model.live
'showTotal' => true,
])

<div class="flex items-center gap-4">
Expand All @@ -20,6 +21,7 @@
:can-next="$canNext"
:prev-action="$prevAction"
:next-action="$nextAction"
:show-total="$showTotal"
/>

<x-pagination.per-page-selector
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
'canNext' => false,
'prevAction' => 'prevPage', // Livewire method name
'nextAction' => 'nextPage', // Livewire method name
'showTotal' => true, // false when the API cannot provide a grand total
])

{{--
Expand All @@ -23,8 +24,12 @@ class="rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-mediu
</button>

<span class="text-xs text-muted">
@if ($total > 0)
Showing {{ number_format($from) }}&ndash;{{ number_format($to) }} of {{ number_format($total) }}
@if ($from > 0)
@if ($showTotal && $total > 0)
Showing {{ number_format($from) }}&ndash;{{ number_format($to) }} of {{ number_format($total) }}
@else
Showing {{ number_format($from) }}&ndash;{{ number_format($to) }}
@endif
@else
No results
@endif
Expand Down
124 changes: 73 additions & 51 deletions resources/views/livewire/occurrence-browser.blade.php
Original file line number Diff line number Diff line change
@@ -1,61 +1,83 @@
<div x-data="occurrenceBrowser" x-on:browser-data-loaded.window="setTableData($event.detail.occurrences)">

{{-- ─── Filter Panel (collapsible on mobile) ─────────────────────── --}}
<div x-data="{ filtersOpen: true }">
<div class="border-b border-border bg-surface">
<div class="flex items-center justify-between px-4 py-2">
<h1 class="text-sm font-semibold">Browse Occurrences</h1>
<button
x-on:click="filtersOpen = !filtersOpen"
x-bind:aria-expanded="filtersOpen"
aria-controls="browser-filters"
class="text-xs text-muted hover:text-text transition-colors lg:hidden"
>
<span x-text="filtersOpen ? 'Hide filters' : 'Show filters'"></span>
</button>
</div>
<div
x-data="occurrenceBrowser"
x-on:browser-data-loaded.window="setTableData($event.detail.occurrences)"
class="grid lg:grid-cols-[18rem_1fr] lg:h-[calc(100vh-4rem)]"
>

{{-- ─── Table + Toolbar ───────────────────────────────────────────────── --}}
{{-- order-first puts this above the filter panel on mobile --}}
<div class="order-first lg:order-last flex flex-col lg:min-h-0">

<div id="browser-filters" x-show="filtersOpen" x-cloak class="border-t border-border">
<livewire:occurrence-filters />
{{-- Toolbar --}}
<div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border bg-surface-raised shrink-0">
<div class="flex flex-col gap-1">
<x-pagination.pagination-bar
:from="$from"
:to="$to"
:total="$total"
:can-prev="$offset > 0"
:can-next="$hasMore"
:per-page="$perPage"
:show-total="false"
/>
@if ($from > 0)
<p class="text-xs text-muted/70">The PBDB API does not provide a total count. Results load {{ $perPage }} at a time.</p>
@endif
</div>

<a
href="{{ $exportUrl }}"
class="rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium hover:bg-surface-hover transition-colors shrink-0"
aria-label="Export current results as CSV"
>
Export CSV
</a>
</div>
</div>

{{-- ─── Toolbar ────────────────────────────────────────────────────── --}}
<div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border bg-surface-raised">

<x-pagination.pagination-bar
:from="$from"
:to="$to"
:total="$total"
:can-prev="$offset > 0"
:can-next="$to < $total"
:per-page="$perPage"
/>

{{-- Export --}}
<a
href="{{ $exportUrl }}"
class="rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium hover:bg-surface-hover transition-colors"
aria-label="Export current results as CSV"
>
Export CSV
</a>
{{-- Error state --}}
@if ($loadError)
<div class="mx-4 mt-4 rounded-md border border-danger/30 bg-danger/10 p-3 text-sm text-danger shrink-0">
{{ $loadError }}
</div>
@endif

{{-- Tabulator table — flex-1 fills remaining column height on desktop --}}
<div
id="eonmap-browser-table"
class="w-full lg:flex-1 lg:overflow-auto"
wire:ignore
></div>

</div>

{{-- ─── Error state ────────────────────────────────────────────────── --}}
@if ($loadError)
<div class="mx-4 mt-4 rounded-md border border-danger/30 bg-danger/10 p-3 text-sm text-danger">
{{ $loadError }}
{{-- ─── Filter Panel ───────────────────────────────────────────────────── --}}
{{-- order-last puts this below the table on mobile, left column on desktop --}}
<aside
x-data="{ filtersOpen: true }"
class="order-last lg:order-first border-t lg:border-t-0 lg:border-r border-border bg-surface flex flex-col lg:overflow-hidden"
aria-label="Filters"
>
{{-- Panel header --}}
<div class="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
<h1 class="text-sm font-semibold">Browse Occurrences</h1>
<button
x-on:click="filtersOpen = !filtersOpen"
x-bind:aria-expanded="filtersOpen"
aria-controls="browser-filters"
class="text-xs text-muted hover:text-text transition-colors lg:hidden"
>
<span x-text="filtersOpen ? 'Hide filters' : 'Show filters'"></span>
</button>
</div>
@endif

{{-- ─── Tabulator table ────────────────────────────────────────────── --}}
<div
id="eonmap-browser-table"
class="w-full"
wire:ignore
></div>
<div
id="browser-filters"
x-show="filtersOpen"
x-cloak
class="flex flex-col flex-1 min-h-0 overflow-hidden"
>
<livewire:occurrence-filters />
</div>
</aside>

</div>
</div>
53 changes: 50 additions & 3 deletions tests/Feature/Livewire/OccurrenceBrowserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Loading