LiveView uploads with AshPhoenix.Form
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