Upload archivos en LiveView
- 4 mins read
Introducción
Crearemos un formulario LiveView que nos permita subir un archivo JSON.
Dicho archivo contiene algunos datos que se usarán para rellenar un formulario con valores por defecto.
Creación del proyecto
Creamos el proyecto de la siguiente forma
mix phx.new demo_upload --install --no-ecto --no-gettext --no-mailer
Entramos al proyecto y lo echamos a correr:
cd demo_upload
mix phx.server
Antes de nada, creamos el directorio live, que no se crea automáticamente:
mkdir lib/demo_upload_web/live
Y ahora creamos el archivo lib/demo_upload_web/live/upload_live.ex:
defmodule DemoUploadWeb.UploadLive do
use DemoUploadWeb, :live_view
def mount(_params, _session, socket) do
form_data = %{
"domain_name" => "",
"nombre_institucion" => "",
"client_email" => "",
"private_key_id" => ""
}
socket =
socket
|> assign(:form, to_form(form_data))
|> assign(:uploaded_cfg, nil) # Guardamos la referencia al archivo guardado
|> allow_upload(:cfg_json, # Nombre con el que nos vamos a referir al archivo
accept: ~w(.json), # Especificamos la extensión del archivo (No se hacen comprobaciones profundad de tipo)
auto_upload: true, # En cuanto sea seleccionado, empezará a subirse
progress: &handle_progress/3, # Para realmente lograr lo de auto_upload: true, se requiere una función que maneje su progreso
max_entries: 1
)
{:ok, socket}
end
defp consume_file(path) do
case File.read(path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, json_data} -> {:ok, json_data}
error -> error
end
error -> error
end
end
def handle_progress(:cfg_json, entry, socket) do
if entry.done? do
current_data = socket.assigns.form.params
# consume_uploaded_entry consume UNA entrada, justo nuestro caso. La función pasada al último debe devolver
# {:ok, resultado} | {:pending, resultado}
# En nuestro caso, basta con el primer caso, que hará que consume_uploaded_entry devuelva 'resultado'
json_path = consume_uploaded_entry(socket, entry, fn %{path: path} -> {:ok, path} end)
socket =
case json_path && consume_file(json_path) do
{:ok, json_data} ->
updated_data = Map.merge(current_data, json_data)
socket
|> assign(:form, to_form(updated_data))
|> assign(:uploaded_cfg, %{ref: entry.ref, name: entry.client_name})
{:error, reason} ->
put_flash(socket, :error, inspect(reason))
_ ->
put_flash(socket, :error, "No se pudo procesar el archivo")
end
{:noreply, socket}
else
{:noreply, socket}
end
end
def handle_event("validate", %{"form" => form_params}, socket) do
{:noreply, assign(socket, :form, to_form(form_params))}
end
def handle_event("save", %{"form" => _form_params}, socket) do
{:noreply, put_flash(socket, :info, "Formulario procesado correctamente")}
end
def handle_event("cancel-upload", _params, socket) do
# Limpiamos el estado local
{:noreply, assign(socket, :uploaded_cfg, nil)}
end
defp error_to_string(:too_large), do: "Archivo demasiado grande"
defp error_to_string(:too_many_files), do: "Demasiados archivos seleccionados"
defp error_to_string(:not_accepted), do: "Tipo de archivo no permitido"
end
Y creamos su render en el archivo por defecto lib/demo_upload_web/live/upload_live.ex:
<Layouts.app flash={@flash}>
<form id="upload-form" phx-submit="save" phx-change="validate">
<!-- Área de drop target -->
<section
phx-drop-target={@uploads.cfg_json.ref}
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-500 transition-colors duration-200">
<.live_file_input upload={@uploads.cfg_json} class="hidden" />
<!-- Mostrar archivo cargado desde assign -->
<%= if @uploaded_cfg do %>
<div class="space-y-3">
<p class="text-sm text-green-600 font-medium">
✓ Configuración por defecto desde <%= @uploaded_cfg.name %>
</p>
<button
type="button"
phx-click="cancel-upload"
class="inline-flex items-center px-3 py-1 border border-red-300 text-sm font-medium rounded text-red-700 bg-red-50 hover:bg-red-100 transition-colors">
Remover archivo
</button>
</div>
<% else %>
<!-- Estado inicial -->
<div class="space-y-3">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div>
<button type="button"
onclick="this.closest('section').querySelector('input[type=file]').click()"
class="inline-flex items-center px-4 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 transition-colors">
Seleccionar archivo
</button>
<p class="text-sm text-gray-500 mt-2">o arrástralo acá</p>
</div>
<p class="text-xs text-gray-400">Archivo de configuración JSON creado en Google Workspace</p>
</div>
<% end %>
</section>
<!-- Campos del formulario -->
<.input field={@form[:domain_name]} type="text" label="Nombre de dominio" name="form[domain_name]" />
<.input field={@form[:client_email]} type="text" label="Client Email" name="form[client_email]" />
<.input field={@form[:private_key_id]} type="text" label="Private key ID" name="form[private_key_id]" />
<.input field={@form[:nombre_institucion]} type="text" label="Nombre Institución" name="form[nombre_institucion]" />
<button type="submit"
class="mt-4 px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors">
Crear Dominio
</button>
</form>
</Layouts.app>
Agregamos las rutas en lib/demo_upload_web/router.ex
#...
scope "/", DemoUploadWeb do
pipe_through :browser
get "/", PageController, :home
live "/upload", UploadLive
end
# ...
En este punto, podemos acceder a http://localhost:4000/upload/ y revisar el comportamiento de la aplicación.