Add file uploads to a form

v0.0.1

The uploads section of a Gervaz form adds file upload to a create or edit form. Gervaz registers the upload with Phoenix LiveView, draws a dropzone, copies each chosen file to a temporary path when the form is saved, and hands the Ash action a list of those paths. This guide declares an upload, receives the files in an action and shows existing files in edit mode. The examples come from the media form of Mishka CMS.

Before you begin

  • Have a form section with an identity and the three actions. See Build an admin form.
  • Have a create action with an argument that accepts a list of maps, as the next section shows.

Receive the files in an action

When the reader saves, Gervaz copies each uploaded entry to the system temp directory as gervaz_<n>_<client name> and puts a list of maps in the params under the key of the upload's field, or under the upload's name when it has no field. Each map has :path, :client_name, :client_type and :client_size. After the submit, Gervaz deletes those temp files whose paths start with the temp directory, so the action must read or move a file before it returns.

The media resource declares the argument and a change that processes it:

elixir
create :form_upload do
  description "Creates media from file upload via form for tenant users"
  accept [:description, :featured, :media_category_id]

  argument :media_files, {:array, :map}, allow_nil?: true

  change {MishkaCmsCore.Runtime.Resources.Changes.ProcessFileUpload, []}
  change slugify(:name, into: :name)
end

Declare the upload

Add an uploads section to the form. Each upload has a name. Gervaz joins it with the component id to make the key of the Phoenix allow_upload call.

elixir
uploads do
  upload :media_files do
    accept ~w(.jpg .jpeg .png .gif .webp .svg .mp4 .webm .mov .pdf .doc .docx .xls .xlsx .ppt .pptx .txt .csv)
    max_entries 1
    max_file_size 50_000_000
    style :dropzone

    ui do
      label fn -> dgettext("mishka_gervaz", "Files") end
    end
  end
end
OptionMeaning
acceptA string such as "image/*,.pdf", or a list such as ~w(.jpg .png). Gervaz checks the format when it compiles. Always set it, because LiveView raises for an upload that has no accept.
max_entriesThe most files at once. Default 1.
max_file_sizeThe largest file in bytes. Default 8,000,000.
style:dropzone (default), :file_input for the standard picker, or :custom for a bare live_file_input.
auto_uploadStarts uploading when files are chosen. Default false.
fieldThe form field the upload belongs to, and the params key.
chunk_size, chunk_timeoutBytes per chunk and the chunk timeout in milliseconds.
external, writerexternal takes a function fn entry, socket -> ... end for direct uploads such as S3. The DSL also accepts a module name for either option, but LiveView accepts only a function in external, and a function of three arguments in writer. Treat both as unconfirmed.

Make accept and max_file_size match the validation of the Ash action, because the browser limits are only the first check.

Show existing files in edit mode

existing tells Gervaz how to read the files already stored on a record. It takes a field name, or a function of the record that returns a list of maps:

elixir
existing fn record ->
  if record.relative_path do
    cache_buster =
      if record.updated_at, do: "?v=#{DateTime.to_unix(record.updated_at)}", else: ""

    [
      %{
        filename: record.name,
        id: record.id,
        url: record.relative_path <> cache_buster,
        type: record.type,
        size: record.size,
        format: record.format
      }
    ]
  else
    []
  end
end

Without existing, Gervaz reads the record attribute named by the upload's field, or by its name when it has no field. A missing value shows nothing already uploaded.

Place the upload among the fields

An upload draws in its own section after the fields. To place it among them, declare a field of type :upload with the same name as the upload. Gervaz marks such a field virtual, because it is not an attribute of the resource. The Media resource does not declare this field, so the block below is an illustration:

elixir
fields do
  field :media_files, :upload do
    ui do
      label fn -> dgettext("mishka_gervaz", "Files") end
    end
  end
end

Gervaz reports an upload whose field names a field that the form does not declare when it compiles.

Mount more than one form on a page

Gervaz scopes each upload name with the component id, so media_files in a component with the id media_form is registered as media_files_media_form. Characters other than letters, digits and underscores in the id become underscores. Several forms on one page never collide. Write the plain name in the DSL and let Gervaz scope it. The media upload of the page builder hides the submit row with submit={false}. A form with no submit row of its own can be submitted by something else, such as a dropzone that fires on drop. The mount also hides the site field for a site user and supplies its value:

heex
<.live_component
  module={MishkaGervaz.Form.Web.Live}
  id={MishkaGervaz.Resource.Info.Form.component_id(Media)}
  resource={Media}
  current_user={@current_user}
  hidden_fields={(@editor.site_id && [:site_id]) || []}
  submit={false}
  defaults={%{site_id: @editor.site_id, media_category_id: @assets_category}}
/>

the Media Library page with the upload card at the top, holding the dropzone, the Site and Folder fields, the Featured checkbox and the submit button reading Create, above the folder breadcrumb

The Media Library page mounts the media form, with its one dropzone upload, in a card above the list.

The card holds more than the dropzone. It also has the Site and Folder fields, a Featured checkbox and a submit button that reads Create. The Media Library page mounts the form without submit={false}, so the button stays.

Next steps