Dalam artikel kali ini akan coba dibawakan bagaimana mengimplementasikan CRUD (Create Read Update Delete) dalam Phoenix Liveview. Untuk kemudahan dan bisa memulai dengan cepat, bisa langsung melakukan git clone atau fork dari github repo berikut https://github.com/rizki96/liveview_todo.git.
Perlu diketahui, database yang digunakan kali ini adalah CubDB. Sedikit tentang CubDB, database ini adalah embedded key value database, dimana mekanisme penyimpanan datanya berlaku Copy On Write (COW) atau append only database untuk menjaga konsistensi data. Mekanisme ini berbeda dengan sqlite yang menggunakan mekanisme file locking. Dari mekanisme ini memungkinkan CubDB untuk disimpan dalam remote file system, seperti AWS S3 fuse mount atau google cloud storage fuse mount, atau file system seperti NFS maupun SMB. Karena dengan solusi remote file system biasanya fitur file locking tidak dijamin, sehingga tidak dimungkinkan sqlite berjalan dengan benar.
Untuk memulai mari kita bangun dulu interface untuk pengaksesan database CubDB. Interface ini akan disimpan dalam sebuah module elixir dalam file 'lib/liveview_todo/kv_store.ex'. Berikut isi dari file kv_store.ex
defmodule LiveviewTodo.KvStore do | |
@moduledoc false | |
require Logger | |
defmodule MetaRecord do | |
defstruct [ | |
last_registered_id: 0 | |
] | |
def new do | |
%__MODULE__{} | |
end | |
end | |
def child_spec(_) do | |
// NOTE: melakukan trigger worker, menggunakan data file CubDB dari config.exs | |
Supervisor.Spec.worker(CubDB, [Application.get_env(:liveview_todo, __MODULE__)[:data_dir], [name: __MODULE__]]) | |
end | |
def init() do | |
Logger.log(:debug, "#{__MODULE__}:init") | |
CubDB.set_auto_compact(__MODULE__, true) | |
CubDB.set_auto_file_sync(__MODULE__, true) | |
:ok | |
end | |
def new_data(record, data) do | |
meta = get_meta(record) | |
CubDB.put(__MODULE__, {record, meta.last_registered_id + 1}, data) | |
{record, id} = update_meta(record, meta.last_registered_id + 1) | |
get_data(record, id) | |
end | |
def update_data(record, id, data) do | |
CubDB.put(__MODULE__, {record, id}, data) | |
get_data(record, id) | |
end | |
def delete_data(record, id) do | |
data = get_data(record, id) | |
CubDB.delete(__MODULE__, {record, id}) | |
data | |
end | |
def get_data(record, id) do | |
CubDB.get(__MODULE__, {record, id}) | |
end | |
def get_meta(record) do | |
CubDB.get(__MODULE__, {:meta_record, record}, MetaRecord.new) | |
end | |
def update_meta(record, last_registered_id) do | |
CubDB.put(__MODULE__, {:meta_record, record}, %MetaRecord{last_registered_id: last_registered_id}) | |
{record, last_registered_id} | |
end | |
def struct_from_map(a_map, as: a_struct) do | |
# Find the keys within the map | |
keys = Map.keys(a_struct) |> Enum.filter(fn x -> x != :__struct__ end) | |
# Process map, checking for both string / atom keys | |
processed_map = | |
for key <- keys, into: %{} do | |
value = Map.get(a_map, key) || Map.get(a_map, to_string(key)) | |
{key, value} | |
end | |
a_struct = Map.merge(a_struct, processed_map) | |
a_struct | |
end | |
end |
Module elixir ini pada hakikatnya adalah sebuah supervisor actor yang akan menjalankan worker engine dari CubDB. Worker engine tentunya akan dijalankan pada saat aplikasi dimulai, oleh sebab itu tambahkan module LiveviewTodo.KvStore ke dalam children supervisor, sebagai contoh nya adalah penggalan berikut pada file 'lib/liveview_todo/application.ex'
def start(_type, _args) do | |
children = [ | |
# Start the Telemetry supervisor | |
LiveviewTodoWeb.Telemetry, | |
LiveviewTodo.KvStore, | |
# Start the PubSub system | |
{Phoenix.PubSub, name: LiveviewTodo.PubSub}, | |
# Start the Endpoint (http/https) | |
LiveviewTodoWeb.Endpoint | |
# Start a worker by calling: LiveviewTodo.Worker.start_link(arg) | |
# {LiveviewTodo.Worker, arg} | |
] | |
# See https://hexdocs.pm/elixir/Supervisor.html | |
# for other strategies and supported options | |
opts = [strategy: :one_for_one, name: LiveviewTodo.Supervisor] | |
Supervisor.start_link(children, opts) | |
end | |
Kemudian terdapat 2 bagian besar dari module LiveviewTodo.KvStore, yaitu bagian dari MetaRecord dan bagian dari record data aplikasi. MetaRecord berisi informasi id yang terakhir di tulis dalam sebuah record data. Informasi mengenai record data ini disimpan dalam bentuk key, yang berisi nama tabel dan id. Dan record yang berisi value adalah informasi lengkap dari record data tersebut. Contoh adalah table todos, dengan record id 1, maka key-nya akan berisi {:todos, 1}.
Data file dari CubDB sendiri akan disimpan dalam config.exs, dimana path dari file ini akan dibaca oleh module file 'kv_store.ex' tersebut. Berikut sebagian isi dari 'config.exs' tempat penyimpanan data file dari CubDB
config :liveview_todo, LiveviewTodo.KvStore, | |
data_dir: "priv/data/kv_store.db" | |
Phoenix framework sendiri mengenal konsep Context, dimana 'kv_store.ex' sebagai interface database tidak diperbolehkan untuk diakses langsung oleh aplikasi, tetapi harus melalui konsep Context ini. Untuk tiap aplikasi berbeda akan memiliki Context yang berbeda sesuai dengan domain aplikasi yang bersangkutan. Context aplikasi ini disimpan dalam module file 'lib/liveview_todo/tasks.ex' dimana dalam module ini terdapat query dan command yang akan diakses oleh aplikasi. Berikut isi dari module Context Tasks
defmodule LiveviewTodo.Tasks do | |
@moduledoc false | |
alias LiveviewTodo.KvStore | |
defmodule Todo do | |
@derive {Jason.Encoder, only: [:id, :text, :completed]} | |
defstruct [ | |
id: nil, | |
text: nil, | |
completed: false | |
] | |
def new(text) do | |
meta = KvStore.get_meta(:todos) | |
%__MODULE__{id: meta.last_registered_id + 1, text: text, completed: false} | |
end | |
end | |
def list_todos do | |
CubDB.select(LiveviewTodo.KvStore, | |
#reverse: true, | |
min_key_inclusive: false, | |
min_key: {:todos, 0}, | |
max_key: {:todos, nil} | |
) | |
end | |
def get_todo!(id), do: KvStore.get_data(:todos, id) | |
def create_todo(attrs \\ %{}) do | |
new_todo = change_todo(%Todo{}, attrs) | |
KvStore.new_data(:todos, Todo.new(new_todo.text)) | |
end | |
def update_todo(%Todo{} = todo, attrs) do | |
updated_todo = change_todo(todo, attrs) | |
KvStore.update_data(:todos, todo.id, updated_todo) | |
end | |
def delete_todo(todo) do | |
KvStore.delete_data(:todos, todo.id) | |
end | |
def change_todo(%Todo{} = todo, attrs \\ %{}) do | |
KvStore.struct_from_map(attrs, as: todo) | |
end | |
end |
Dalam Context Tasks ini terdapat CRUD module yang akan digunakan oleh aplikasi LiveView. Bagaimana penggunaan CRUD ini dalam LiveView ? Berikut module liveview yang telah dibuat pada artikel sebelumnya yaitu 'todo_live.ex', dengan ditambahkan fungsi list_todos untuk menampilkan semua data Todo.
defmodule LiveviewTodoWeb.TodoLive do | |
use LiveviewTodoWeb, :live_view | |
alias LiveviewTodo.Tasks | |
alias LiveviewTodoWeb.Todos.AddTodoFormComponent | |
require Logger | |
@impl true | |
def mount(_params, _session, socket) do | |
{:ok, todos} = Tasks.list_todos() | |
todos = Enum.map(todos, fn {_, todo} -> todo end) | |
socket = assign(socket, todos: todos) | |
{:ok, assign(socket, temporary_assigns: [todos: []])} | |
end | |
end |
Jika diperhatikan ada beberapa operasi pattern matching dalam kode ini. Seperti misalnya {:ok, todos} = Tasks.list_todos(), Dalam Elixir operator '=' bukan hanya berlaku sebagai assignment tapi juga sebagai match operator. Dimana match operator akan melakukan pencocokan antara sisi kiri dan kanan dari operasi '='. Kemudian dalam Phoenix (tidak hanya LiveView) dikenal perintah assign, contohnya adalah socket = assign(socket, todos: todos), perintah ini berfungsi untuk merubah state socket yang berisi variable yang dapat diakses dalam halaman HTML yang di render oleh LiveView. Dalam halaman HTML tersebut variable yang di assign dalam socket, seperti todos akan dikenal sebagai '@todos'. Dalam kode ini juga digunakan temporary_assigns seperti pada {:ok, assign(socket, temporary_assigns: [todos: []])}. temporary_assigns akan memberlakukan variable todos sebagai nilai sementara agar pada event berikutnya tidak dikirim semua data lagi ke halaman HTML LiveView. Fitur temporary_assigns ini yang akan membuat pengiriman data antara backend dan frontend menjadi efisien, karena semua data hanya akan dikirim sekali ketika event mount. Event berikutnya hanya data yang berubah saja yang akan dikirimkan. Perlu diketahui juga, data todos yang dikirim ke halaman HTML, ini secara otomatis akan di serialisasi kan sebagai data JSON, karena dalam module Tasks sebelumnya, module struct Todo telah ditambahkan line berikut : @derive {Jason.Encoder, only: [:id, :text, :completed]} , baris ini yang akan membuat framework Phoenix menginterpretasikan struct Todo menjadi JSON jika dibutuhkan, dan hanya field-field yang terpilih saja akan disertakan sebagai JSON object.
Kemudian bagaimana untuk menampilkan query list_todos ini dalam HTML ? Untuk itu mari kita buat LiveView Component yang berisi HTML dengan item-item 'todos'. Seperti sebelumnya buat Component dalam direktori 'lib/liveview_todo_web/live/components/todos' dengan nama file 'todo_list.ex' dan 'todo_list_item.ex'. Berturut-turut file tersebut berfungsi sebagai renderer untuk list of todos dan item todo. Berikut berturut-turut isi dari 'todo_list.ex' dan 'todo_list_item.ex'
defmodule LiveviewTodoWeb.Todos.TodoListComponent do | |
use Phoenix.LiveComponent | |
alias LiveviewTodoWeb.Todos.TodoListItemComponent | |
require Logger | |
def render(assigns) do | |
~L""" | |
<ul phx-update="append"> | |
<%= for todo <- @todos do %> | |
<%= live_component @socket, TodoListItemComponent, id: todo.id, todo: todo %> | |
<% end %> | |
</ul> | |
""" | |
end | |
def mount(socket) do | |
{:ok, socket} | |
end | |
end |
defmodule LiveviewTodoWeb.Todos.TodoListItemComponent do | |
use Phoenix.LiveComponent | |
use Phoenix.HTML | |
require Logger | |
def render(assigns) do | |
~L""" | |
<li id="list-item-<%= @todo.id %>" phx-update="replace"> | |
<label class='<%= if @todo.completed == true, do: "complete", else: "" %>'> | |
<%= checkbox(:todo, :completed, phx_click: "update_todo:#{@todo.id}", phx_value: Jason.encode!(@todo), id: "chkbox-#{@todo.id}", checked: @todo.completed) %> | |
<%= @todo.text %> | |
</label> | |
</li> | |
""" | |
end | |
def mount(socket) do | |
{:ok, socket} | |
end | |
end | |
Untuk mengetahui Component tersebut berfungsi, diharuskan untuk menambahkan Component tersebut ke halaman utama dari LiveView todo_live.html.leex. Sehingga halaman todo_live.html.leex akan berisi seperti berikut
<div> | |
<%= live_component @socket, TodoListComponent, id: :my_todo_list, todos: @todos %> | |
<%= live_component @socket, AddTodoFormComponent %> | |
</div> |
Tentu saja setelah ini harus ditambahkan juga fungsi untuk melakukan penambahan dan pengubahan data ke dalam database. Sehingga file 'todo_live.ex' akan ditambahkan event untuk melakukan add dan update. Berikut ini isi dari 'todo_live.ex'
defmodule LiveviewTodoWeb.TodoLive do | |
use LiveviewTodoWeb, :live_view | |
alias LiveviewTodo.Tasks | |
alias LiveviewTodoWeb.Todos.AddTodoFormComponent | |
alias LiveviewTodoWeb.Todos.TodoListComponent | |
require Logger | |
@impl true | |
def mount(_params, _session, socket) do | |
{:ok, todos} = Tasks.list_todos() | |
todos = Enum.map(todos, fn {_, todo} -> todo end) | |
socket = assign(socket, todos: todos) | |
{:ok, assign(socket, temporary_assigns: [todos: []])} | |
end | |
@impl true | |
def handle_event("add_todo", %{"todo" => todo} = _params, socket) do | |
Logger.log(:debug, "#{inspect todo}") | |
new_todo = Tasks.create_todo(todo) | |
{:noreply, assign(socket, :todos, [new_todo])} | |
end | |
@impl true | |
def handle_event("update_todo:" <> todo_id, params, socket) do | |
Logger.log(:debug, "#{inspect params}") | |
old_todo = Tasks.get_todo!(String.to_integer(todo_id)) | |
socket = | |
if old_todo do | |
updated_todo = Tasks.update_todo(old_todo, | |
(if Map.has_key?(params, "value") and params["value"] == "true", | |
do: %{completed: true, id: old_todo.id, text: old_todo.text}, | |
else: %{completed: false, id: old_todo.id, text: old_todo.text})) | |
assign(socket, :todos, [updated_todo]) | |
else | |
socket | |
end | |
{:noreply, socket} | |
end | |
end |
Dalam event yang akan di trigger dari halaman HTML pun mengenal pattern matching. Contohnya adalah baris 'def handle_event("add_todo", %{"todo" => todo} = _params, socket) do' dan 'def handle_event("update_todo:" <> todo_id, params, socket) do'. Parameter pada baris sebelumnya yaitu '%{"todo" => todo} = _params', params yang dikirim ke dalam event kita pattern match sebagai tipe map dan dilakukan ekstraksi terhadap nilai dalam map tersebut. Sedangkan params secara keseluruhan akan diabaikan dengan menambahkan '_' di depan nama variablenya. Begitu juga pada baris event berikutnya, "update_todo:" <> todo_id merupakan sebuah pattern match. Yang dilakukan disini adalah ekstraksi string untuk mendapatkan bagian yang dinginkan. Misalnya secara keseluruhan string nya adalah "update_todo:2" maka variable todo_id akan bernilai string '2'. Oleh sebab itu pattern matching adalah salah satu kekuatan dari bahasa Elixir untuk membuat kode lebih jelas dan ringkas.
Setelah fungsi add dan update, jalankan 'mix phx.server' untuk menjalankan phoenix web server dan akses ke halaman 'http://localhost:4000/todo'. Coba untuk menambahkan todo, misalnya adalah 'testing 1'. Maka akan terlihat seperti berikut ini
Bagaimana cara untuk melihat data yang dipertukarkan antara frontend dan backend dalam LiveView ? Buka bagian developer inspect element pada browser, kemudian pada bagian console javascript jalankan perintah liveSocket.enableDebug(). Berikut ini contoh gambarnya
Setelah itu coba untuk melakukan update dan akan terlihat hanya data yang berubah saja akan dikirim ke backend server. Contoh adalah sebagai berikut
Kemudian jika ditambahkan data todo berikutnya akan terlihat data baru ditambahkan pada element terakhir dari list_todo dan semua item todo terurut dari item todo yang lama ke item todo yang baru. Hal ini dimungkinkan dengan fitur DOM patching dari phoenix LiveView, dimana pada element list ditambahkan attribute phx-update="append". Bagaimana bila dibutuhkan sebaliknya ? data yang ditambahkan selalu berada diatas dan akan berurutan terbalik dari item todo yang baru ke item todo yang lama ? Caranya cukup mudah, yaitu ubah query list_todos menjadi reverse=true, query ini terdapat pada Context Tasks dan ubah nilai phx-update pada Component 'list_item.ex' menjadi phx-update="prepend". Maka ketika data ditambahkan todo akan terlihat seperti gambar dibawah
Bagaimana jika kita ingin menampilkan lebih Custom dari pada hanya sekedar append dan prepend. Jawabannya adalah gunakan JS Interop Hooks dalam LiveView.
Demikian bagian ke 2 dari artikel membangun web fullstack dengan menggunakan Phoenix LiveView, untuk JS Interop Hooks silahkan dicoba sebagai sebuah latihan, dan bisa dicoba juga untuk menambahkan fitur hapus. Terima kasih telah membaca artikel ini dan kemudian mencoba Phoenix LiveView, semoga lancar dan jika ada kritik, saran atau pertanyaan dapat langsung kontak melalui linkedin atau twitter di @rizki.
Top comments (0)