Mishka Gervaz Forms: Validation, Nested Fields and File Uploads in Ash

A hands-on guide to Mishka Gervaz forms: put the rules in the Ash action, see errors under each field, edit a list of links as rows and take a cover image upload.

Mishka Gervaz Forms: Validation, Nested Fields and File Uploads in Ash

To build a validated form in Ash, put the rules in the action and describe the form in a mishka_gervaz block. Mishka Gervaz then draws the fields, shows each Ash error under its field, edits a list of maps as rows and takes a file upload. This guide builds one article form with code we ran on Mishka Gervaz 0.0.7.

Time left until the offer ends
114
D
23
H
56
M
07
S
Claim your seat · $250One payment, no subscription. Questions first? Contact us.

Our Mishka Gervaz and Mishka CMS stay free. A founding seat pays for the time we spend on them and on the extensions, and it gives you lifetime access, priority support and every paid extension we release.

You need Elixir 1.20, a Phoenix LiveView app with Ash, and about twenty minutes. The messages and results below are what the released library printed in our test run.

By the end you have a form with a required title, a slug rule, a list of links you can add to and remove from, and a cover image upload, plus a test that proves all three.

Newsletter

Mishka CMS releases. Once a month.

New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.

✓ once a month✓ no spam
0.0.7latest release on Hex
1block holds fields, groups and uploads
2,200+downloads on Hex

The short answer

Gervaz does not invent a second set of rules. It runs your Ash action on every change and shows the action's own errors under the field. Rows and files are field types in the same block.

1 · InstallAdd the depsmishka_gervaz and Ash
2 · RulesWrite the actionvalidate in Ash
3 · FormDescribe fieldsOne mishka_gervaz block
4 · RowsNest a listnested_field columns
5 · FilesAdd an uploadDropzone and a path
Diagram of a Gervaz form: the reader types, the form validates with the Ash action, errors appear under the field and the save runs the same action

One action decides: the same validations run on each change and on the save.

Step 1: install the packages

Add Mishka Gervaz and its Ash and LiveView partners to mix.exs, then run mix deps.get. It needs Elixir 1.20 or newer.

elixir
def deps do
  [
    {:mishka_gervaz, "~> 0.0.7"},
    {:ash, "~> 3.37"},
    {:ash_phoenix, "~> 2.3"},
    {:phoenix_live_view, "~> 1.2"}
  ]
end

A new Ash project also needs config :ash, default_string_length_count: :codepoints in config/config.exs. Ash 3.37 raised an error without it in our run.

Step 2: put the rules in the Ash action

Write validations on the action, not in the form. Gervaz asks Ash for the errors, so the title rule below shows up in the form and guards every other caller of :create.

elixir
defmodule MyApp.Blog.Article do
  use Ash.Resource,
    domain: MyApp.Blog,
    extensions: [MishkaGervaz.Resource],
    data_layer: Ash.DataLayer.Ets

  actions do
    defaults [:read, :destroy]

    create :create do
      primary? true
      accept [:title, :slug, :status, :links]
      argument :cover, {:array, :map}, allow_nil?: true

      validate string_length(:title, min: 5),
        message: "must have at least 5 characters"

      validate match(:slug, ~r/^[a-z0-9-]+$/),
        where: present(:slug),
        message: "may use only lowercase letters, digits and dashes"
    end

    update :update do
      primary? true
      accept [:title, :slug, :status, :links]

      validate string_length(:title, min: 5),
        message: "must have at least 5 characters"
    end
  end

  attributes do
    uuid_primary_key :id
    attribute :title, :string, allow_nil?: false, public?: true
    attribute :slug, :string, public?: true
    attribute :status, :string, public?: true, default: "draft"
    attribute :cover_path, :string, public?: true

    attribute :links, {:array, :map} do
      default []
      public? true

      constraints items: [
                    fields: [
                      label: [type: :string, allow_nil?: false],
                      url: [type: :string, allow_nil?: false]
                    ]
                  ]
    end
  end
end

The :cover argument is where the uploaded file arrives. The links attribute is a list of maps whose constraints say each row needs a label and a URL.

Step 3: describe the form

Add the mishka_gervaz block to the same resource. Fields, groups, the upload and the submit labels live in one place. A form needs create, update and read actions, here named in source.

elixir
mishka_gervaz do
  form do
    identity do
      name :article_form
      route "/articles"
    end

    source do
      actions do
        create {:create, :create}
        update {:update, :update}
        read {:read, :read}
      end
    end

    fields do
      field :title, :text do
        required true
        ui do
          label "Title"
          placeholder "A clear title"
        end
      end

      field :slug, :text do
        ui do
          label "Slug"
          description "Lowercase letters, digits and dashes"
        end
      end

      field :status, :select do
        options [{"Draft", "draft"}, {"Published", "published"}]
      end

      field :links, :nested do
        ui do
          label "Links"
          add_label "+ Add link"
          remove_label "Remove"
        end

        nested_field :label do
          required true
          ui do
            label "Label"
          end
        end

        nested_field :url do
          required true
          ui do
            label "URL"
            placeholder "https://"
          end
        end
      end

      field :cover, :upload do
        ui do
          label "Cover image"
        end
      end
    end

    groups do
      group :basics do
        fields [:title, :slug, :status]
      end

      group :extras do
        fields [:links, :cover]
      end
    end

    uploads do
      upload :cover do
        field :cover
        accept "image/*"
        max_entries 1
        max_file_size 2_000_000
      end
    end

    submit do
      create label: "Save article"
      update label: "Save changes"
    end
  end
end

Every non-virtual field must be an attribute, relationship, calculation or aggregate of the resource, and a group may not name a missing field. A field of type :upload is virtual by itself.

Step 4: mount it on a page

Mount the form as a LiveComponent on any LiveView page. Pass record_id to edit a record. Without it the form creates one.

heex
<.live_component
  module={MishkaGervaz.Form.Web.Live}
  id="article"
  resource={MyApp.Blog.Article}
  current_user={@current_user}
/>

Add the domain and the resource extension as the Build a form guide shows, and the form is live.

Step 5: see the validation

Type a short title and a slug with a space, then press the save button. Each message appears under its own field:

Title *  must have at least 5 characters
Slug     may use only lowercase letters, digits and dashes

Those are the exact messages of the two Ash validations. An empty required title says "is required". Fix the value and the message goes away on the next change.

When errors show

A create form stays quiet while the reader types. In our run a short title sent by a change event showed nothing, and the same title showed its error after the first submit. After that first submit the errors follow every change. An edit form shows them from the first change.

Step 6: edit a list of links as rows

A :nested field turns an array-of-maps attribute into rows. The reader presses "+ Add link", fills a label and a URL, and can remove any row. No change to your database shape.

In the form block above, each nested_field is one column of a row. Mark a column required true and the row reports it:

Links 1   Remove
  Label *   is required
  URL *     is required

Rows are numbered as form[links][0][label], form[links][1][url] and so on. When one row had a label and no URL, only the URL said "is required". In that run Ash's own constraint message "value must not be nil" also appeared under the list, so expect one line of Ash wording there.

A nested column takes a narrower type set: text, textarea, number, checkbox, date, datetime, select, hidden, toggle, range or json. The form fields DSL docs list every option.

Step 7: take a file upload

The uploads block registers a Phoenix LiveView upload and draws a dropzone. The field of the same name in fields keeps the position in the layout. The block above allows one image up to 2,000,000 bytes.

Gervaz scopes the upload name to the form, so several forms on one page do not collide. In the page it is the input named cover_article, the upload name plus the component id.

What the action receives

On save, Gervaz copies each chosen file to the system temp directory as gervaz_<n>_<client name> and gives the action a list of maps under the field's name. Each map has :path, :client_name, :client_type and :client_size. After the submit it deletes those temp files, so read or move the file inside the action.

Diagram of the upload path: choose a file, size check in the dropzone, save, temp copy, action argument, your copy, temp file deleted

The temp copy lives only until the submit ends. Your action keeps its own copy.

Add a change to the :create action that copies the file where your app keeps files:

elixir
      change fn changeset, _context ->
        case Ash.Changeset.get_argument(changeset, :cover) do
          [%{path: path, client_name: name} | _] ->
            dest = Path.join("priv/static/covers", name)
            File.cp!(path, dest)
            Ash.Changeset.force_change_attribute(changeset, :cover_path, dest)

          _ ->
            changeset
        end
      end

In our run the saved record held the new path and the file read back as the bytes we uploaded. A 2.5 MB file was refused in the dropzone with "File is too large" before any save.

Animation of the article form: a short title and a bad slug show two errors, a link row asks for a URL, a large file is refused and a valid form saves

The form in five moments. An illustration drawn in HTML from the messages above, not a screenshot.

Step 8: prove it with a test

Phoenix.LiveViewTest can drive the whole form: submit, add a row, upload a file and read the record. This is the test we ran, and it passed on 0.0.7.

elixir
test "the form validates, takes rows and a file" do
  {:ok, view, _html} = live(build_conn(), "/new")

  view |> form("#article-form", %{"form" => %{"title" => "Hi"}}) |> render_submit()
  # the page now says: Title * must have at least 5 characters

  view |> element(~s|button[phx-click="add_nested"][phx-value-field="links"]|) |> render_click()

  up = file_input(view, "#article-form", :cover_article,
         [%{name: "cover.png", content: "PNGDATA", type: "image/png"}])
  render_upload(up, "cover.png")

  view
  |> form("#article-form", %{"form" => %{
       "title" => "Forms in Ash",
       "slug" => "forms-in-ash",
       "links" => %{"0" => %{"label" => "Docs", "url" => "https://mishka.tools/gervaz/docs"}}
     }})
  |> render_submit()

  assert [%{title: "Forms in Ash", cover_path: path}] = Ash.read!(MyApp.Blog.Article)
  assert File.read!(path) == "PNGDATA"
end

The test needs a PubSub in the endpoint config, or the upload client raises "no :pubsub_server configured".

Four mistakes to avoid

  • Do not put the rule in the form. A rule in the action runs for the form and for every other caller.
  • Do not expect errors while the reader first types in a create form. They arrive with the first submit.
  • Do not read the temp file after the action returns. Gervaz deletes it after the submit.
  • Do not rely on show_preview or dropzone_text. The library's own usage rules say the built-in template compiles them and does not read them, so set the label with ui instead.

Next steps

Start with the Mishka Gervaz page and keep the Mishka Gervaz docs open. Read form steps for a wizard, form and table hooks for code before and after a save, and troubleshooting when a build fails.

To see the whole admin around this form, read Mishka Gervaz: Build an Ash Admin with Tables and Forms. Its tables, filters and forms come from the same DSL.

Mishka Gervaz is free, and a small team builds it. If you want to help us work on it full time, a founding seat gives you lifetime access, priority support and every paid extension we release.

Time left until the offer ends
114
D
23
H
56
M
07
S
Claim your seat · $250One payment, no subscription. Questions first? Contact us.

You can also sponsor our open source work, or hire our team for an Elixir project.

Newsletter

Mishka CMS releases. Once a month.

New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.

✓ once a month✓ no spam

Questions people ask about Mishka Gervaz forms

A declarative UI library for the Ash ecosystem. You describe tables and forms in a DSL on the resource, and Gervaz draws them as Phoenix LiveView components with filters, sorting, validation and uploads.

In your Ash action. Gervaz validates the form with AshPhoenix on each change and shows the action's errors under the field, so a rule written once in the resource also guards every other caller of that action.

A create form stays quiet while the reader types and shows errors after the first submit. After that, and in an edit form, the errors update on every change.

Yes. A nested field over an array of maps draws one row per entry with add and remove buttons. Each nested_field is a column with its own label, and required ones are checked before the save.

Yes. An uploads block registers a Phoenix LiveView upload and draws a dropzone. On save your action receives a list of maps with path, client_name, client_type and client_size, and the temporary files are deleted after the submit, so the action must copy the file first.

Version 0.0.7 needs Elixir 1.20 or newer, Ash 3.37 or newer, AshPhoenix 2.3 or newer and Phoenix LiveView 1.2 or newer.

Thank you

Mishka Gervaz stands on Elixir, Ash Framework, AshPhoenix and Phoenix LiveView. Thank you to everyone who builds them, and to you for reading this far.

Contributors Mishka Group Core
Shahryar Tavakkoli
LEAD
Shahryar Tavakkoli @shahryar-tavakkoli
Elixir · Phoenix · LiveView

Shahryar Tavakkoli writes about Elixir and Phoenix LiveView: Mishka Chelekom releases, GuardedStruct validation, static sites, service workers and SEO.

Comments

Sign in to comment

No comments yet.