Membuat Dropdown Bersyarat dengan Django

Bagus Aji Santoso 31 Januari 2018

Membuat Dropdown Bersyarat dengan Django

Diterjemahkan dari How to Implement Dependent/Chained Dropdown List with Django karya Vitor Freitas

Dropdown bersyarat (dependent/chained dropdown list) adalah field khusus yang bergantung pada field lain untuk menampilkan data nya. Field ini biasanya dipakai untuk menampilkan daftar provinsi dan kota, dimana kita memilih provinsi dulu, lalu berdasarkan provinsi yang dipilih baru kita bisa memilih kota-kota yang ada di provinsi tersebut.

Menjadi Developer Web dengan Python dan Flask Bagian I: Hello World

Perhatikan contoh model di bawah ini:

models.py

from django.db import models

class Country(models.Model):
    name = models.CharField(max_length=30)

    def __str__(self):
        return self.name

class City(models.Model):
    country = models.ForeignKey(Country, on_delete=models.CASCADE)
    name = models.CharField(max_length=30)

    def __str__(self):
        return self.name

class Person(models.Model):
    name = models.CharField(max_length=100)
    birthdate = models.DateField(null=True, blank=True)
    country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True)
    city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True)

    def __str__(self):
        return self.name

Di aplikasi ini kita akan membangun sebuah form processing untuk membuat dan mengupdate objek person. Dropdown bersyarat akan bergantung pada field country dan city yang ada di dalam model person.

urls.py

from django.urls import include, path

from . import views

urlpatterns = [
    path('', views.PersonListView.as_view(), name='person_changelist'),
    path('add/', views.PersonCreateView.as_view(), name='person_add'),
    path('<int:pk>/', views.PersonUpdateView.as_view(), name='person_change'),
]

Terakhir, tiga view yang akan kita pakai:

views.py

from django.views.generic import ListView, CreateView, UpdateView
from django.urls import reverse_lazy
from .models import Person

class PersonListView(ListView):
    model = Person
    context_object_name = 'people'

class PersonCreateView(CreateView):
    model = Person
    fields = ('name', 'birthdate', 'country', 'city')
    success_url = reverse_lazy('person_changelist')

class PersonUpdateView(UpdateView):
    model = Person
    fields = ('name', 'birthdate', 'country', 'city')
    success_url = reverse_lazy('person_changelist')

Contoh di atas sudah bisa berjalan, selain ia memperbolehkan data yang tidak konsisten disimpan ke database. Misalnya, seseorang bisa memilih Brazil dari dropdown country lalu memilih kota New York di dropdown city. Kita tentu tak mau hal ini terjadi. Kita ingin agar dropdown city hanya menampilkan kota-kota di Brazil saja.

City Dropdown List

Kode HTML di bawah melakukan proses rendering dari view yang telah dibuat:

{% extends 'base.html' %}

{% block content %}
  <h2>Person Form</h2>
  <form method="post" novalidate>
    {% csrf_token %}
    <table>
      {{ form.as_table }}
    </table>
    <button type="submit">Save</button>
    <a href="{% url 'person_changelist' %}">Nevermind</a>
  </form>
{% endblock %}

Dependent Dropdown Form

Cara terbaik untuk menjaga konsistensi data pada form ini ialah dengan mengimplementasi dropdown bersyarat. Ia dapat dibuat dengan sebuah model form. Dengan model form kita mendapatkan fleksibilitas untuk memperluas fitur-fiturnya.

forms.py

from django import forms
from .models import Person, City

class PersonForm(forms.ModelForm):
    class Meta:
        model = Person
        fields = ('name', 'birthdate', 'country', 'city')

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['city'].queryset = City.objects.none()

Contoh di atas hanyalah sebuah form biasa dengan satu perubahan penting: kita meng-override method init dan mengatur queryset di field city untuk mengosongkannya:

Empty City Dropdown List

Catatan: Jangan lupa untuk view agar memakai kelas form yang baru dibuat:

views.py

class PersonCreateView(CreateView):
    model = Person
    form_class = PersonForm
    success_url = reverse_lazy('person_changelist')

class PersonUpdateView(UpdateView):
    model = Person
    form_class = PersonForm
    success_url = reverse_lazy('person_changelist')

Sekarang kita perlu membuat sebuah view untuk mengembalikan daftar kota berdasarkan negara yang dipilih. View ini akan menggunakan rikues AJAX.

views.py

def load_cities(request):
    country_id = request.GET.get('country')
    cities = City.objects.filter(country_id=country_id).order_by('name')
    return render(request, 'hr/city_dropdown_list_options.html', {'cities': cities})

Sebuah fungsi view sederhana yang membaca daftar kota di database berdasarkan negara yang dipilih. Di bawah adalah kode template HTML yang dipakai:

templates/hr/city_dropdown_list_options.html

<option value="">---------</option>
{% for city in cities %}
<option value="{{ city.pk }}">{{ city.name }}</option>
{% endfor %}

Dapat memahami apa yang ada di template? Kita template di atas hanya untuk menampilkan daftar kota ke dalam tag option milik select dengan forloop. Langkah berikutnya ialah menempelkan template ini ke halaman yang sudah ada tanpa merefresh halaman HTML utama.

Sebelum melanjutkan, mari buat sebuah route URL untuk view di atas:

urls.py

from django.urls import include, path
from . import views

urlpatterns = [
    path('', views.PersonListView.as_view(), name='person_changelist'),
    path('add/', views.PersonCreateView.as_view(), name='person_add'),
    path('<int:pk>/', views.PersonUpdateView.as_view(), name='person_change'),

    path('ajax/load-cities/', views.load_cities, name='ajax_load_cities'),  # <-- this one here
]

Sekarang waktunya AJAX. Pada contoh di bawah ini, penulis menggunaan jQuery, namun pembaca bebas untuk memakai framework JavaScript manapun (atau JavaScript bisa) untuk membuat rikues asynchronous:

templates/person_form.html

{% extends 'base.html' %}

{% block content %}

  <h2>Person Form</h2>

  <form method="post" id="personForm" data-cities-url="{% url 'ajax_load_cities' %}" novalidate>
    {% csrf_token %}
    <table>
      {{ form.as_table }}
    </table>
    <button type="submit">Save</button>
    <a href="{% url 'person_changelist' %}">Nevermind</a>
  </form>

  <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
  <script>
    $("#id_country").change(function () {
      var url = $("#personForm").attr("data-cities-url");  // get the url of the `load_cities` view
      var countryId = $(this).val();  // get the selected country ID from the HTML input

      $.ajax({                       // initialize an AJAX request
        url: url,                    // set the url of the request (= localhost:8000/hr/ajax/load-cities/)
        data: {
          'country': countryId       // add the country id to the GET parameters
        },
        success: function (data) {   // `data` is the return of the `load_cities` view function
          $("#id_city").html(data);  // replace the contents of the city input with the data that came from the server
        }
      });

    });
  </script>

{% endblock %}

Pertama, kita menambahkan sebuah ID untuk form (personForm) sehingga kita bisa mengaksesnya dengan lebih mudah. Lalu, kita tambahkan sebuah atribut data ke form, data-cities-url. Atribut tersebut merupakan strategi yang baik saat kita menemukan kasus dimana JavaScript-nya akan dibuat di file yang terpisah sehingga kita bisa mengakses URL yang dirender oleh Django.

Lalu, setelah menambah sebuah listener di dropdown country (negara), yang ditandai oleh id_country. ID ini dibuat secara otomatis oleh Django. listener kita menunggu agar value-nya berubah. Saat berubah, ia akan menjalankan rikues AJAX ke server, mengirim ID negara terpilkih ke fungsi view.

Setelah rikues berhasil, skrip yang sudah kita tulis akan menyisipkan HTML yang telah di-render di dalam view load_cities ke dalam HTML utama dengan ID id_city.

Dynamic City Dropdown List

Sekarang bagian front end sudah bekerja dengan baik, tapi bagian backend belum bisa bekerja sesuai dengan yang kita inginkan. Jika kita mengirim form ini sekarang, maka akan muncul error:

Error City Dropdown List

Itu karena list cities yang sebelumnya kita kosongkan di kelas form. Penulis ingin menunjukkan pesan error ini karena sangat berguna. Ia membantu kita menjaga konsistensi form. Artinya form Django akan mmemeriksa jika nilai yang diberikan ada di queryset.

Di bawah ini kode untuk menyelesaikannya:

forms.py

from django import forms
from .models import Person, City

class PersonForm(forms.ModelForm):
    class Meta:
        model = Person
        fields = ('name', 'birthdate', 'country', 'city')

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['city'].queryset = City.objects.none()

        if 'country' in self.data:
            try:
                country_id = int(self.data.get('country'))
                self.fields['city'].queryset = City.objects.filter(country_id=country_id).order_by('name')
            except (ValueError, TypeError):
                pass  # invalid input from the client; ignore and fallback to empty City queryset
        elif self.instance.pk:
            self.fields['city'].queryset = self.instance.country.city_set.order_by('name')

Maksud form ini, jika ada data POST (data is not None), ia akan mengambil daftar cities berdasarkan ID country yang diterima form. Jika tidak valid, maka form akan menampilkan pesan error. Jika tidak ada data POST tapi ada sebuah instance form (artinya form sedang dipakai untuk mengupdate), ia akan mengisi fiel city dengan daftar kota berdasarkan negara yang terpilih. Jika tidak ada sebuah instance, cukup kirimkan list kosong.

Kesimpulan

Cara terbaik untuk belajar ialah dengan mencobanya sendiri. Periksa kode yang ada di Github untuk mencobanya di komputer loka. Modofikasi contoh ini, adn pakai di project yang sedang pembaca kerjakan. Jika ada pertanyaan silahkan posting komentar di bawah!

Contoh kode tersedia online di dependent-dropdown-example.herokuapp.com;

Kode sumbernya ada di github.com/sibtc/dependent-dropdown-example/.