Integrating a Livewire component to display the monuments using a spatial query in PostGIS, paginating results, and implementing instant search

No screencast available yet for this post.

In the last few lessons, we've made significant progress on the front-end side; we have a very performant map using WMS and some excellent end-user functionalities. In this lesson, we will create a form to let the users view and search monuments using Laravel Livewire and Alpine.js. We will also see more PostGIS by deducing each monument country using a spatial query.

  • First, let's use our existing legend panel and create so "quick and dirty" tabs with Alpine.js to host the monuments list; in resources/js/components/map.js file, we add an activeTab property and set it by default to 'legend':
(...)
document.addEventListener("alpine:init", () => {
    Alpine.data("map", function () {
        return {
            legendOpened: false,
            map: {},
            activeTab: 'legend',
            initComponent() {
                let monumentsLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:monuments',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'Monuments',
                });
(...)
  • In the resources/views/components/map.blade.php, let's make the following adjustments:
<div x-data="map()" x-init="initComponent()">
    <div x-ref="map" class="relative h-[600px] overflow-clip rounded-md border border-slate-300 shadow-lg">
        <div class="absolute top-2 right-8 z-10 rounded-md bg-white bg-opacity-75">
            <div class="ol-unselectable ol-control">
<!-- We call it details as it will not only be the legend anymore -->
                <button x-on:click.prevent="legendOpened = ! legendOpened" title="Open/Close legend"
                    class="absolute inset-0 flex items-center justify-center">
                <button x-on:click.prevent="legendOpened = ! legendOpened" title="Open/Close details"
                    class="absolute inset-0 flex items-center justify-center">
                    <!-- Heroicon name: outline/globe -->
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 pl-0.5" fill="none" viewBox="0 0 24 24"
                        stroke="currentColor" stroke-width="1">
                        <path stroke-linecap="round" stroke-linejoin="round"
                            d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                    </svg>
                </button>
            </div>
        </div>

        <div x-cloak x-show="legendOpened" x-transition:enter="transition-opacity duration-300"
            x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
            x-transition:leave="transition-opacity duration-300" x-transition:leave-start="opacity-100"
            x-transition:leave-end="opacity-0"
            class="absolute right-0 top-16 left-2 bottom-2 z-10 max-w-sm rounded-md border border-slate-300 bg-white bg-opacity-50 shadow-sm">
            <div class="absolute inset-1 overflow-y-auto rounded-md bg-white bg-opacity-75 p-2">
                <div class="flex items-start justify-between">
                    <h3 class="text-lg font-medium text-slate-700">Legend</h3>
            <div class="absolute inset-1 overflow-y-auto rounded-md bg-white bg-opacity-75 p-2 pt-1">
                <div class="flex items-center justify-between pr-1">
<!-- We will have 2 tabs, so let's put 2 titles that will also change the activeTab on click -->
                    <div class="flex justify-start space-x-4">
                        <h3 x-on:click.prevend="activeTab = 'legend'" class="cursor-pointer text-slate-700"
                            x-bind:class="activeTab === 'legend' && 'font-bold'">Legend</h3>
                        <h3 x-on:click.prevend="activeTab = 'monuments'" class="cursor-pointer text-slate-700"
                            x-bind:class="activeTab === 'monuments' && 'font-bold'">Monuments</h3>
                    </div>
                    <button x-on:click.prevent="legendOpened = false"
                        class="text-2xl font-black text-slate-400 transition hover:text-[#3369A1] focus:text-[#3369A1] focus:outline-none">&times;</button>
                        class="mb-1 text-2xl font-black text-slate-400 transition hover:text-[#3369A1] focus:text-[#3369A1] focus:outline-none">&times;</button>
                </div>
                <ul class="mt-2 space-y-1 rounded-md border border-slate-300 bg-white p-1">
<!-- Keep the same legend as we had when activeTab === 'legend' (with some nice Alpine.js transitions) -->
                <ul x-show="activeTab === 'legend'" x-transition:enter="transition-opacity duration-150"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                    x-transition:leave="transition-opacity duration-150" x-transition:leave-start="opacity-100"
                    x-transition:leave-end="opacity-0"
                    class="mt-2 p-1 space-y-1 rounded-md border border-slate-300 bg-white">
                    <template x-for="(layer, index) in map.getAllLayers().reverse()" :key="index">
                        <li class="flex items-center p-0.5">
                            <div x-id="['legend-range']" class="w-full rounded-md border border-gray-300 px-2 py-1">
                                <div class="space-y-1">
                                    <label x-bind:for="$id('legend-range')" class="flex items-center">
                                        <span class="text-sm text-slate-600" x-text="layer.get('label')"></span>
                                    </label>
                                    <div x-show="hasLegend(layer)">
                                        <img x-bind:src="legendUrl(layer)" alt="Legend">
                                    </div>
                                </div>
                                <div class="mt-2 text-sm text-slate-600">
                                    <input class="w-full accent-[#3369A1]" type="range" min="0" max="1"
                                        step="0.01" x-bind:id="$id('legend-range')" x-bind:value="layer.getOpacity()"
                                        x-on:change="layer.setOpacity(Number($event.target.value))">
                                </div>
                            </div>
                        </li>
                    </template>
                </ul>
<!-- If activeTab === 'monuments', we show our placeholder for the monuments list and form -->
                <div x-show="activeTab === 'monuments'" x-transition:enter="transition-opacity duration-150"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                    x-transition:leave="transition-opacity duration-150" x-transition:leave-start="opacity-100"
                    x-transition:leave-end="opacity-0" class="mt-2 p-1 rounded-md border border-slate-300 bg-white">
                    Monuments
                </div>
            </div>
        </div>
        <div x-cloak x-ref="popup" class="ol-popup ol-control transition">
            <div class="m-0.5 rounded-md bg-white p-2">
                <div class="flex justify-between">
                    <h3 class="text-xs font-medium text-slate-400">Monument</h3>
                    <a href="#" title="Close" x-on:click.prevent="closePopup"
                        class="-mt-1 font-black text-slate-400 transition hover:text-slate-600 focus:text-slate-600 focus:outline-none">&times;</a>
                </div>
                <div x-ref="popupContent" class="mt-2 min-h-[200px] overflow-y-auto"></div>
            </div>
        </div>
    </div>
</div>

@once
    @push('styles')
        @vite(['resources/css/components/map.css'])
    @endpush
    @push('scripts')
        @vite(['resources/js/components/map.js'])
    @endpush
@endonce

  • You have to love Alpine.js, and it's simplicity; in just a few lines of code, we integrated tabs in the details panel of the map. Now let's fill it with a Laravel Livewire component. Create it by launching the following command:
dr php artisan livewire:make monuments.index
sudo chmod a+w app/Http/Livewire/Monuments/Index.php
sudo chmod a+w resources/views/livewire/monuments/index.blade.php
  • In the resources/views/components/map.blade.php, let's make the following adjustment:
(...)
                        </li>
                    </template>
                </ul>
                <div x-show="activeTab === 'monuments'" x-transition:enter="transition-opacity duration-150"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                    x-transition:leave="transition-opacity duration-150" x-transition:leave-start="opacity-100"
                    x-transition:leave-end="opacity-0" class="mt-2 p-1 rounded-md border border-slate-300 bg-white">
                    Monuments
					<livewire:monuments.index />
                </div>
            </div>
        </div>
(...)
  • As a sanity check, make the following adjustment to the resources/views/livewire/monuments/index.blade.php file:
<div>
    {{-- If your happiness depends on money, you will never be happy with yourself. --}}
    Livewire component
</div>
  • You should now see the Livewire component loaded in the Monuments tab in the details panel:

Interacting with PostGIS from Livewire, Alpine.js and OpenLayers

  • Let's go to the back-end file, query the monuments with Eloquent and, display them in the app/Http/Livewire/Monuments/Index.php, make the following changes:
<?php

namespace App\Http\Livewire\Monuments;

use App\Models\Monument;
use Livewire\Component;

class Index extends Component
{
// In Livewire components, public properties are automatically available to the view
    public $monuments;

    public function mount()
    {
// Order the monuments by name and only select the name; with PostGIS data, we have to
// be careful not to select the geometry field when not needed because it can be huge.
        $this->monuments = Monument::orderBy('name')->get('name');
    }

    public function render()
    {
        return view('livewire.monuments.index');
    }
}
  • All we are left to do is to display a list of monuments in the component's view (resources/views/livewire/monuments/index.blade.php):
<div>
    Livewire component
<div class="px-1 py-2">	
    <ul class="space-y-2">
        @forelse($monuments as $monument)
            <li>{{ $monument->name }}</li>
        @empty
            <li>No monuments found.</li>
        @endforelse
    </ul>
</div>

Interacting with PostGIS from Livewire, Alpine.js and OpenLayers

  • It would be nice to have the country name in parenthesis after the monument name, but we are missing this field in the monuments table. We have the world-administrative-boundaries table and a powerful geographical query system with PostGIS. Let's adjust the Eloquent query like this:
<?php

namespace App\Http\Livewire\Monuments;

use App\Models\Monument;
use Livewire\Component;
use Illuminate\Support\Facades\DB;

class Index extends Component
{
    public $monuments;

    public function mount()
    {
        $this->monuments = Monument::orderBy('name')->get('name');
// We use a left join to make sure we have all monuments, no matter if they are or not within a country
// We might have surprises! PostGIS exposes the function st_within, which takes two geometry arguments
// and checks if the first one is entirely within the second one; we use it as a condition of the left join
// We have to use raw expressions because Eloquent doesn't know about PostGIS
        $this->monuments = Monument::query()
            ->leftJoin('world-administrative-boundaries', DB::raw('st_within(monuments.geom, "world-administrative-boundaries".geom)'), '=', DB::raw('true'))
            ->select('monuments.name as name', 'world-administrative-boundaries.name as country')
            ->orderBy('monuments.name')
            ->get();
    }

    public function render()
    {
        return view('livewire.monuments.index');
    }
}
  • We should now have the country field coming from the world-administrative-boundaries table; let's display it in the view:
<div class="px-1 py-2">
    <ul class="space-y-2">
        @forelse($monuments as $monument)
           <li>{{ $monument->name }}</li>
           <li>{{ $monument->name }} ({{ $monument->country ?? 'Unknown' }})</li>
        @empty
            <li>No monuments found.</li>
        @endforelse
    </ul>
</div>

Interacting with PostGIS from Livewire, Alpine.js and OpenLayers

  • As I told you, we might have surprises; PostGIS tells us that the Statue of Liberty is not within any country! As a matter of fact, it is correct. If we zoom to it, it is not in the United Stated polygon!

Interacting with PostGIS from Livewire, Alpine.js and OpenLayers

  • The world-administrative-boundaries we use come from opendatasoft are not perfectly precise at the scale we want to use it. We will leave it like this for now, but we will use this case as an example in a future lesson on calculating distances. The rest of the monuments all fall within the correct country, so we've proven that our query is working perfectly.
  • Let's continue to work on the monuments list, a few lessons ago, while working with vector features, we had a list of monuments in the legend, but we were able to click them to go to each monument; that was a nice end-user experience, let's try to reproduce it here. First, we will adjust the resources/js/components/map.js file:
import Map from "ol/Map.js";
import View from "ol/View.js";
import {
    Tile as TileLayer
} from 'ol/layer.js';
import OSM from "ol/source/OSM.js";
import Overlay from "ol/Overlay.js";
import TileWMS from 'ol/source/TileWMS.js';
// We remove these two imports as we won't construct a feature anymore; we will use the geojson directly
// Reducing the number of imports will also reduce the final javascript bundle size
import Feature from "ol/Feature";
import Point from 'ol/geom/Point.js';

document.addEventListener("alpine:init", () => {
    Alpine.data("map", function () {
        return {
            legendOpened: false,
            map: {},
            activeTab: 'legend',
            initComponent() {
                let monumentsLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:monuments',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'Monuments',
                });

                let worldAdministrativeBoundariesLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-administrative-boundaries',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Administrative Boundaries',
                });

                let worldRiversLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-rivers',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Rivers',
                });

                this.map = new Map({
                    target: this.$refs.map,
                    layers: [
                        new TileLayer({
                            source: new OSM(),
                            label: 'OpenStreetMap',
                        }),
                        worldAdministrativeBoundariesLayer,
                        worldRiversLayer,
                        monumentsLayer
                    ],
                    view: new View({
                        projection: "EPSG:4326",
                        center: [-78.2161, -0.7022],
                        zoom: 8,
                    }),
                    overlays: [
                        new Overlay({
                            id: 'info',
                            element: this.$refs.popup,
                            stopEvent: true,
                        }),
                    ],
                });

                this.map.on("singleclick", (event) => {
                    if (event.dragging) {
                        return;
                    }

                    let overlay = this.map.getOverlayById('info')
                    overlay.setPosition(undefined)
                    this.$refs.popupContent.innerHTML = ''

                    const viewResolution = /** @type {number} */ (event.map.getView().getResolution())

                    const url = monumentsLayer.getSource().getFeatureInfoUrl(
                        event.coordinate,
                        viewResolution,
                        'EPSG:4326', {
                            'INFO_FORMAT': 'application/json'
                        })

                    if (url) {
                        fetch(url)
                            .then((response) => response.json())
                            .then((json) => {
                                if (json.features.length > 0) {
// We move most of this code to a new gotoMonument function that will accept geojson instead
// of a feature object; this way, we will be able to call this function from the map.bladed.php file easily
                                    let jsonFeature = json.features[0]

                                    let feature = new Feature({
                                        geometry: new Point(jsonFeature.geometry.coordinates),
                                        name: jsonFeature.properties.name,
                                        image: jsonFeature.properties.image
                                    })

                                    this.gotoFeature(feature)
                                    this.gotoMonument(json.features[0])

                                    let content =
                                        '<h4 class="text-gray-500 font-bold">' +
                                        feature.get('name') +
                                        '</h4>'

                                    content +=
                                        '<img src="' +
                                        feature.get('image') +
                                        '" class="mt-2 w-full max-h-[200px] rounded-md shadow-md object-contain overflow-clip">'

                                    this.$refs.popupContent.innerHTML = content

                                    setTimeout(() => {
                                        overlay.setPosition(
                                            feature.getGeometry().getCoordinates()
                                        );
                                    }, 500)

                                    return
                                }
                            });
                    }

                });
            },
            closePopup() {
                let overlay = this.map.getOverlayById('info')
                overlay.setPosition(undefined)
                this.$refs.popupContent.innerHTML = ''
            },
// Function replaced by the gotoMonument function
            gotoFeature(feature) {
                this.map.getView().animate({
                    center: feature.getGeometry().getCoordinates(),
                    zoom: 15,
                    duration: 500,
                });
            },
// Here we accept the geojson representation of the monument but the function's logic stays the same
            gotoMonument(jsonFeature) {
                this.map.getView().animate({
                    center: jsonFeature.geometry.coordinates,
                    zoom: 15,
                    duration: 500,
                });

                let content =
                    '<h4 class="text-gray-500 font-bold">' +
                    jsonFeature.properties.name +
                    '</h4>'

                content +=
                    '<img src="' +
                    jsonFeature.properties.image +
                    '" class="mt-2 w-full max-h-[200px] rounded-md shadow-md object-contain overflow-clip">'

                this.$refs.popupContent.innerHTML = content

                setTimeout(() => {
                    this.map.getOverlayById('info').setPosition(
                        jsonFeature.geometry.coordinates
                    );
                }, 500)

            },
            hasLegend(layer) {
                return layer.getSource() instanceof TileWMS
            },
            legendUrl(layer) {
                if (this.hasLegend(layer)) {
                    return layer
                        .getSource()
                        .getLegendUrl(this.map.getView().getResolution(), {
                            LEGEND_OPTIONS: 'forceLabels:on'
                        })
                }
            }
        };
    });
});
  • We now have a gotoMonument function accepting a geojson representation of the monument; how can we get it? Easy, PostGIS too supports geojson! Let's tweak the Eloquent query a little bit and get it:
<?php

namespace App\Http\Livewire\Monuments;

use Livewire\Component;
use App\Models\Monument;
use Illuminate\Support\Facades\DB;

class Index extends Component
{
    public $monuments;

    public function mount()
    {
        $this->monuments = Monument::query()
            ->leftJoin('world-administrative-boundaries', DB::raw('st_within(monuments.geom, "world-administrative-boundaries".geom)'), '=', DB::raw('true'))
            ->select('monuments.name as name', 'world-administrative-boundaries.name as country')
// We use selectRaw and the st_asgeojson PostGIS function to directly get a representation of the monument with all attributes
            ->selectRaw('monuments.name as name, "world-administrative-boundaries".name as country, st_asgeojson(monuments.*) as geojson')
            ->orderBy('monuments.name')
            ->get();
    }

    public function render()
    {
        return view('livewire.monuments.index');
    }
}
  • This query will return something like this with a full geojson representation of each monument (precisely what we need to pass to the gotoMonument javascript function):

Interacting with PostGIS from Livewire, Alpine.js and OpenLayers

  • The last piece of this functionality is to use this geojson to call the gotoMonument function from the resources/views/livewire/monuments/index.blade.php file:
<div class="px-1 py-2">
    <ul class="space-y-2">
        @forelse($monuments as $monument)
            <li>{{ $monument->name }} ({{ $monument->country ?? 'Unknown' }})</li>
<!-- We put a button (with a svg from https://heroicons.dev/ as an image) at the end of each li element to call the gotoMonument function -->
            <li class="flex items-center justify-between">
                <span>{{ $monument->name }} ({{ $monument->country ?? 'Unknown' }})</span>
<!-- The only thing we need is to call the function from Alpine.js with the $monument->geojson as a argument -->
                <button x-on:click.prevent="gotoMonument({{ $monument->geojson }})" title="Go to {{ $monument->name }}"
                    class="text-slate-500 transition hover:text-slate-800 focus:text-slate-800 focus:outline-none">
                    <svg class="h5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
                        <path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"></path>
                        <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"></path>
                      </svg>
                </button>
            </li>
        @empty
            <li>No monuments found.</li>
        @endforelse
    </ul>
</div>

  • Great, this excellent functionality is working again! But if you remember correctly, we removed it for a reason a few lessons ago: what if we have thousands of monuments, the amount of data we would load would be enormous. Also, the user would have to scroll a lot to find a monument! When we were working with WFS, implementing pagination would have been quite complicated, but now, we are working from a Laravel Livewire component. As you probably know, pagination with Eloquent is relatively easy, and Livewire supports it without needing a full page reload; it's precisely what we need and can be implemented with just a few lines of code:
<?php

namespace App\Http\Livewire\Monuments;

use Livewire\Component;
use App\Models\Monument;
use Illuminate\Support\Facades\DB;
use Livewire\WithPagination;

class Index extends Component
{
// Simply by using the Livewire\WithPagination trait, the pagination will be done in the background,
// without a full page reload
    use WithPagination;
    public $monuments;

    public function mount()
    {
        $this->monuments = Monument::query()
            ->leftJoin('world-administrative-boundaries', DB::raw('st_within(monuments.geom, "world-administrative-boundaries".geom)'), '=', DB::raw('true'))
            ->selectRaw('monuments.name as name, "world-administrative-boundaries".name as country, st_asgeojson(monuments.*) as geojson')
            ->orderBy('monuments.name')
            ->get();
    }

    public function render()
    {
// We move the query to the render function as Livewire doesn't support pagination objects as public properties
// This will not change the behaviour of the view at all
        return view('livewire.monuments.index');
        return view('livewire.monuments.index', [
            'monuments' => Monument::query()
                ->leftJoin('world-administrative-boundaries', DB::raw('st_within(monuments.geom, "world-administrative-boundaries".geom)'), '=', DB::raw('true'))
                ->selectRaw('monuments.name as name, "world-administrative-boundaries".name as country, st_asgeojson(monuments.*) as geojson')
                ->orderBy('monuments.name')
// We use simple paginate with only 2 elements per page so we can test with only 5 monuments, 
// we will switch this to 10 after our test
                ->simplePaginate(2)
        ]);
    }
}
  • The last thing we need is to add the paginator object in the component blade file:
<div class="px-1 py-2">
    <ul class="space-y-2">
        @forelse($monuments as $monument)
            <li class="flex items-center justify-between">
                <span>{{ $monument->name }} ({{ $monument->country ?? 'Unknown' }})</span>
                <button x-on:click.prevent="gotoMonument({{ $monument->geojson }})" title="Go to {{ $monument->name }}"
                    class="text-slate-500 transition hover:text-slate-800 focus:text-slate-800 focus:outline-none">
                    <svg class="h5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
                        <path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"></path>
                        <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"></path>
                      </svg>
                </button>
            </li>
        @empty
            <li>No monuments found.</li>
        @endforelse
    </ul>
    @if ($monuments->hasPages())
        <div class="mt-4">{{ $monuments->links() }}</div>
    @endif
</div>

  • We are almost finished with this lesson; now that we have pagination and are sure not to load an enormous amount of data on the page, the last thing we might want is to allow the end-user to search for monuments with Livewire again; it's straightforward. Let's first add a search input in our component blade view:
<div class="px-1 py-2">
<!-- We use wire:model to map the content of the search box to a Livewire server-side $search property -->
    <input type="search" wire:model.debounce.300ms="search" class="w-full rounded-md border-gray-300 px-2 py-1.5"
        placeholder="Search">
    <ul class="space-y-2">
    <ul class="mt-4 space-y-2">
        @forelse($monuments as $monument)
            <li class="flex items-center justify-between">
                <span>{{ $monument->name }} ({{ $monument->country ?? 'Unknown' }})</span>
                <span class="ml-1 text-gray-600">{{ $monument->name }} ({{ $monument->country ?? 'Unknown' }})</span>
                <button x-on:click.prevent="gotoMonument({{ $monument->geojson }})" title="Go to {{ $monument->name }}"
                    class="text-slate-500 transition hover:text-slate-800 focus:text-slate-800 focus:outline-none">
                    <svg class="h5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"
                        xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
                        <path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z">
                        </path>
                        <path stroke-linecap="round" stroke-linejoin="round"
                            d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"></path>
                    </svg>
                </button>
            </li>
        @empty
            <li>No monuments found.</li>
        @endforelse
    </ul>
    @if ($monuments->hasPages())
        <div class="mt-4">{{ $monuments->links() }}</div>
    @endif
</div>
  • Finally, we make a few tweaks to the app/Http/Livewire/Monuments/Index.php to have our instant search working:
<?php

namespace App\Http\Livewire\Monuments;

use Livewire\Component;
use App\Models\Monument;
use Illuminate\Support\Facades\DB;
use Livewire\WithPagination;

class Index extends Component
{
    use WithPagination;

    public $search;

    public function updatedSearch()
    {
        $this->resetPage();
    }

    public function render()
    {
        return view('livewire.monuments.index', [
            'monuments' => Monument::query()
                ->leftJoin('world-administrative-boundaries', DB::raw('st_within(monuments.geom, "world-administrative-boundaries".geom)'), '=', DB::raw('true'))
                ->selectRaw('monuments.name as name, "world-administrative-boundaries".name as country, st_asgeojson(monuments.*) as geojson')
// When the $search property is not empty or null, we add a where clause using ilike 
// (similar to like but case insensitive). We also use the country name in the search
// This query could be optimized with indices, or even better, full text search, we might cover it in a future lesson
                ->when($this->search, function ($query, $search) {
                    $search = '%' . $search . '%';
                    $query->where('monuments.name', 'ilike', $search)
                        ->orWhere('world-administrative-boundaries.name', 'ilike', $search);
                })
                ->orderBy('monuments.name')
                ->simplePaginate(2)
                ->simplePaginate(10)
        ]);
    }
}

Well, that was a big lesson; we've learned how to make tabs with Alpine.js, make a PostGIS spatial query from Eloquent, paginate the results without refreshing the page, and create an instant search function with Livewire. Our application feels like a SPA, but it's not! Stay along; in the next post and following few lessons, we will learn how to save new monuments to PostGIS from Laravel and Livewire.

The commit for this post is available here: interacting-with-postgis-from-livewire-alpinejs-and-openlayers

First published 1 year ago
Latest update 1 year ago
wim debbaut
Posted by wim debbaut 1 year ago

Great tutorial!! I just finished it without no any hickups. I am impressed about the loading performance due to the tile layers. Also by the flexibility of the LiveWire, Alpine and Laravel frameworks. No clue however how in OL you are able to save new monuments in the PostGis datasource from within Laraval. Really looking forward to it. Any ideas when chapter 17 and others comes available? Have a nice day.


webgisdev
Posted by webgisdev 1 year ago

Hello wim,

Again, thank you for your kind comments! I might publish a new lesson next weekend (I'm only working on this project in my free time and lesson 17 is not ready/finished yet). You can follow @gislaravel on Twitter (by using the button at the top of the page) to be notified when new content becomes available.

Cheers!

ckayhernandez
Posted by ckayhernandez 4 months ago

I am having this error

Cannot declare class App\Http\Livewire\Monuments\Index, because the name is already in use


No response yet
You need to be signed in to post comments, you can sign in here if you already have an account or register here if you don't.