T</>TahuCoding

Part 3 - Tutorial GIS Interaktif Menggunakan Laravel Inertia & React - CRUD - Create, Update & Delete

Loading...

Pada tutorial kali ini kita akan menambahkan fitur CRUD lainnya yaitu: Create, Update & Delete. Tidak hanya sekedar menambahkan, kia juga belajar bagaimana caranya menambahkan interaksi drag marker ketika proses edit berlangsung. Berikut demo dari project yang akan kita buat:

Bagi kamu yang belum setup project ini silakan cek tutorial sebelumnya:

Part 1 - Tutorial GIS Interaktif Menggunakan Laravel Inertia & React

Part 2 - Tutorial GIS Interaktif Menggunakan Laravel Inertia & React

Step 1: Tambahkan Operasi Create

Sebelum mulai, pastikan data pada table locations sudah dihapus semua agar bisa load data yang sudah diupload nanti-nya.

Tambahkan route create pada web.php:

routes\web.php
...
Route::post('/location-create', [LocationController::class, 'create'])->name('location.create');

Navigasi ke LocationController lalu tambahkan method create yang akan menghandle proses create location:

app\Http\Controllers\LocationController.php
<?php
 
namespace App\Http\Controllers;
 
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Redirect;
 
use Inertia\Inertia;
use Inertia\Response;
use App\Models\Location;
 
class LocationController extends Controller
{
    public function index(): Response{
        $locations = Location::all();
 
        return Inertia::render('Location/Index', [
            'locations' => $locations
        ]);
    }
 
    public function create(Request $request): RedirectResponse{
        //validate each request
        $validateForm = $request->validate([
            'lat' => ['required', 'min:4', 'max:100'],
            'long' => ['required', 'min:4', 'max:100'],
            'name' => ['required', 'min:3', 'max:255'],
            'description' => ['max:500'],
            'image' => ['required','mimes:jpg,jpeg,png,avif', 'max:4096'],
            'rating' => ['required'],
        ]);
 
        //define formData
        $formData = [
            'lat' => $request->lat,
            'long' => $request->long,
            'name' => $request->name,
            'description' => $request->description,
            'rating' => $request->rating,
            'is_active' => 1
        ];
 
        //upload image to public/images folder
        if($request->image){
            $originalImage = $request->image;
            $filename = uniqid() . '.' . $request->image->getClientOriginalExtension();
            $originalImage->move(public_path().'/images/', $filename);
 
            $formData['image'] = $filename;
        }
 
        //create location
        Location::create($formData);
 
        return Redirect::route('location.index');
    }
}

Pindah ke file Index.jsx, panggil component LocationForm (nanti akan kita bikin), tambahkan allLocations sebagai temp state agar kedepan proses update bisa berjalan dengan lancar:

resources\js\Pages\Location\Index.jsx
/* ... previous import */
import LocationForm from "@/Components/LocationForm";
import PrimaryButton from "@/Components/PrimaryButton";
 
const Location = ({ auth }) => {
  const { locations } = usePage().props;
 
  const [allLocations, setAllLocations] = useState([]);
  const [isCreateMode, setIsCreateMode] = useState(false);
 
  //make sure to update state when locations update from server after create new location
  useEffect(() => {
    setAllLocations(locations);
  }, [locations]);
 
  /* ... previous code */
 
  return (
    <AuthenticatedLayout
      user={auth.user}
      header={
        <h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
          Master Location
        </h2>
      }
    >
      <Head title="Dashboard GIS" />
      <div className="w-3/4">
        <Map
          reuseMaps
          ref={mapRef}
          mapboxAccessToken="pk.eyJ1IjoiY3J1c2hlcmJsYWNrIiwiYSI6ImNsOXk3cTZzajAyazYzbnBkbWs0Y3AyNjcifQ.hXpOiJw9u5SzTTbFi-a_zQ"
          initialViewState={{
            longitude: 106.8291201,
            latitude: -6.1836782,
            zoom: 12,
            bearing: 0,
            pitch: 0,
          }}
          style={{ width: "100%", height: 600 }}
          mapStyle="mapbox://styles/mapbox/streets-v9"
          cursor={isCreateMode ? "pointer" : "auto"}
        >
          <GeolocateControl position="top-left" />
          <FullscreenControl position="top-left" />
          <NavigationControl position="top-left" />
          <ScaleControl />
          {pins}
 
          {popupInfo && (
            <Popup
              anchor="top"
              longitude={Number(popupInfo.long)}
              latitude={Number(popupInfo.lat)}
              onClose={() => setPopupInfo(null)}
            >
              <div className="mb-3">
                <h2 className="font-semibold mb-2 text-lg">{popupInfo.name}</h2>
                <p>{popupInfo.description}</p>
                <p>Rating: {popupInfo.rating}</p>
              </div>
              <img
                width="100%"
                src={"/images/" + popupInfo.image}
                className="object-cover rounded-sm"
              />
            </Popup>
          )}
        </Map>
 
        <div className="mt-8" />
 
        <ItemSlider
          locations={allLocations}
          ref={sliderRef}
          handleJumpTo={handleJumpTo}
          setPopupInfo={setPopupInfo}
        />
      </div>
 
      <div className="w-1/4">
        {!isCreateMode && (
          <PrimaryButton className="mb-4" onClick={() => setIsCreateMode(true)}>
            Add New Location
          </PrimaryButton>
        )}
 
        {isCreateMode && (
          <p className="text-green-500 font-semibold text-sm mb-2">
            Click On Map To Add New Location
          </p>
        )}
 
        {isCreateMode && <LocationForm />}
      </div>
    </AuthenticatedLayout>
  );
};
 
export default Location;

Buatlah sebuah component LocationForm:

resources\js\Components\LocationForm.jsx
import React from "react";
import { Transition } from "@headlessui/react";
 
import PrimaryButton from "./PrimaryButton";
import InputError from "./InputError";
import TextInput from "./TextInput";
import InputLabel from "./InputLabel";
 
const LocationForm = ({
  setData = () => {},
  handleSubmit = () => {},
  handleResetForm = () => {},
  data,
  errors,
  processing,
  recentlySuccessful,
}) => {
  return (
    <form onSubmit={handleSubmit} className="w-full">
      <div>
        <InputLabel htmlFor="long" value="Longtitude" />
 
        <TextInput
          value={data.long}
          onChange={(e) => setData("long", e.target.value)}
          type="text"
          className="mt-1 block w-full"
        />
 
        <InputError message={errors.long} className="mt-2" />
      </div>
 
      <div className="mt-4" />
 
      <div>
        <InputLabel htmlFor="password" value="Latitude" />
 
        <TextInput
          value={data.lat}
          onChange={(e) => setData("lat", e.target.value)}
          type="text"
          className="mt-1 block w-full"
        />
 
        <InputError message={errors.lat} className="mt-2" />
      </div>
 
      <div className="mt-4" />
 
      <div>
        <InputLabel htmlFor="name" value="Name" />
 
        <TextInput
          value={data.name}
          onChange={(e) => setData("name", e.target.value)}
          type="text"
          className="mt-1 block w-full"
        />
 
        <InputError message={errors.name} className="mt-2" />
      </div>
 
      <div className="mt-4" />
 
      <div>
        <InputLabel htmlFor="description" value="Description" />
 
        <TextInput
          value={data.description}
          onChange={(e) => setData("description", e.target.value)}
          type="text"
          className="mt-1 block w-full"
        />
 
        <InputError message={errors.description} className="mt-2" />
      </div>
 
      <div className="mt-4" />
 
      <div>
        <InputLabel htmlFor="rating" value="Rating" />
 
        <TextInput
          value={data.rating}
          onChange={(e) => setData("rating", e.target.value)}
          type="number"
          className="mt-1 block w-full"
        />
 
        <InputError message={errors.rating} className="mt-2" />
      </div>
 
      <div className="mt-4" />
 
      <div>
        <InputLabel htmlFor="image" value="Image" />
 
        <TextInput
          onChange={(e) => setData("image", e.target.files[0])}
          type="file"
          className="mt-1 block w-full"
        />
 
        <InputError message={errors.image} className="mt-2" />
      </div>
 
      <div className="mt-8" />
 
      <div className="flex items-center gap-4">
        <PrimaryButton
          type="button"
          disabled={processing}
          onClick={handleResetForm}
        >
          Cancel
        </PrimaryButton>
        <PrimaryButton disabled={processing}>Save Location</PrimaryButton>
 
        <Transition
          show={recentlySuccessful}
          enter="transition ease-in-out"
          enterFrom="opacity-0"
          leave="transition ease-in-out"
          leaveTo="opacity-0"
        >
          <p className="text-sm text-gray-600 dark:text-gray-400">
            New Location Added
          </p>
        </Transition>
      </div>
    </form>
  );
};
 
export default LocationForm;

Karena kita sudah load gambar dari database maka kita akan modifikasi component ItemSlider agar support:

resources\js\Components\ItemSlider.jsx
import React, { forwardRef } from "react";
import Slider from "react-slick";
 
const settings = {
  dots: false,
  infinite: true,
  speed: 500,
  slidesToShow: 1,
  slidesToScroll: 1,
  arrows: false,
  autoplay: true,
  variableWidth: true,
  pauseOnHover: true,
  swipeToSlide: true,
};
 
const ItemSlider = forwardRef(
  (
    { locations = [], handleJumpTo = () => {}, setPopupInfo = () => {} },
    ref
  ) => {
    return (
      <Slider {...settings} ref={ref}>
        {locations.map((location) => (
          <div
            key={location.name}
            className={"pr-4 h-32 w-40 hover:cursor-pointer hover:opacity-80"}
            onClick={() => {
              handleJumpTo(location);
              setPopupInfo(location);
            }}
          >
            <img
              src={"/images/" + location.image}
              className="object-cover h-full w-full aspect-video rounded-sm"
            />
            <p className="text-white/80 mt-2 w-40 text-lg">{location.name}</p>
            <p className="text-white/80 mt-1 w-40 text-sm">
              {location.description}
            </p>
          </div>
        ))}
      </Slider>
    );
  }
);
 
export default ItemSlider;

Berikut tampilan form setelah klik tombol Tambah Location:

Create Form

Tambahkan function simpan location dan teruskan pada component LocationForm

resources\js\Pages\Location\Index.jsx
/* ... previous import */
import { Head, useForm, usePage } from "@inertiajs/react";
 
const Location = ({ auth }) => {
  /* ... previous code */
 
  //initialize form and setup HTTP Method to handle save/update form
  const {
    setData,
    post: postHTTPMethod,
    delete: deleteHTTPMethod,
    reset,
    data,
    errors,
    processing,
    recentlySuccessful,
  } = useForm({
    long: "",
    lat: "",
    name: "",
    description: "",
    image: "",
  });
 
  //handle to get coordinate from map
  const handleSetLocation = useCallback(
    (e) => {
      if (!isCreateMode) return;
 
      setData({
        long: e.lngLat.lng,
        lat: e.lngLat.lat,
      });
    },
    [isCreateMode]
  );
 
  //handle to save location to database
  const handleSubmit = useCallback(
    (e) => {
      e.preventDefault();
 
      if (isCreateMode) {
        postHTTPMethod(route("location.create", data), {
          onSuccess: () => {
            handleResetForm();
          },
        });
      } else {
        //next code here
      }
    },
    [isCreateMode, data]
  );
 
  const handleResetForm = useCallback(() => {
    setPopupInfo(null);
    setIsCreateMode(false);
    setAllLocations(locations);
    reset();
  }, [locations]);
 
  /* ... previous code */
 
  return (
    <AuthenticatedLayout
      user={auth.user}
      header={
        <h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
          Master Location
        </h2>
      }
    >
      <Head title="Dashboard GIS" />
      <div className="w-3/4">
        <Map
          reuseMaps
          ref={mapRef}
          mapboxAccessToken="pk.eyJ1IjoiY3J1c2hlcmJsYWNrIiwiYSI6ImNsOXk3cTZzajAyazYzbnBkbWs0Y3AyNjcifQ.hXpOiJw9u5SzTTbFi-a_zQ"
          initialViewState={{
            longitude: 106.8291201,
            latitude: -6.1836782,
            zoom: 12,
            bearing: 0,
            pitch: 0,
          }}
          style={{ width: "100%", height: 600 }}
          mapStyle="mapbox://styles/mapbox/streets-v9"
          handleSetLocation={handleSetLocation}
          cursor={isCreateMode ? "pointer" : "auto"}
        >
          {/* ... previous code */}
        </Map>
      </div>
 
      <div className="w-1/4">
        {!isCreateMode && (
          <PrimaryButton className="mb-4" onClick={() => setIsCreateMode(true)}>
            Add New Location
          </PrimaryButton>
        )}
 
        {isCreateMode && (
          <p className="text-green-500 font-semibold text-sm mb-2">
            Click On Map To Add New Location
          </p>
        )}
 
        {isCreateMode && (
          <LocationForm
            data={data}
            setData={setData}
            errors={errors}
            handleSubmit={handleSubmit}
            handleResetForm={handleResetForm}
            processing={processing}
            recentlySuccessful={recentlySuccessful}
          />
        )}
      </div>
    </AuthenticatedLayout>
  );
};
 
export default Location;

Agar bisa menambahkan location baru pastikan kamu sudah mengklik tombol tambah location, kemudian klik ke arah map hingga longtitude dan latitude pada map terisi kemudian klik save location.

Step 2: Tambahkan Operasi Update

Tambahkan sebuah route update pada web.php:

routes\web.php
...
Route::post('/location-update', [LocationController::class, 'update'])->name('location.update');

Kembali ke LocationController lalu tambahkan method update yang akan menghandle proses update location:

app\Http\Controllers\LocationController.php
<?php
 
namespace App\Http\Controllers;
 
// ... previous code
 
class LocationController extends Controller
{
    // ... previous code
 
    public function update(Request $request): RedirectResponse{
        // find location before update
        $location = Location::find($request->id);
 
        $validateForm = $request->validate([
            'lat' => ['required', 'min:4', 'max:100'],
            'long' => ['required', 'min:4', 'max:100'],
            'name' => ['required', 'min:3', 'max:255'],
            'description' => ['max:500'],
            'image' => ['mimes:jpg,jpeg,png,avif', 'max:4096'],
            'rating' => ['required'],
        ]);
 
        $formData = [
            'lat' => $request->lat,
            'long' => $request->long,
            'name' => $request->name,
            'description' => $request->description,
            'rating' => $request->rating,
            'is_active' => 1
        ];
 
        //if have previous image delete it and replace with new image
        if($request->image){
            $imagePath = public_path().'/images/' . $location->image;
 
            if (file_exists($imagePath)) {
                unlink($imagePath);
            }
 
            $originalImage = $request->image;
            $filename = uniqid() . '.' . $request->image->getClientOriginalExtension();
            $originalImage->move(public_path().'/images/', $filename);
 
            $formData['image'] = $filename;
        }
 
        $location->update($formData);
 
        return Redirect::route('location.index');
    }
}

Pada Index.jsx tambahkan modifikasi pada function handleJumpTo dan marker agar bisa edit location pada form:

resources\js\Pages\Location\Index.jsx
/* ... previous import */
 
const Location = ({ auth }) => {
  /* ... previous code */
  const [isUpdateMode, setIsUpdateMode] = useState(false);
 
  /* ... previous code */
 
  const handleJumpTo = useCallback(
    (data) => {
      setIsUpdateMode(true);
 
      setData({
        id: data.id,
        lat: data.lat,
        long: data.long,
        name: data.name,
        description: data.description,
        rating: data.rating,
      });
 
      mapRef.current.easeTo(
        {
          center: [data.long, data.lat],
          zoom: 13, // Zoom level of the target location
          bearing: 0, // Bearing of the map (optional)
          pitch: 0, // Pitch of the map (optional)
        },
        {
          duration: 2000, // Animation duration in milliseconds
          easing: (t) => t, // Easing function, default is linear
        }
      );
    },
    [mapRef, isCreateMode]
  );
 
  /* ... previous code */
 
  const pins = useMemo(
    () =>
      allLocations.map((location, index) => (
        <Marker
          key={`marker-${index}`}
          longitude={location.long}
          latitude={location.lat}
          anchor="bottom"
          onClick={(e) => {
            e.originalEvent.stopPropagation();
 
            //make sure user can't click marker when in create mode
            if (isCreateMode) return;
 
            setPopupInfo(location);
            scrollToSlide(index);
            handleJumpTo(location);
          }}
          draggable={location.id === data.id}
          onDragStart={() => setPopupInfo(null)}
          onDragEnd={(e) => {
            const copiedLocations = [...allLocations];
 
            copiedLocations[index] = {
              ...copiedLocations[index],
              lat: e.lngLat.lat,
              long: e.lngLat.lng,
            };
 
            setAllLocations(copiedLocations);
 
            setData({
              ...data,
              lat: e.lngLat.lat,
              long: e.lngLat.lng,
            });
          }}
        >
          <img
            src="https://cdn.iconscout.com/icon/free/png-256/free-restaurant-1495593-1267764.png?f=webp"
            className={`h-8 w-8 ${
              isCreateMode || (isUpdateMode && location.id !== data.id)
                ? "opacity-40"
                : "opacity-100"
            }`}
          />
        </Marker>
      )),
    [data, isCreateMode, allLocations]
  );
 
  /* ... previous code */
 
  const handleResetForm = useCallback(() => {
    setPopupInfo(null);
    setIsCreateMode(false);
    setIsUpdateMode(false);
    setAllLocations(locations);
    reset();
  }, [locations]);
 
  return (
    <AuthenticatedLayout
      user={auth.user}
      header={
        <h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
          Master Location
        </h2>
      }
    >
      <Head title="Dashboard GIS" />
      <div className="h-max w-3/4">{/*Previous Code*/}</div>
 
      <div className="w-1/4">
        {!isCreateMode && !isUpdateMode && (
          <PrimaryButton className="mb-4" onClick={() => setIsCreateMode(true)}>
            Add New Location
          </PrimaryButton>
        )}
 
        {isCreateMode && (
          <p className="text-green-500 font-semibold text-sm mb-2">
            Click On Map To Add New Location
          </p>
        )}
 
        {(isCreateMode || isUpdateMode) && (
          <LocationForm
            data={data}
            setData={setData}
            errors={errors}
            handleSubmit={handleSubmit}
            handleResetForm={handleResetForm}
            processing={processing}
            recentlySuccessful={recentlySuccessful}
            isUpdateMode={isUpdateMode}
          />
        )}
      </div>
    </AuthenticatedLayout>
  );
};
 
export default Location;

Tambahkan conditional agar tulisan pada button berubah sesuai dengan state update atau create:

resources\js\Components\LocationForm.jsx
/*... previous import*/
 
const LocationForm = ({
  setData = () => {},
  handleSubmit = () => {},
  handleResetForm = () => {},
  handleDeleteLocation = () => {},
  data,
  errors,
  processing,
  recentlySuccessful,
  isUpdateMode,
}) => {
  return (
    <form onSubmit={handleSubmit} className="w-full">
      {/*Previous Code*/}
 
      <div className="flex items-center gap-4">
        <PrimaryButton
          type="button"
          disabled={processing}
          onClick={handleResetForm}
        >
          Cancel
        </PrimaryButton>
        <PrimaryButton disabled={processing}>
          {isUpdateMode ? "Update" : "Save"} Location
        </PrimaryButton>
 
        {/*Previous Code*/}
      </div>
    </form>
  );
};
 
export default LocationForm;

Jika sudah maka kamu dapat mengklik salah satu marker location, lalu tampilan aplikasimu akan me-load data dari map:

Update Form

Modifikasi function handleSubmit agar bisa update ke database:

resources\js\Pages\Location\Index.jsx
/* ... previous import */
 
const Location = ({ auth }) => {
  /* ... previous code */
  const handleSubmit = useCallback(
    (e) => {
      e.preventDefault();
 
      if (isCreateMode) {
        postHTTPMethod(route("location.create", data), {
          onSuccess: () => {
            handleResetForm();
          },
        });
      } else {
        postHTTPMethod(route("location.update", data), {
          onSuccess: () => {
            handleResetForm();
          },
        });
      }
    },
    [isCreateMode, data]
  );
 
  /* ... previous code */
 
  return (
    <AuthenticatedLayout
      user={auth.user}
      header={
        <h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
          Master Location
        </h2>
      }
    >
      <Head title="Dashboard GIS" />
      <div className="h-max w-3/4">{/*Previous Code*/}</div>
 
      {/*Rest of Code*/}
    </AuthenticatedLayout>
  );
};
 
export default Location;

Silakan lakukan update pada location dengan cara memilih salah satu marker pada map

Step 3: Tambahkan Operasi Delete

Tambahkan route delete pada web.php:

routes\web.php
...
Route::delete('/delete/{id}', [LocationController::class, 'delete'])->name('location.delete');

Kembali ke LocationController lalu tambahkan method delete yang akan menghandle proses delete location:

app\Http\Controllers\LocationController.php
<?php
 
namespace App\Http\Controllers;
 
// ... previous code
 
class LocationController extends Controller
{
    // ... previous code
 
     public function delete($id): RedirectResponse{
        $location = Location::find($id);
        $location->delete();
 
        return Redirect::route('location.index');
    }
}

Pada file Index.jsx tambahkan function delete dan turunkan pada component LocationForm:

resources\js\Pages\Location\Index.jsx
/* ... previous import */
 
const Location = ({ auth }) => {
  /* ... previous code */
 
  const handleDeleteLocation = useCallback((id) => {
    deleteHTTPMethod(
      route("location.delete", {
        id,
      }),
      {
        onSuccess: () => {
          handleResetForm();
        },
      }
    );
  }, []);
 
  return (
    <AuthenticatedLayout
      user={auth.user}
      header={
        <h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
          Master Location
        </h2>
      }
    >
      <Head title="Dashboard GIS" />
      <div className="h-max w-3/4">{/*Previous Code*/}</div>
 
      <div className="w-1/4">
        {/*Previous Code*/}
 
        {(isCreateMode || isUpdateMode) && (
          <LocationForm
            data={data}
            setData={setData}
            errors={errors}
            handleSubmit={handleSubmit}
            handleResetForm={handleResetForm}
            handleDeleteLocation={handleDeleteLocation}
            processing={processing}
            recentlySuccessful={recentlySuccessful}
            isUpdateMode={isUpdateMode}
          />
        )}
      </div>
    </AuthenticatedLayout>
  );
};
 
export default Location;

Tambahkan logic delete pada component LocationForm

resources\js\Components\LocationForm.jsx
import React from "react";
import { Transition } from "@headlessui/react";
 
import PrimaryButton from "./PrimaryButton";
import InputError from "./InputError";
import TextInput from "./TextInput";
import InputLabel from "./InputLabel";
 
const LocationForm = ({
  setData = () => {},
  handleSubmit = () => {},
  handleResetForm = () => {},
  handleDeleteLocation = () => {},
  data,
  errors,
  processing,
  recentlySuccessful,
  isUpdateMode,
}) => {
  return (
    <form onSubmit={handleSubmit} className="w-full">
      {/*Previous Code*/}
 
      <div className="flex items-center gap-4">
        <PrimaryButton
          type="button"
          disabled={processing}
          onClick={handleResetForm}
        >
          Cancel
        </PrimaryButton>
        <PrimaryButton disabled={processing}>
          {isUpdateMode ? "Update" : "Save"} Location
        </PrimaryButton>
 
        <Transition
          show={recentlySuccessful}
          enter="transition ease-in-out"
          enterFrom="opacity-0"
          leave="transition ease-in-out"
          leaveTo="opacity-0"
        >
          <p className="text-sm text-gray-600 dark:text-gray-400">
            New Location Added
          </p>
        </Transition>
      </div>
 
      {isUpdateMode && (
        <div className="mt-8">
          <DangerButton
            type="button"
            disabled={processing}
            onClick={() => handleDeleteLocation(data.id)}
          >
            Delete Location
          </DangerButton>
        </div>
      )}
    </form>
  );
};
 
export default LocationForm;

Kamu dapat melakukan delete dengan cara mengklik salah satu marker location dan pada mode edit klik tombol merah yaitu delete location

Delete Form

Penutup

Selesai!!! Selamat kamu sudah menyelesaikan tutorial ini, berikut link repo github dari tutorial ini, jangan lupa di share dan kasih star pada github reponya ya biar kami makin semangat memberikan tutorial gratis lainnya!