Add nightly town sync from ArcGIS API with autocomplete
All checks were successful
linter / quality (pull_request) Successful in 1m21s
security / Dependency Audit (pull_request) Successful in 1m25s
security / Static Analysis (pull_request) Successful in 1m49s
tests / ci (8.4) (pull_request) Successful in 1m23s
tests / ci (8.5) (pull_request) Successful in 1m27s
All checks were successful
linter / quality (pull_request) Successful in 1m21s
security / Dependency Audit (pull_request) Successful in 1m25s
security / Static Analysis (pull_request) Successful in 1m49s
tests / ci (8.4) (pull_request) Successful in 1m23s
tests / ci (8.5) (pull_request) Successful in 1m27s
- Add towns table with town_pid, town_name, state, population, town_class, date_retired - Add AustralianState enum with label and abbreviation helpers - Add Town model with active() and search() scopes - Add SyncTowns job that paginates ArcGIS API and upserts all 1977 towns - Schedule SyncTowns to run nightly at 02:00 - Add /towns/search endpoint returning JSON suggestions filtered by name and state - Add Alpine-powered autocomplete on origin/destination fields in create form - Add state filter dropdown in journeys card header to narrow autocomplete results
This commit is contained in:
@@ -309,10 +309,42 @@ new #[Layout('components.layouts.app')] class extends Component {
|
||||
</div>
|
||||
|
||||
{{-- Journeys --}}
|
||||
<div class="card mb-4">
|
||||
<div class="card mb-4" x-data="{
|
||||
stateFilter: '',
|
||||
suggestions: {},
|
||||
open: {},
|
||||
async searchTowns(key, query) {
|
||||
if (query.length < 2) {
|
||||
this.suggestions[key] = [];
|
||||
this.open[key] = false;
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ query });
|
||||
if (this.stateFilter) params.append('state', this.stateFilter);
|
||||
const res = await fetch(`/towns/search?${params}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
this.suggestions[key] = data;
|
||||
this.open[key] = data.length > 0;
|
||||
},
|
||||
selectTown(key, wirePath, name) {
|
||||
$wire.set(wirePath, name);
|
||||
this.suggestions[key] = [];
|
||||
this.open[key] = false;
|
||||
}
|
||||
}">
|
||||
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
|
||||
Journeys
|
||||
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
|
||||
<span>Journeys</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select x-model="stateFilter" class="form-select form-select-sm" style="width: auto;" title="Filter town autocomplete by state">
|
||||
<option value="">All states</option>
|
||||
@foreach(\App\Enums\AustralianState::cases() as $state)
|
||||
<option value="{{ $state->value }}">{{ $state->abbreviation() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror
|
||||
@@ -328,13 +360,53 @@ new #[Layout('components.layouts.app')] class extends Component {
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Origin <span class="text-danger">*</span></label>
|
||||
<input type="text" wire:model="journeys.{{ $i }}.origin" class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror" placeholder="e.g. Perth">
|
||||
@error('journeys.'.$i.'.origin') <div class="invalid-feedback">{{ $message }}</div> @enderror
|
||||
<div class="position-relative">
|
||||
<input
|
||||
type="text"
|
||||
wire:model="journeys.{{ $i }}.origin"
|
||||
class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror"
|
||||
placeholder="e.g. Perth"
|
||||
autocomplete="off"
|
||||
x-on:input.debounce.300ms="searchTowns('{{ $i }}-origin', $event.target.value)"
|
||||
x-on:blur="$nextTick(() => open['{{ $i }}-origin'] = false)"
|
||||
>
|
||||
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-origin'] ?? false" style="z-index: 1055;">
|
||||
<template x-for="s in (suggestions['{{ $i }}-origin'] ?? [])">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item small"
|
||||
x-text="s.label"
|
||||
x-on:mousedown.prevent="selectTown('{{ $i }}-origin', 'journeys.{{ $i }}.origin', s.name)"
|
||||
></button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
@error('journeys.'.$i.'.origin') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Destination <span class="text-danger">*</span></label>
|
||||
<input type="text" wire:model="journeys.{{ $i }}.destination" class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror" placeholder="e.g. Sydney">
|
||||
@error('journeys.'.$i.'.destination') <div class="invalid-feedback">{{ $message }}</div> @enderror
|
||||
<div class="position-relative">
|
||||
<input
|
||||
type="text"
|
||||
wire:model="journeys.{{ $i }}.destination"
|
||||
class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror"
|
||||
placeholder="e.g. Sydney"
|
||||
autocomplete="off"
|
||||
x-on:input.debounce.300ms="searchTowns('{{ $i }}-destination', $event.target.value)"
|
||||
x-on:blur="$nextTick(() => open['{{ $i }}-destination'] = false)"
|
||||
>
|
||||
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-destination'] ?? false" style="z-index: 1055;">
|
||||
<template x-for="s in (suggestions['{{ $i }}-destination'] ?? [])">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item small"
|
||||
x-text="s.label"
|
||||
x-on:mousedown.prevent="selectTown('{{ $i }}-destination', 'journeys.{{ $i }}.destination', s.name)"
|
||||
></button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
@error('journeys.'.$i.'.destination') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Date <span class="text-danger">*</span></label>
|
||||
|
||||
Reference in New Issue
Block a user