Combining Live Uploads with Ecto changesets for nested creates/updates

Hi :wave:

I’m trying to marry live uploads and ecto changesets. More in particular I’d like to create a form for creating and updating a container schema with nested sub-schema’s that contain the file contents that were uploaded.

Imagine these two structs:

defmodule MyApp.Container do
  use Ecto.Schema

  schema "containers" do
    field :name, :string
    has_many :files, MyApp.File
  end

  def changeset(container, attrs) do
    container
    |> cast(attrs, [:name])
    |> cast_assoc(:files, required: true)
  end
end

and

defmodule MyApp.File do
  use Ecto.Schema

  schema "files" do
    field :content, :binary
    field :name, :string
    field :container_id, :id
  end

  def changeset(file, attrs) do
    file |> cast(attrs, [:name, :content])
  end
end

I’m not sure how to accumulate the uploaded files, and let the files “flow into” a changeset, together with form parameters from the container. In the end, I’d like to save one Container with multiple Files, and later edit the existing Container and its related Files (upload new ones, and remove already uploaded files), with a minimal amount of database inserts and updates.

What I’ve tried is accumulating both the Container params and the uploaded files on a changeset with cast_assoc/3, like this (inspired by this gist by @LostKobrakai, but with assocs instead of embeds):

  defp handle_progress(:files, entry, socket) do
    socket =
      if entry.done? do
        content =
          consume_uploaded_entry(socket, entry, fn %{path: path} ->
            {:ok, File.read!(path)}
          end)

        update(socket, :changeset, fn changeset ->
          existing = Ecto.Changeset.get_field(changeset, :files, [])
          new_file = %{name: entry.client_name, content: content}

          params = %{
            files:
              Enum.map(
                existing,
                &Map.take(&1, [:id, :name, :content])
              ) ++ [new_file]
          }

          changeset
          |> Ecto.Changeset.cast(params, [])
          |> Ecto.Changeset.cast_assoc(:files)
        end)
      else
        socket
      end

    {:noreply, socket}
  end

In the heex template I use inputs_for/4 to display the files on the changeset. But I can’t make this strategy work. Either the changes of the files are lost, or the changes from the form are gone, no matter how I arrange stuff. As I’ve read before, changesets are just not meant to accumulate state over multiple interactions. They should be built with a set of input params, and be discarded. At least, that’s where I’m at.

How would you structure a LiveView and the related context functions to support these features? Any examples in the wild that I can turn to for inspiration?

2 Likes