Dynamic types with LiveView Streams · Fly
On this submit, we’ll develop a dynamic listing part utilizing the brand new LiveView Streams and the improved type options launched in LiveView 0.18/0.19. Prepare to find the right way to create a responsive listing part with interactive capabilities like including, enhancing, and deleting gadgets. Fly.io is a superb place to run your Phoenix LiveView purposes! Try the right way to get started!
You developed a part that permits you to add and take away gadgets in a “has_many” relationship. Take, for instance, a buying listing the place it will probably have many gadgets. You’ve got bought a cool type that permits you to ship information for brand new gadgets, and it really works rather well:
Nonetheless, you are still not fully proud of the consequence. Whenever you make a mistake whereas including an merchandise to the listing, there isn’t any solution to edit it. How will you make the listing gadgets editable proper within the listing part, with out the necessity for a modal?
Nicely, what if we use particular person types for every listing merchandise as a substitute of a single type so as to add and affiliate them with the listing? That is a easy process, we will render as many types as we wish. We’ll use the to_form/2 perform to create type structs from changesets, after which we will use these structs to render our type elements.
And one other factor: as soon as we have fastened up our type part, we will see the right way to use LiveView Streams to handle and manipulate our listing gadgets with out the necessity to retailer all of them in reminiscence. Including, enhancing, deleting, and resetting gadgets in a set has by no means been simpler!
Defining Our Component’s Markup
Let’s begin by refactoring our component and holding solely the header:
defmodule ComponentsExamplesWeb.ListComponent do
use ComponentsExamplesWeb, :live_component
def render(assigns) do
~H"""
<div class="bg-grey-100 py-4 rounded-lg">
<div class="area-y-5 mx-auto max-w-7xl px-4 area-y-4">
<.header>
<%= @list_name %>
</.header>
</div>
</div>
"""
finish
def replace(%{listing: listing} = assigns, socket) do
socket =
socket
|> assign(
list_id: listing.id,
list_name: listing.title
)
{:okay, socket}
finish
finish
Within the assigns, we’re passing a struct listing
. It comprises an id, a title, and an inventory of things, every of which has a number of attributes.
To generate these structs, their corresponding changesets, and the context features that we’ll be utilizing later, you should use the phx.gen.context generator:
combine phx.gen.context SortableList Record lists title:string
combine phx.gen.context SortableList Merchandise gadgets identify:string place:integer list_id:integer
This is an instance listing, containing a single merchandise:
%ComponentsExamples.SortableList.Record{
__meta__: #Ecto.Schema.Metadata<:loaded, "lists">,
id: 1,
title: "Procuring listing 1",
gadgets: [
%ComponentsExamples.SortableList.Item{
__meta__: #Ecto.Schema.Metadata<:loaded, "items">,
id: 161,
name: "chocolate",
position: 0,
status: :started,
list_id: 1,
list: #Ecto.Association.NotLoaded<association :list is not loaded>,
inserted_at: ~N[2023-05-16 20:29:12],
updated_at: ~N[2023-05-16 20:29:12]
}
],
inserted_at: ~N[2023-04-25 19:35:09],
updated_at: ~N[2023-04-25 19:35:09]
}
Now, we have to iterate over the gadgets of the listing and render the types. However earlier than we dive into that, let’s replace our assigns to incorporate a type per merchandise:
def replace(%{listing: listing} = assigns, socket) do
+ item_forms = Enum.map(listing.gadgets, &build_item_form(&1, %{list_id: listing.id}))
socket =
socket
|> assign(
list_id: listing.id,
list_name: listing.title,
+ gadgets: item_forms
)
{:okay, socket}
finish
Let’s have a look at the content material of the build_item_form/1
perform:
defp build_item_form(item_or_changeset, params) do
changeset =
item_or_changeset
|> SortableList.change_item(params)
to_form(changeset, id: "form-#{changeset.information.list_id}-#{changeset.information.id}")
finish
We obtain an %Merchandise{}
and create a changeset from it. Then, we use the Phoenix.Component.html.to_form/2 perform. This helpful perform converts a knowledge construction right into a Phoenix.HTML.Form, which we will use to render our form/1 elements. Notice that the id
of the shape has the list_id
and the id
of the merchandise interpolated.
Because of this, we now have an assign referred to as :gadgets
that holds the Phoenix.HTML.Kind
structs containing our component information. We iterate by way of every merchandise in our assign and render a simple_form/1
for every of them:
def render(assigns) do
~H"""
<div class="bg-gray-100 py-4 rounded-lg">
<div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">
<.header>
<%= @list_name %>
</.header>
+ <div id={"#{@list_id}-items"} class="grid grid-cols-1 gap-2">
+ <div :for={type <- @gadgets} id={"listing#{@list_id}-item#{merchandise.id}"}>
+ <.simple_form
+ for={type}
+ phx-change="validate"
+ phx-submit="save"
+ phx-target={@myself}
+ class="min-w-0 flex-1 drag-ghost:opacity-0"
+ phx-value-id={type.information.id}
+ >
+ <div class="flex">
+ </div>
+ </.simple_form>
</div>
</div>
</div>
</div>
"""
finish
We outline the occasions triggered on change or information submission, and we go the merchandise ID to those occasions utilizing the phx-value-*
bindings.
Now, let’s add components to the physique of our merchandise type. We begin by including a button to vary the standing of our component:
def render(assigns) do
~H"""
<div class="bg-gray-100 py-4 rounded-lg">
<div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">
<.header>...</.header>
<div id={"#{@list_id}-items"} class="grid grid-cols-1 gap-2">
<div :for={type <- @gadgets} id={"listing#{@list_id}-item#{merchandise.id}"}>
<.simple_form ...>
<div class="flex">
+. <button
+ :if={type.information.id}
+ kind="button"
+ class="w-10"
+. phx-click={JS.push("toggle_complete", goal: @myself, worth: %{id: type.information.id})}
+ >
+ <.icon
+ identify="hero-check-circle"
+ class={[
+ "w-7 h-7",
+. if(form[:status].worth == :accomplished,
+ do: "bg-green-600",
+ else: "bg-gray-300")
+ ]}
+ />
+. </button>
</div>
</.simple_form>
</div>
</div>
</div>
</div>
"""
finish
This button is displayed conditionally when the listing component has an id
, indicating that it already exists within the database and its standing could be edited. Moreover, we apply conditional courses to the <.icon>
primarily based on the merchandise’s standing, to vary its colour.
Now, let’s add a very powerful half, the textual content enter to to edit every listing merchandise:
We add two textual content inputs to ship the parameters of our merchandise: the :identify
and the :list_id
to which it belongs:
def render(assigns) do
~H"""
<div class="bg-gray-100 py-4 rounded-lg">
<div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">
<.header>...</.header>
<div id={"#{@list_id}-items"} class="grid grid-cols-1 gap-2">
<div :for={type <- @gadgets} id={"listing#{@list_id}-item#{merchandise.id}"}>
<.simple_form ...>
<div class="flex">
...
+ <div class="flex-auto block text-sm leading-6 text-zinc-900">
+ <enter kind="hidden" identify={type[:list_id].identify} worth={type[:list_id].worth} />
+ <.enter
+ area={type[:name]}
+ kind="textual content"
+ phx-target={@myself}
+ phx-key="escape"
+ phx-keydown={
+ !type.information.id &&
+ JS.push("discard", goal: @myself, worth: %{list_id: @list_id})
+ }
+ phx-blur={type.information.id && JS.dispatch("submit", to: "##{type.id}")}
+ />
+ </div>
</div>
</.simple_form>
</div>
</div>
</div>
</div>
"""
finish
Let’s take a more in-depth take a look at the weather we added. First, we embrace a hidden enter area to retailer the id of the listing to which the component belongs. This permits us to ship it as a part of the parameters for the validate
and save
occasions by setting the worth={type[:list_id].worth}
attribute.
Subsequent, we introduce a barely extra advanced <.enter>
part with occasion choices. By utilizing the phx-key
and phx-keydown
attributes, we specify that urgent the escape key triggers the discard
occasion despatched to the server.
We’ve got two methods to avoid wasting adjustments: urgent Enter to set off the submit
occasion or permitting adjustments to be mechanically saved when the enter loses focus.
For components that exist already within the database and are modified, the phx-blur
binding comes into play. It mechanically submits the shape when the enter loses focus, guaranteeing that adjustments are saved seamlessly.
Lastly, we add a button with an icon to delete current components from the database:
def render(assigns) do
~H"""
<div class="bg-gray-100 py-4 rounded-lg">
<div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">
<.header>...</.header>
<div id={"#{@list_id}-items"} class="grid grid-cols-1 gap-2">
<div :for={type <- @gadgets} id={"listing#{@list_id}-item#{merchandise.id}"}>
<.simple_form ...>
<div class="flex">
...
+ <button
+ :if={type.information.id}
+ kind="button"
+ class="w-10 -mt-1 flex-none"
+ phx-click={
+ JS.push("delete", goal: @myself, worth: %{id: type.information.id})
+ |> disguise("#listing#{@list_id}-item#{type.information.id}")
+ }
+ >
+ <.icon identify="hero-x-mark" />
+ </button>
</div>
</.simple_form>
</div>
</div>
</div>
</div>
"""
finish
We set off the delete
occasion whereas concurrently hiding the type of the merchandise we want to take away.
We now add the ultimate part to our part. We embrace two buttons: one so as to add a brand new merchandise to the listing, and one other to delete all gadgets from the listing.
def render(assigns) do
~H"""
<div class="bg-gray-100 py-4 rounded-lg">
<div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">
<.header>...</.header>
<div id={"#{@list_id}-items"} class="grid grid-cols-1 gap-2">
<div :for={type <- @gadgets} id={"listing#{@list_id}-item#{merchandise.id}"}>
...
</div>
</div>
+ <.button phx-click={JS.push("new", goal: @myself, worth: %{list_id: @list_id})} class="mt-4">
+ Add merchandise
+ </.button>
+ <.button
+ phx-click={JS.push("reset", goal: @myself, worth: %{list_id: @list_id})}
+ class="mt-4"
+ >
+ Reset
+ </.button>
</div>
</div>
"""
finish
We despatched occasions to the server utilizing JS.push. Each the reset
and new
occasions obtain the list_id
as a parameter.
Superior! We have completed the markup for our part!
We are able to simply render it by passing an inventory struct just like the one we noticed above:
def render(assigns) do
~H"""
<div id="lists" class="grid sm:grid-cols-1 md:grid-cols-3 hole-2">
<.live_component
:for={listing <- @lists}
module={ComponentsExamplesWeb.ListComponent}
id={listing.id}
listing={listing}
/>
</div>
"""
finish
However earlier than we deal with dealing with all of the occasions we outlined, how about we optimize reminiscence utilization through the use of Streams
? With just some tweaks, we will implement this characteristic and guarantee we’re not storing all of the listing components in reminiscence.
Converting to Streams
To optimize memory usage, let’s start by making a small change to our assigns. We assign the stream items using the stream/4 perform:
def replace(%{listing: listing} = assigns, socket) do
item_forms = Enum.map(listing.gadgets, &build_item_form(&1, %{list_id: assigns.id}))
socket =
socket
|> assign(
list_id: listing.id,
list_name: listing.title,
- gadgets: item_forms
)
+ |> stream(:gadgets, item_forms)
{:okay, socket}
finish
Subsequent, we outline the required phx-update
DOM attribute on the mum or dad container the place the merchandise assortment is rendered. The gadgets at the moment are accessed by way of the brand new assign @streams
, and we eat the stream utilizing a comprehension:
def render(assigns) do
~H"""
<div class="bg-gray-100 py-4 rounded-lg">
<div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">
...
- <div id={"#{@list_id}-items"} class="grid grid-cols-1 gap-2">
+ <div
+ id={"#{@list_id}-items"}
+ class="grid grid-cols-1 gap-2"
+ phx-update="stream"
+ >
- <div :for={type <- @gadgets} id={"listing#{@list_id}-item#{merchandise.id}"}>
+ <div :for={{id, type} <- @streams.gadgets} id={id}>
...
</div>
</div>
...
</div>
</div>
"""
finish
Now we’re all set to deal with the occasions we outlined earlier!
Add, Delete, Update, Reset Streams
We’ll be defining five events (new, validate, save, delete, reset). Our essential focus is clarify how we will use streams to replicate adjustments in our listing. We can’t be diving into features that work together with the database.
To get began, it is going to be useful to outline a helper perform that creates types already related to the list_id
:
defp build_empty_form(list_id) do
build_item_form(%Merchandise{list_id: list_id}, %{})
finish
Take into accout this perform, in addition to the one we outlined earlier. We’ll be utilizing them in our upcoming steps!
New
To add a new element to the list, we can use the function we just defined to create an empty form. Then, we can insert this form into the stream using the stream_insert/4 perform:
def handle_event("new", %{"list_id" => list_id}, socket) do
{:noreply, stream_insert(socket, :gadgets, build_empty_form(list_id), at: -1)}
finish
To insert the brand new type on the finish of the listing, we use the :at
choice offered by the stream_insert/4
perform.
Validate
To display the errors of our item, we need to modify the already rendered form in the client and insert a new form that includes the errors from the changeset.
One important detail to note is that in order for our component to recognize that it should display the changeset errors, we need to add an :action
to it. To achieve this, we make a slight modification to the build_item_form/4
function that we defined earlier:
-defp build_item_form(item_or_changeset, params) do
+defp build_item_form(item_or_changeset, params, action nil) do
changeset =
item_or_changeset
|> SortableList.change_item(params)
+ |> Map.put(:action, action)
to_form(changeset, id: "form-#{changeset.data.list_id}-#{changeset.data.id}")
end
The action can be any random atom, but hey, let’s keep things clear and name them sensibly.
Now let’s see how to handle the event and use this new :action
parameter:
def handle_event("validate", %{"item" => item_params} = params, socket) do
item = %Item nil, list_id: item_params["list_id"]
item_form = build_item_form(item, item_params, :validate))
{:noreply, stream_insert(socket, :items, item_form}
end
First, we generate a new %Item{}
that includes the item id
sent from the text input and the list_id
sent using the phx-value-id={form.data.id}
parameter. Next, we call our build_item_form/4
function with the :validate
action and insert the item into the stream using stream_insert/4
.
Like magic, we didn’t have to specify that this is an update of a form that already exists! This is because to_form
created a DOM ID that allows to identify the elements of the stream that already exists in the client.
Save
When attempting to save a new item, we may receive one of two possible responses. First, when an item is successfully inserted, we clear the new item form and replace it with a fresh empty form. We also insert a new form containing the persisted data. Second, if an error occurs, we display the relevant errors to the user.
Let’s now delve into the implementation details of these actions.
def handle_event("save", %{"item" => item_params}, socket) do
case SortableList.create_item(item_params) do
{:ok, new_item} ->
empty_form = build_empty_form(item_params["list_id"])
{:noreply,
socket
|> stream_insert(:items, build_item_form(new_item, %{}))
|> stream_delete(:items, empty_form)
|> stream_insert(:items, empty_form)}
{:error, changeset} ->
{:noreply, assign(socket, :form, build_item_form(changeset, %{}, :insert)))}
end
end
Great! We can chain together the different functions of the stream using the pipeline operator.
On the other hand, the save
event may be triggered by one of the forms that have already been saved in the database, indicating an update of the item. We can identify this by pattern matching, receiving a non-null item_id
:
def handle_event("save", %{"id" => item_id, "item" => params}, socket) do
todo = SortableList.get_item!(item_id)
case SortableList.update_item(todo, params) do
{:ok, updated_item} ->
{:noreply, stream_insert(socket, :items, build_item_form(updated_item, %{}))}
{:error, changeset} ->
{:noreply, stream_insert(socket, :items, build_item_form(changeset, %{}, :update))}
end
end
It’s almost like magic, isn’t it? We just need to use stream_insert
, and the stream takes care of updating itself. And if we want to display the errors from the changeset, we simply add an :action
to it.
Delete
Deleting items from a stream is easy too!
For this we have stream_delete/3 which additionally receives changesets as a parameter:
def handle_event("delete", %{"id" => item_id}, socket) do
merchandise = SortableList.get_item!(item_id)
{:okay, _} = SortableList.delete_item(merchandise)
{:noreply, stream_delete(socket, :gadgets, build_item_form(merchandise, %{}))}
finish
You can too use the stream_delete/3
perform to discard
the type of the brand new component when wanted:
def handle_event("discard", params, socket) do
{:noreply, stream_delete(socket, :gadgets, build_empty_form(params["list_id"]))}
finish
Reset
We have the option to remove all items from a stream, which is perfect for our reset button:
def handle_event("reset", params, socket) do
empty_form = build_empty_form(params["list_id"])
{:noreply, stream(socket, :items, [empty_form], reset: true)}
end
We simply need to reconfigure our stream by passing the option reset: true
. Additionally, we can include a list of new elements to be inserted, which in this case would be at least one empty form.
Hooray! We have finished it! Our part is now full and able to shine!
Closing
The features we explored today unlock a world of exciting new possibilities for apps development with LiveView. LiveView Streams
revolutionize collection handling and memory optimization, simplifying tasks such as adding, editing, and deleting items. Furthermore, the optimizations brought by to_form/1
enable efficient manipulation of individual inputs without the need for full form re-rendering. This simple yet immensely powerful function opens up new avenues for form usage, expanding the potential of your applications.
Check out this repo to see these game-changing options in motion. We used our previous learnings to create an much more spectacular part!
Credits
A big shoutout to Chris McCord for sharing the incredible example that impressed these posts and for patiently answering any questions in regards to the thrilling new ideas in Phoenix and LiveView.