6.8 KiB
FuelFinder Sub-Component Split
Date: 2026-04-07
Status: Approved
Scope: Split the monolithic FuelFinder Livewire component into focused sub-components communicating via browser events.
Motivation
FuelFinder currently owns search state, API calls, map data, station results, and prediction — all in one component. Every search re-renders the entire page including the map container. Splitting into focused components allows the map, station list, and recommendation to update independently without re-mounting Leaflet or triggering unnecessary re-renders.
Architecture
FuelFinder (shell) — layout only, no state
├── <livewire:fuel.search /> — search form, filters, geolocation, API calls
├── <livewire:fuel.map /> — relays stations-found to Alpine/Leaflet
├── <livewire:fuel.station-list /> — renders station cards
├── <livewire:fuel.recommendation /> — renders prediction card
└── <x-fuel.forecast /> — Blade component, unchanged
Components
FuelFinder (shell)
Class: App\Livewire\Public\FuelFinder
View: resources/views/livewire/public/fuel-finder.blade.php
Retains #[Layout('layouts.shell')]. Holds no state and no methods. The view contains only the page chrome (header, main scroll area, bottom nav) and the four <livewire:fuel.*> tags.
fuel.search
Class: App\Livewire\Public\Fuel\Search
View: resources/views/livewire/public/fuel/search.blade.php
Owns all search state and logic migrated from FuelFinder:
Properties:
search: string— postcode/town inputfuelType: string— default'petrol'radius: int— default5sort: string— default'reliable'apiError: ?stringhasSearched: bool
Methods:
findStations()— validates, calls/api/stations, calls/api/prediction, dispatchesstations-foundupdatedFuelType(),updatedRadius(),updatedSort()— re-runfindStations()ifhasSearched
Dispatches:
$this->dispatch('stations-found',
results: $results,
meta: $meta,
prediction: $prediction,
radius: $this->radius, // needed by Map for Leaflet zoom level
);
The view contains the search input, geolocation Alpine component, filter selects, and error display — extracted from the current fuel-finder.blade.php.
fuel.map
Class: App\Livewire\Public\Fuel\Map
View: resources/views/livewire/public/fuel/map.blade.php
Thin relay. Listens for stations-found on the PHP side and re-dispatches as a browser event for Alpine/Leaflet to consume. No re-render required.
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
$this->dispatch('map-update', results: $results, meta: $meta, radius: $radius);
}
The view contains the <x-fuel.station-map> component wrapped in wire:ignore. The Alpine stationMap function in station-map.js is updated to listen for the map-update browser event instead of $watch-ing an entangled Livewire property.
station-map.js change:
init() {
// ...existing Leaflet init...
window.addEventListener('map-update', (e) => {
this.results = e.detail.results;
this.meta = e.detail.meta;
this.radius = e.detail.radius;
this._plotMarkers();
});
}
station-map.blade.php props change from @entangle(...) to static defaults — Map holds no state:
<div x-data="stationMap([], {}, 5)" class="h-56 w-full ..."></div>
fuel.station-list
Class: App\Livewire\Public\Fuel\StationList
View: resources/views/livewire/public/fuel/station-list.blade.php
Listens for stations-found, stores results, re-renders station cards.
Properties:
results: arraymeta: arrayhasSearched: bool
Methods:
#[On('stations-found')]
public function handle(array $results, array $meta): void
{
$this->results = $results;
$this->meta = $meta;
$this->hasSearched = true;
}
The view contains the "Stations Nearby" heading, result count, @forelse station card loop, and the empty-state message — extracted from fuel-finder.blade.php.
fuel.recommendation
Class: App\Livewire\Public\Fuel\Recommendation
View: resources/views/livewire/public/fuel/recommendation.blade.php
Listens for stations-found, stores prediction, re-renders.
Properties:
prediction: ?array
Methods:
#[On('stations-found')]
public function handle(array $results, array $meta, ?array $prediction = null): void
{
$this->prediction = $prediction;
}
The view renders <x-fuel.recommendation :prediction="$prediction" /> when prediction is set. Currently commented out in the parent view — this component makes it easy to uncomment when the scoring engine is wired.
Event Contract
Event name: stations-found
Payload:
[
'results' => array, // station records from /api/stations
'meta' => array, // lat, lng, count from API response
'prediction' => ?array, // from /api/prediction, nullable
'radius' => int, // user-selected radius in miles, for Leaflet zoom
]
All listener components must accept ?array $prediction = null and int $radius = 5 as defaults to avoid errors when optional fields are absent.
File Changes
New files:
app/Livewire/Public/Fuel/Search.phpapp/Livewire/Public/Fuel/Map.phpapp/Livewire/Public/Fuel/StationList.phpapp/Livewire/Public/Fuel/Recommendation.phpresources/views/livewire/public/fuel/search.blade.phpresources/views/livewire/public/fuel/map.blade.phpresources/views/livewire/public/fuel/station-list.blade.phpresources/views/livewire/public/fuel/recommendation.blade.php
Modified files:
app/Livewire/Public/FuelFinder.php— strip all state/methods, keep layout attributeresources/views/livewire/public/fuel-finder.blade.php— replace inline sections with<livewire:fuel.*>tagsresources/js/maps/station-map.js— replace$watchwithmap-updateevent listenerresources/views/components/fuel/station-map.blade.php— remove@entangleprops
Moved tests:
FuelFinderTestsearch/API logic tests →SearchTest
New tests:
SearchTest— validates, dispatchesstations-found, handles API errorsStationListTest— responds tostations-found, renders cards, handles empty stateRecommendationTest— responds tostations-found, renders when prediction set, hidden when null
What Does Not Change
x-fuel.forecast— remains a Blade componentstation-map.jsmap logic — only the data-binding mechanism changes- All
fuel/Blade components (station-card,recommendation,forecast, etc.) - The visual layout in
fuel-finder.blade.php - Route definition