AF
Ash Frameworkβ€’3y ago
axdc

LiveView uploads with AshPhoenix.Form

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
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
10 Replies
ZachDaniel
ZachDanielβ€’3y ago
πŸ‘‹ So I haven't actually set this up yet, but I think what you want is AshPhoenix.Form.update_form πŸ™‚ actually nvm, this isn't nested Yeah, so prepare_source may actually be your best bet. That or basically hand-rolling this
# 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
# 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
Hand rolling it looks like this and then when its time to submit you'd do submit(..., params: Map.put(params, "wallpaper_url", socket.assigns.wallpaper_url)) 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
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
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
axdcOPβ€’3y ago
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
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
ZachDanielβ€’3y ago
Yeah you probably should assign it to nil on mount That looks pretty reasonable to me πŸ‘ 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. Would need some workshopping/might not be realistic though
axdc
axdcOPβ€’3y ago
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
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
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 😭 that would be fantastic πŸ™‚
ZachDaniel
ZachDanielβ€’3y ago
Some preliminary research tells me that it should be possible but not necessarily easy πŸ™‚ actually....we could use the form path plus field to make it happen what does your consume_uploads function look like?
axdc
axdcOPβ€’3y ago
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
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
ZachDanielβ€’3y ago
Yeah so we'd basically just need to get a callback function that gets a path/entry 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 i.e in graphql it would add something like a "presigned_url" action and in this it would say "hey, gimme a handler for the file", or maybe just do it magically with the extension
Nelson EstevΓ£o
Nelson EstevΓ£oβ€’3mo ago
Hi πŸ‘‹ Is there any established pattern to integrate with S3/local storage etc for file uploads? I am very new to ash but I couldn't find any guides on it πŸ™
ZachDaniel
ZachDanielβ€’3mo ago
Nothing official, I think everyone is rolling their own
Nelson EstevΓ£o
Nelson EstevΓ£oβ€’3mo ago
Thank you πŸ™

Did you find this page helpful?