LiveView uploads with AshPhoenix.Form

axdc
2023-05-23

axdc:

Hello,

I’ve got a form that allows the user to select from a few example images, or to upload their own and use that.

Right now it’s a set of radio buttons plus a live_file_input. I’ve gotten it ~working but it feels pretty dirty and hacky with some showstopping bugs so I’m looking for prior art and best practices when it comes to the Ash Way to do live uploads.

I’ve searched here and it doesn’t look like there’s a specific upload extension or type yet, but in the meantime, is there a better or recommended way to do what I’m trying to do?

Specifically this:

  defp maybe_put_uploaded_wallpaper_url(socket, form) do
    wallpaper_url = consume_uploads(socket) |> List.first()

    if(wallpaper_url) do
      # a wallpaper was uploaded
      Logger.info("handling uploaded wallpaper")
      form = form |> AshPhoenix.Form.set_data(%{form.data | wallpaper_url: wallpaper_url})
      form = %{form | params: %{form.params | "wallpaper_url" => wallpaper_url}}

      form = %{
        form
        | source: %{
            form.source
            | params: %{form.source.params | "wallpaper_url" => wallpaper_url}
          }
      }

      IO.inspect(form)
    else
      # pass through form unchanged
      form
    end
  end

This feels like sin to me. The liveview file upload examples have you patching params but I don’t have params, I have an AshPhoenix.Form.

One bug example: If I change the image, save, and then change it back to what it originally was, it looks like it persists but actually doesn’t. I suspect this may have something to do with how I’m setting the checked attribute?

my index.ex: https://gitlab.com/avoh-labs/panacea/-/blob/main/lib/panacea_web/live/rosegarden/configuration_live/index.ex

my index.html.heex: https://gitlab.com/avoh-labs/panacea/-/blob/main/lib/panacea_web/live/rosegarden/configuration_live/index.html.heex#L597

Thank you. Posting here is always clarifying. chasing down some more threads now. prepare_source maybe

zachdaniel:

👋 So I haven’t actually set this up yet, but I think what you want is AshPhoenix.Form.update_form 🙂

zachdaniel:

actually nvm, this isn’t nested

zachdaniel:

Yeah, so prepare_source may actually be your best bet.

zachdaniel:

That or basically hand-rolling this

zachdaniel:

  # returns a socket instead of your original form example
  defp maybe_put_uploaded_wallpaper_url(socket, form) do
    wallpaper_url = consume_uploads(socket) |> List.first()
  
    if(wallpaper_url) do
      assign(socket, :wallpaper_url, wallpaper_url)
    else
      socket
    end
  end

zachdaniel:

Hand rolling it looks like this

zachdaniel:

and then when its time to submit you’d do submit(..., params: Map.put(params, "wallpaper_url", socket.assigns.wallpaper_url))

zachdaniel:

but you could also do:

  defp maybe_put_uploaded_wallpaper_url(socket, form) do
    wallpaper_url = consume_uploads(socket) |> List.first()
  
    if(wallpaper_url) do
      AshPhoenix.Form.prepare_source(form, fn changeset -> 
        Ash.Changeset.change_attribute(changeset, :wallpaper_url, wallpaper)url)
      end)
    else
      form
    end
  end

zachdaniel:

the first one is probably better in this case (or perhaps a combination of the two) so that you can use that wallpaper_url assign to manage the UI state (i.e the upload is complete and this is the url)

axdc:

EDIT: I’m getting ** (KeyError) key :wallpaper_url not found in: %{__changed__: %{}, ... when I don’t change the wallpaper, so I think I might have to assign that to something at mount? But when I upload it persists now, fixed a bug with using an atom instead of a string as a key

   def handle_event("save", %{"form" => params}, socket) do
    form = AshPhoenix.Form.validate(socket.assigns.form, params)

    socket = socket |> maybe_put_uploaded_wallpaper_url()

    params = Map.put(params, "wallpaper_url", socket.assigns.wallpaper_url)
    IO.inspect(params)

    case AshPhoenix.Form.submit(form,
           params: params
         ) do
      {:ok, configuration} ->
        {:noreply,
         socket
         |> assign(form: form)
         |> assign(configuration: configuration)
         |> put_flash(:info, "Settings updated successfully")
         |> push_patch(to: ~p"/rosegarden/configuration")}

      {:error, form} ->
        Logger.error(AshPhoenix.Form.errors(form, for_path: :all))

        {:noreply, assign(socket, form: form)}
    end
  end

 defp maybe_put_uploaded_wallpaper_url(socket) do
    wallpaper_url = consume_uploads(socket) |> List.first()

    if(wallpaper_url) do
      # a wallpaper was uploaded
      Logger.info("handling uploaded wallpaper")
      assign(socket, :wallpaper_url, wallpaper_url)
    else
      # pass through form unchanged
      socket
    end
  end

zachdaniel:

Yeah you probably should assign it to nil on mount

zachdaniel:

That looks pretty reasonable to me 👍

zachdaniel:

At some point I’d like to support file uploads natively in AshPhoenix.Form so you can just say which params are file uploads or something along those lines.

zachdaniel:

Would need some workshopping/might not be realistic though

axdc:

okay this feels like a breakthrough, excited rn

is there anything devastatingly busted about this approach? it’s working :>


def handle_event("save", %{"form" => params}, socket) do
    form = AshPhoenix.Form.validate(socket.assigns.form, params)

    {socket, params} = maybe_put_uploaded_wallpaper_url(socket, params)

    IO.inspect(params)

    case AshPhoenix.Form.submit(form,
           params: params
         ) do
      {:ok, configuration} ->
        {:noreply,
         socket
         |> assign(form: form)
         |> assign(configuration: configuration)
         |> put_flash(:info, "Settings updated successfully")
         |> push_patch(to: ~p"/rosegarden/configuration")}

      {:error, form} ->
        Logger.error(AshPhoenix.Form.errors(form, for_path: :all))

        {:noreply, assign(socket, form: form)}
    end
  end


  defp maybe_put_uploaded_wallpaper_url(socket, params) do
    wallpaper_url = consume_uploads(socket) |> List.first()

    if(wallpaper_url) do
      # a wallpaper was uploaded
      Logger.info("handling uploaded wallpaper")
      socket = assign(socket, :wallpaper_url, wallpaper_url)
      params = Map.put(params, "wallpaper_url", socket.assigns.wallpaper_url)
      {socket, params}
    else
      # pass through unchanged
      {socket, params}
    end
  end

axdc:

still getting used to elixir’s scope and mindset of assigning from private functions, instead of the nested if hell i find myself reaching for first. but this is sooo much cleaner 😭

axdc:

that would be fantastic 🙂

zachdaniel:

Some preliminary research tells me that it should be possible

zachdaniel:

but not necessarily easy 🙂

zachdaniel:

actually….we could use the form path plus field to make it happen

zachdaniel:

what does your consume_uploads function look like?

axdc:

defp consume_uploads(socket) do
    consume_uploaded_entries(socket, :wallpaper_url, fn %{path: path}, _entry ->
      file = File.read!(path)

      object_key = "wallpaper/" <> socket.assigns.current_site.id <> ".jpg"

      case ExAws.S3.put_object(
             "panacea",
             object_key,
             file,
             content_type: "image/jpeg",
             acl: :public_read
           )
           |> ExAws.request() do
        {:ok, _} -> Logger.info("Uploaded file")
        {:error, error} -> Logger.error("Error uploading file: #{IO.inspect(error)}")
      end

      cache_buster = "?" <> (System.os_time() |> Integer.to_string())

      url =
        "https://panacea.sfo3.cdn.digitaloceanspaces.com/" <>
          object_key <> cache_buster

      {:ok, url}
    end)
  end

zachdaniel:

Yeah so we’d basically just need to get a callback function that gets a path/entry

zachdaniel:

Sounds interesting. What I’d really like to do is make a file uploading extension for Ash that each API type can use to do certain things

zachdaniel:

i.e in graphql it would add something like a “presigned_url” action

zachdaniel:

and in this it would say “hey, gimme a handler for the file”, or maybe just do it magically with the extension