Home Posts Tags Post Search Tag Search

Post 96

LiveView 11 - Chapter 5: Form Bidings and File Upload

Published on: 2025-12-14 Tags: elixir, Blog, Side Project, files, LiveView, Ecto, Html/CSS, Phoenix
LiveView Form Bindings
        LiveView uses annotations called binding to tie live view to events using JavaScript. We focused so far on the phx-submit and phx-change. There is also bindings to control how often or when a LiveView JavaScript emits from events. 

        Submit and Disable a Form
            By default phx-submit cause 3 things to occur on the client:
                • The form’s inputs are set to “readonly”
                • The submit button is disabled
                • The "phx-submit-loading" CSS class is applied to the form

            In order to better make sure that a user can't change things once submitted the phx-disable-with binding disables the submit button. Normally there isn't enough time to see the disable but you can add into the JavaScript console: 
                liveSocket.enableLatencySim(1000)

            This will give it a 1 second delay. You will need to head into the browsers console:
                Chrome / Edge / Brave:
                F12 or Ctrl + Shift + I → “Console” tab

                Firefox:
                F12 or Ctrl + Shift + K

                Safari (macOS):
                Enable “Develop” menu in preferences, then Develop → Show JavaScript Console

            Let's try it right now and see if you notice the button becoming unusable.

        Rate Limit Form Events
            There are ways to limit rates for events with:
                phx-debounce - set an integer for when the first event will happen in milliseconds. Blur can be used if you want to wait till the user tabs away from the field.
                phx-throttle

            Normally when ever a user types ANYTHING into the fields it will start to send events to the server with the validate field. In this case the user will see an error while trying to type in their email and show that it is not valid until the entire email has been entered. This is too aggressive you might want to enable some debounce. 

            Let's add that to the email field now.
                <.input field={@form[:email]} type="email" label="Email" phx-debounce="blur" 

    Live Uploads
        Let's focus on how to add in the ability to upload to the server. 

        This will be the process that we go through here:
            Add an upload feature to the products
            Add a LiveView to see the upload progress
        
        To do this we will need to take these steps:
            • Extend our database and schema for Products to allow images in a new migration and in our Product schema.
            • Establish an upload configuration for images in our ProductLive.Form LiveView socket in the mount/3 function.
            • Render the components to allow uploads, track upload progress, and handle upload errors in the ProductLive.Form render/1 function.
            • Handle the new uploaded form data when the user saves.

        Persist Product Images
            Let's start at the top. Remember that we will need to leverage the generator to create a new migration. But in this case it will be one that appends an existing table so it will require a different syntax. Let's start with the generator
                mix ecto.gen.migration add_image_to_products

            This will create the migration file that we can run once we have the right code inside head to pento/priv/repo/migrations/..._add_image_to_products.exs then add this
                defmodule Pento.Repo.Migrations.AddImageToProducts do
                    use Ecto.Migration

                    def change do
                        alter table(:products) do
                        add :image_upload, :string
                        end
                    end
                end

            Now let's run the migration.
                mix ecto.migrate
                
            Now we need to add the fields to the product schema and be sure that we cast the :image_upload to the changeset. pento/lib/pento/catalog/product.ex
                @doc false
                def changeset(product, attrs, user_scope) do
                    product
                    |> cast(attrs, [:name, :description, :unit_price, :sku, :image_upload])
                    |> validate_required([:name, :description, :unit_price, :sku])
                    |> validate_number(:unit_price, greater_than: 0.0)
                    |> unique_constraint(:sku)
                    |> put_change(:user_id, user_scope.user.id)
                end

                schema "products" do
                    field :name, :string
                    field :description, :string
                    field :unit_price, :float
                    field :sku, :integer
                    field :user_id, :id
                    field :image_upload, :string

                    timestamps(type: :utc_datetime)
                end

            That should be in for the migration part of this task. Let's move onto the form for now.

        Enable File Uploads with allow_upload/3
            Normally the socket will keep track of all the information within the @product assignment but for the uploads they should be tracked independently, so we will use the @uploads key. There is also going to be a need to set the form to know that uploads will be used so we will have to use allow_upload/3 this should be done within the mount to make sure that the @uploads is available and that the page knows. pento/lib/pento_web/live/product_live/form.ex
                def mount(params, _session, socket) do
                    {:ok,
                        socket
                        |> assign(:return_to, return_to(params["return_to"]))
                        |> allow_upload(:image,
                            accept: ~w(.jpg .jpeg .png .gif),
                            max_entries: 1,
                            max_file_size: 9_000_000,
                            auto_upload: true
                        )
                        |> apply_action(socket.assigns.live_action, params)}
                end

            For the rest of the events we have created a private function that will allows us to use this syntax to update or assign the form
                # {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
                defp assign_form(socket, changeset) do
                    assign(socket, :form, to_form(changeset))
                end

            We could still use the old functionality but as it will be used a lot we will want to type less. If we were to look at the socket assign we might see something like this:
                %{
                    # ...
                    uploads: %{
                        __phoenix_refs_to_names__: %{"phx-FlZ_j-hPIdCQuQGG" => :image},
                        image: #Phoenix.LiveView.UploadConfig<
                            accept: ".jpg,.jpeg,.png",
                            auto_upload?: true,
                            entries: [],
                            errors: [],
                            max_entries: 1,
                            max_file_size: 9000000,
                            name: :image,
                            ...
                       >
                    },
                    product: %Pento.Catalog.Product{...},
                    ...
                }

            Lot's to pull from this but make sure that you know about, :image (the key that we pass in allow_upload), auto_upload (true), entries (we will go into this in a second), and the rest of the values that we have set within allow_upload/3

            Looking at entries we will see something like this when we upload a file.
                %Phoenix.LiveView.UploadEntry{
                    progress: 66,
                    preflighted?: true,
                    upload_config: :image,
                    upload_ref: "phx-FzvII24GtrtIvQLm",
                    ref: "1",
                    uuid: "f6781ab0-f062-4a89-b441-f5861544d2b5",
                    valid?: true,
                    done?: false,
                    cancelled?: false,
                    client_name: "tic-tac-toe.jpg",
                    client_type: "image/jpeg",
                    client_last_modified: 1655835404000
                }

            The errors will be populated with the errors so that we can show them to the user.

        Render the File Upload field
            In order to make sure that a user can upload a file we need to give them a field to do so. In that case we will use the live_file_input/2 while still in form let's head to the render portion of the code. This should be within the .form section.
                <div
                    phx-drop-target={@uploads.image.ref}
                    class="border-2 border-dashed border-gray-300 rounded-lg p-6"
                    >
                    <label for={@uploads.image.ref} class="block text-sm font-medium">
                        Product Image
                    </label>
                    <.live_file_input upload={@uploads.image} />
                </div>

            This is a lot in one set of code:
                Adds the drag and drop to the container where the image.ref is the target
                
                <.label> and <.live_file_input> This is an other core_component that we are leveraging. This will also allow us to see the progress with the @uploads.image.entries and the @uploads.image.errors. 
            
            Head to the products/new to check it out. IF you were to inspect the element you would see something like this.
                    <input
                        id="phx-FzvNu1Y0VXwmYgek"
                        type="file"
                        name="image"
                        accept=".jpg,.jpeg,.png"
                        data-phx-hook="Phoenix.LiveFileUpload"
                        data-phx-update="ignore"
                        data-phx-upload-ref="phx-FzvNu1Y0VXwmYgek"
                        data-phx-active-refs=""
                        data-phx-done-refs="" data-phx-preflighted-refs="">

        Present Upload Progress, Previews, and Errors
            So much of the upload is handled automatically but we still need to be able to deal with the progress and the errors with some helper functions. Let's drop the new html below the .simple_form (.form) so that it is not part of the form. 
                <div :for={entry <- @uploads.image.entries}>
                    <div class="bg-blue-100 border border-blue-400 text-blue-700
                px-4 py-3 rounded">
                    {entry.client_name} - {entry.progress}%
                    </div>
                </div>

            This will iterate over the entries part of the form and allow us to see the uploads progress for all the files, in our case there should be only one but we can allow for more than 1 file. We can also add in an image preview with the .live_image_preview
                <div class="mt-2">
                    <.live_img_preview entry={entry} class="max-w-sm max-h-60 rounded"/>
                </div>

             Now let's add in the errors so we can see those if needs be this should be withing the for loop for the @uploads.images.entries. There is a lot here so know that you need to add it all.
                <div
                    :for={err <- upload_errors(@uploads.image)}
                    class="bg-red-100 border border-red-400 text-red-700
                px-4 py-3 rounded"
                    >
                    Upload error: {upload_error_to_string(err)}
                    </div>
                    <div :for={entry <- @uploads.image.entries}>
                    <div
                        :for={_err <- upload_errors(@uploads.image, entry)}
                        class="bg-red-100 border border-red-400 text-red-700
                px-4 py-3 rounded"
                    >
                        {upload_image_error(@uploads, entry)}
                    </div>
                    </div>
                </div>

                # This utilized the upload_image_error function that needs to be added as well.
                def upload_image_error(%{image: %{errors: errors}}, entry)
                    when length(errors) > 0 do
                    {_, msg} =
                    Enum.find(errors, fn {ref, _} ->
                        ref == entry.ref || ref == entry.upload_ref
                    end)

                    upload_error_msg(msg)
                end

                def upload_image_error(_, _), do: ""

                defp upload_error_msg(:not_accepted) do
                    "Invalid file type"
                end

                defp upload_error_msg(:too_many_files) do
                    "Too many files"
                end

                defp upload_error_msg(:too_large) do
                    "File exceeds max size"
                end

                defp upload_error_to_string(:too_large), do: "Too large"

                defp upload_error_to_string(:not_accepted),
                    do: "You have selected an unacceptable file type"

                defp upload_error_to_string(:too_many_files),
                    do: "You have selected too many files"

        Save the Image
            Keep in mind that the product and the image are stored in different parts of the socket and right now we are using just the @product to create the changeset for the database. 

            First let's create a function that will consume all the new uploaded images and save them and then return a list of product parameters including the upload path to the user.
                def params_with_image(socket, params) do
                    path =
                        socket
                        |> consume_uploaded_entries(:image, &upload_static_file/2)
                        |> List.first()

                    Map.put(params, "image_upload", path)
                end

            The consume_uploaded_entries will take the :image part of the socket and run the upload_static_file on them. In this case we know that only one file will be added so we can just grab the first. 

            Now we need to write the custom callback function.
                defp upload_static_file(%{path: path}, _entry) do
                    # Plug in your production image file persistence implementation here!
                    filename = Path.basename(path)
                    dest = Path.join("priv/static/images", filename)
                    File.cp!(path, dest)

                    {:ok, ~p"/images/#{filename}"}
                end

            Quick thing here for our application we need to have the directory ready to go so run
                # the generator might have done this but be sure
                mkdir priv/static/images 

            Also this is where you might want to have some sort of logic for a cloud storage so think about how you would go about that.

            Let's now add the code to the save and edit events.
                def handle_event("save", %{"product" => product_params}, socket) do
                    product_params = params_with_image(socket, product_params)
                    save_product(socket, socket.assigns.live_action, product_params)
                end

                defp save_product(socket, :edit, product_params) do
                    case Catalog.update_product(
                        socket.assigns.current_scope,
                        socket.assigns.product,
                        product_params
                        ) do
                    {:ok, product} ->
                        {:noreply,
                        socket
                        |> put_flash(:info, "Product updated successfully")
                        |> push_navigate(
                        to: return_path(socket.assigns.current_scope, socket.assigns.return_to, product)
                        )}

                    {:error, %Ecto.Changeset{} = changeset} ->
                        {:noreply, assign_form(socket, changeset)}
                    end
                end

                defp save_product(socket, :new, product_params) do
                    case Catalog.create_product(
                        socket.assigns.current_scope,
                        product_params
                        ) do
                    {:ok, product} ->
                        {:noreply,
                        socket
                        |> put_flash(:info, "Product created successfully")
                        |> push_navigate(
                        to: return_path(socket.assigns.current_scope, socket.assigns.return_to, product)
                        )}

                    {:error, %Ecto.Changeset{} = changeset} ->
                        {:noreply, assign_form(socket, changeset)}
                    end
                end

            It's the same code, we added in the assign_form though. What we really did was take the handle_event for save and make sure that we add in the correct values for the upload image as well. That is the wonder of spreading out the work into different functions.

        Display Image Uploads
            Head to pento/lib/pento_web/live/product_live/show.ex add this to the last part of the render
                <div>
                    <img
                        alt="product image" width="200"
                        src={@product.image_upload}
                    >
                </div>

            We Did IT!!!!1