Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Forms and Validation

livebooks/forms-and-validation.livemd

Forms and Validation

notebook_path = __ENV__.file |> String.split("#") |> hd()

Mix.install(
  [
    {:kino_live_view_native, github: "liveview-native/kino_live_view_native"},
    {:ecto, "~> 3.11"},
    {:phoenix_ecto, "~> 4.5"}
  ],
  config: [
    server: [
      {ServerWeb.Endpoint,
       [
         server: true,
         url: [host: "localhost"],
         adapter: Phoenix.Endpoint.Cowboy2Adapter,
         render_errors: [
           formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON],
           layout: false
         ],
         pubsub_server: Server.PubSub,
         live_view: [signing_salt: "JSgdVVL6"],
         http: [ip: {0, 0, 0, 0}, port: 4000],
         check_origin: false,
         secret_key_base: String.duplicate("a", 64),
         live_reload: [
           patterns: [
             ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$",
             ~r/#{notebook_path}$/
           ]
         ]
       ]}
    ],
    kino: [
      group_leader: Process.group_leader()
    ],
    phoenix: [
      template_engines: [neex: LiveViewNative.Engine]
    ],
    phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]],
    mime: [
      types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]}
    ],
    live_view_native: [plugins: [LiveViewNative.SwiftUI]],
    live_view_native_stylesheet: [
      attribute_parsers: [
        style: [
          livemd: &Server.AttributeParsers.Style.parse/2
        ]
      ],
      content: [
        swiftui: [
          "lib/**/*swiftui*",
          notebook_path
        ]
      ],
      pretty: true,
      output: "priv/static/assets"
    ],
    # Ensures that app.js compiles to avoid the switch to longpolling
    # when a LiveView doesn't exist yet
    esbuild: [
      version: "0.17.11",
      server_web: [
        args:
          ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
        cd: Path.expand("../assets", __DIR__),
        env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
      ]
    ]
  ],
  force: true
)

Overview

The LiveView Native Live Form project makes it easier to build forms in LiveView Native. This project enables you to group different Control Views inside of a LiveForm and control them collectively under a single phx-change or phx-submit event handler, rather than with multiple different phx-change event handlers.

Getting the most out of this material requires some understanding of the Ecto project and in particular a reasonably deep understanding of Ecto.Changeset. Review the Ecto documentation if you find any of the examples difficult to follow.

Installing LiveView Native Live Form

To install LiveView Native Form, we need to add the liveview-native-live-form SwiftUI package to our iOS application.

Follow the LiveView Native Form Installation Guide on that project’s README and come back to this guide after you have finished the installation process.

Creating a Basic Form

The LiveView Native mix lvn.install task generates a core_components.swiftui.ex file for native SwiftUI function components similar to the core_components.ex file generated in a traditional phoenix application for web function components.

See Phoenix’s Components and HEEx HexDoc documentation if you need a primer on function components.

In the core_components.swiftui.ex file there’s a simple_form/1 component that is a similar abstraction to the simple_form/1 component found in core_components.ex.

First, we’ll see how to use this abstraction at a basic level, then later we’ll dive deeper into how forms work under the hood in LiveView Native.

A Basic Form

The code below demonstrates a basic form that uses the same event handlers for the phx-change and phx-submit events on both the web and native versions of the form.

We’ll break down and understand the individual parts of this form in a moment.

For now, evaluate the following example. Open the native form in your simulator, and open the web form on http://localhost:4000/. Enter some text into both forms, then submit them. Watch the logs in the cell below to see the printed params.

require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate">
      <.input field={@form[:value]} type="TextField" placeholder="Enter a value" />
      <:actions>
        <.button type="submit">
          Ping
        
      
    
    """
  end
end

defmodule ServerWeb.ExampleLive do
  use ServerWeb, :live_view
  use ServerNative, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(%{}, as: "my_form"))}
  end

  @impl true
  def render(assigns) do
    ~H"""
      <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate">
      <.input field={@form[:value]} placeholder="Enter a value" />
      <:actions>
        <.button type="submit">
          Ping
        
      
    
    """
  end

  @impl true
  def handle_event("submit", params, socket) do
    IO.inspect(params, label: "Submitted")
    {:noreply, socket}
  end

  @impl true
  def handle_event("validate", params, socket) do
    IO.inspect(params, label: "Validating")
    {:noreply, socket}
  end
end
|> Server.SmartCells.LiveViewNative.register("/")

import Server.Livebook, only: []
import Kernel
:ok

After submitting both forms, notice that both the web and native params are the same shape:%{"my_form" => %{"value" => "some text"}}. This makes it easier to share event handlers for both web and native.

Sharing event handlers hugely simplifies and speeds up the process of writing web and native application logic because you only have to write the logic once. Alternatively, if your web and native UI deviates significantly, you can also separate the event handlers.

Breaking down a Basic Form

Simple Form

The interface for the native simple_form/1 and web simple_form/1 is intentionally identical.

<.simple_form for={@form} id="form" phx-submit="submit">

We’ll go into the internal implementation details later on, but for now you can treat these components as functionally identical. Both require a unique id and accept the for attribute that contains the Phoenix.HTML.Form datastructure containing form fields, error messages, and other form data.

If you need a refresher on forms in Phoenix, see the Form Bindings HexDoc documentation.

Inputs

Both web and native core components define a input/1 function component. Inputs in the web form and native form differ since one is an abstraction on top of HTML elements and the other is an abstraction on top of SwiftUI Views. Therefore, they have different values for the type attribte that determines which input type to render.

On web, the input/1 component accepts the following values for the type attribute. These reflect html input types.

  attr :type, :string,
    default: "text",
    values: ~w(checkbox color date datetime-local email file hidden month number password
               range radio search select tel text textarea time url week)

On native, the input/1 component accepts the following values for the type attribute. These reflect the SwiftUI Views from the Controls and Indicators and Text Input and Outputs sections.

attr :type, :string,
  default: "TextField",
  values: ~w(TextFieldLink DatePicker MultiDatePicker Picker SecureField Slider Stepper TextEditor TextField Toggle hidden)

Changesets

The Phoenix.Component.to_form/2 function also supports Ecto changesets for form data and error validation. See Ecto.Changeset for a refresher on changesets. Also see Form Bindings and Phoenix.HTML.Form for a refresher on Phoenix Forms.

We’ll use the following changeset to demonstrate how to validate data in a LiveView Native Live Form.

defmodule User do
  import Ecto.Changeset
  defstruct [:email]
  @types %{email: :string}

  def changeset(user, params) do
    {user, @types}
    |> cast(params, [:email])
    |> validate_required([:email])
    |> validate_format(:email, ~r/@/)
  end
end

The Phoenix.HTML.Form struct stores the changeset. The simple_form/1 and input/1 components for both web and native use the Phoenix.HTML.Form struct and nested Phoenix.HTML.FormField structs to render form data and display errors.

For example, :action field in the changeset determines if errors should display or not. Here’s an example we’ll use in a moment of faking a database :insert action and storing the changeset information inside of a form.

User.changeset(%User{}, %{email: "test"})
|> Map.put(:action, :insert)
|> Phoenix.Component.to_form()

Here’s an example of how we can use Ecto changesets with the LiveView Native Live Form. Now when we submit or validate the form data we apply the changes to the changeset and store the new version of the form in the socket. The simple_form/1 and input/1 components use the form data to render content and display errors.

Evaluate the cell below and open your iOS application. Submit the form with an invalid email. You should notice a has invalid format error appear.

require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate">
      <.input field={@form[:email]} type="TextField" placeholder="Email" />
      <:actions>
        <.button type="submit">
          Submit
        
      
    
    """
  end
end

defmodule ServerWeb.ExampleLive do
  use ServerWeb, :live_view
  use ServerNative, :live_view

  @impl true
  def mount(_params, _session, socket) do
    changeset = User.changeset(%User{}, %{})
    {:ok, assign(socket, form: to_form(changeset))}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate">
      <.input field={@form[:email]} placeholder="Email" />
      <:actions>
        <.button type="submit">
          Submit
        
      
    
    """
  end

  @impl true
  def handle_event("submit", %{"user" => params}, socket) do
    changeset =
      User.changeset(%User{}, params)
      # Faking a Database insert action
      |> Map.put(:action, :insert)
      |> IO.inspect(label: "Form Field Values")

    {:noreply, assign(socket, form: to_form(changeset))}
  end

  @impl true
  def handle_event("validate", %{"user" => params}, socket) do
    changeset =
      User.changeset(%User{}, params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, form: to_form(changeset))}
  end
end
|> Server.SmartCells.LiveViewNative.register("/")

import Server.Livebook, only: []
import Kernel
:ok

Keyboard Types

The keyboardType modifier changes the type of keyboard for a TextField view.

Evaluate the example below to see the different keyboards as you focus on each input. If you don’t see the keyboard, go to I/O -> Keyboard -> Toggle Software Keyboard to enable the software keyboard in your simulator.

require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <.simple_form for={@form} id="form">
      <.input field={@form[:number_pad]} type="TextField" style="keyboardType(.numberPad)"/>
      <.input field={@form[:email_address]} type="TextField" style="keyboardType(.emailAddress)"/>
      <.input field={@form[:phonePad]} type="TextField" style="keyboardType(.phonePad)"/>
      <:actions>
        <.button type="submit">
          Submit
        
      
    
    """
  end
end
|> Server.SmartCells.RenderComponent.register()

import Server.Livebook, only: []
import Kernel
:ok

For a complete list of accepted keyboard types, see the UIKeyboardType documentation.

Core Components

Setting up a LiveView Native application using the generators creates a core_components.swiftui.ex file. If you have the liveview-native-live-form dependency, this file includes function components for building forms.

To better understand how to work with each core component, refer to the core_components.swiftui.ex file generated in a Phoenix LiveView Native project. For the core components used in this Livebook, refer to the core_components.swiftui.ex from the Kino LiveView Native project.

We’ve already been using the two main functions, simple_form/1 and input/1. These are abstractions on top of the native SwiftUI views and some custom views defined by the LiveView Native Live Form library.

in this section, we’ll dive deeper into these abstractions so that you can build your own custom forms.

Simple Form

Here’s the simple_form/1 definition.

  attr :for, :any, required: true, doc: "the datastructure for the form"
  attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"

  attr :rest, :global,
    include: ~w(autocomplete name rel action enctype method novalidate target multipart),
    doc: "the arbitrary HTML attributes to apply to the form tag"

  slot :inner_block, required: true
  slot :actions, doc: "the slot for form actions, such as a submit button"

  def simple_form(assigns) do
    ~LVN"""
    <.form :let={f} for={@for} as={@as} {@rest}>
      
        <%= render_slot(@inner_block, f) %>
        
          <%= for action <- @actions do %>
            <%= render_slot(action, f) %>
          <% end %>
        
      
    
    """
  end

We show this to highlight the similarity between this form, and the one used in core_components.ex.

attr :for, :any, required: true, doc: "the datastructure for the form"
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"

attr :rest, :global,
  include: ~w(autocomplete name rel action enctype method novalidate target multipart),
  doc: "the arbitrary HTML attributes to apply to the form tag"

slot :inner_block, required: true
slot :actions, doc: "the slot for form actions, such as a submit button"

def simple_form(assigns) do
  ~H"""
  <.form :let={f} for={@for} as={@as} {@rest}>
    
      <%= render_slot(@inner_block, f) %>
      
        <%= render_slot(action, f) %>
      
    
  
  """
end

Input

The type attribute on the input/1 component determines which View to render. Here’s the same input/1 definition.

attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any

attr :type, :string,
  default: "TextField",
  values: ~w(TextFieldLink DatePicker MultiDatePicker Picker SecureField Slider Stepper TextEditor TextField Toggle hidden)

attr :field, Phoenix.HTML.FormField,
  doc: "a form field struct retrieved from the form, for example: @form[:email]"

attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"

attr :min, :any, default: nil
attr :max, :any, default: nil

attr :placeholder, :string, default: nil

attr :readonly, :boolean, default: false

attr :autocomplete, :string,
  default: "on",
  values: ~w(on off)

attr :rest, :global,
  include: ~w(disabled step)

slot :inner_block

def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
  # Input Definition
end

The input/1 function then continues to call a separate function definition depending on the type attribute. For example, here’s the "TextField" definition:

def input(%{type: "TextField"} = assigns) do
  ~LVN"""
  
    <%= @placeholder || @label %>
    <.error :for={msg <- @errors}><%= msg %>
  
  """
end

Here’s a list of valid options with links to their documentation:

For more on the form compatible views see the Interactive SwiftUI Views guide.

Core Components vs Views

SwiftUI Core Components attempts to make the API consistent and easy to remember between platforms. For that reason, we deviate somewhat from the interface used by SwiftUI.

Let’s take the Slider view as an example. The Slider view accepts the min and max attributes instead of lowerBound and upperBound because they better reflect the html range slider. The component also accepts the label attribute instead of using children for the same reason.

  def input(%{type: "Slider"} = assigns) do
    ~LVN"""
    
      
        <%= @label %>
        <%= @label %>
      
      <.error :for={msg <- @errors}><%= msg %>
    
    """
  end

Labels with Form Data

Sometimes you may wish to use data within the form separately as part of your UI. For example, let’s say we want to have a Stepper view with a dynamic label based on the current step value. In these cases, you can access form data through the @form.params.

Here’s an example showing how to have a dynamic label based on the Stepper view’s current value. Evaluate the example below and run it in your simulator.

require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate">
      <.input 
        field={@form[:value]} 
        type="Stepper"
        label={"Value: #{@form.params["value"]}"}
      />
      <:actions>
        <.button type="submit">
          Ping
        
      
    
    """
  end
end

defmodule ServerWeb.ExampleLive do
  use ServerWeb, :live_view
  use ServerNative, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(%{"value" => 0}, as: "my_form"))}
  end

  @impl true
  def render(assigns), do: ~H""

  @impl true
  def handle_event("submit", %{"my_form" => params}, socket) do
    IO.inspect(params, label: "PARAMS")
    {:noreply, assign(socket, form: to_form(params, as: "my_form"))}
  end

  @impl true
  def handle_event("validate", %{"my_form" => params}, socket) do
    {:noreply, assign(socket, form: to_form(params, as: "my_form"))}
  end
end
|> Server.SmartCells.LiveViewNative.register("/")

import Server.Livebook, only: []
import Kernel
:ok

Your Turn

Create a form that has TextField, Slider, Toggle, and DatePicker fields.

Example Solution

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate">
      <.input field={@form[:text]} type="TextField" placeholder="Enter a value" />
      <.input field={@form[:slider]} type="Slider"/>
      <.input field={@form[:toggle]} type="Toggle"/>
      <.input field={@form[:date_picker]} type="DatePicker"/>
      <:actions>
        <.button type="submit">
          Ping
        
      
    
    """
  end
end

defmodule ServerWeb.ExampleLive do
  use ServerWeb, :live_view
  use ServerNative, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(%{}, as: "my_form"))}
  end

  @impl true
  def render(assigns), do: ""

  @impl true
  def handle_event("submit", params, socket) do
    IO.inspect(params, label: "Submitted")
    {:noreply, socket}
  end

  @impl true
  def handle_event("validate", params, socket) do
    IO.inspect(params, label: "Validating")
    {:noreply, socket}
  end
end
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate">
      
      <:actions>
        <.button type="submit">
          Ping
        
      
    
    """
  end
end

defmodule ServerWeb.ExampleLive do
  use ServerWeb, :live_view
  use ServerNative, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(%{}, as: "my_form"))}
  end

  @impl true
  def render(assigns), do: ~H""

  @impl true
  def handle_event("submit", params, socket) do
    IO.inspect(params, label: "Submitted")
    {:noreply, socket}
  end

  @impl true
  def handle_event("validate", params, socket) do
    IO.inspect(params, label: "Validating")
    {:noreply, socket}
  end
end
|> Server.SmartCells.LiveViewNative.register("/")

import Server.Livebook, only: []
import Kernel
:ok

Native Views

The LiveView Native LiveForm library defines a few custom SwiftUI views such as LiveForm and LiveSubmitButton. Several core components use these components.

Typically, you won’t need to use these views directly and will instead rely upon the core components directly.

Mini Project: User Form

Taking everything you’ve learned, you’re going to create a more complex user form with data validation and error displaying.

User Changeset

First, create a CustomUser changeset below that handles data validation.

Requirements

  • A user should have a name field
  • A user should have a password string field of 10 or more characters. Note that for simplicity we are not hashing the password or following real security practices since our pretend application doesn’t have a database. In real-world apps passwords should never be stored as a simple string, they should be encrypted.
  • A user should have an age number field greater than 0 and less than 200.
  • A user should have an email field which matches an email format (including @ is sufficient).
  • A user should have a accepted_terms field which must be true.
  • A user should have a birthdate field which is a date.
  • All fields should be required

Example Solution

defmodule CustomUser do
  import Ecto.Changeset
  defstruct [:name, :password, :age, :email, :accepted_terms, :birthdate]

  @types %{
    name: :string,
    password: :string,
    age: :integer,
    email: :string,
    accepted_terms: :boolean,
    birthdate: :date
  }

  def changeset(user, params) do
    {user, @types}
    |> cast(params, Map.keys(@types))
    |> validate_required(Map.keys(@types))
    |> validate_format(:email, ~r/@/)
    |> validate_length(:password, min: 10)
    |> validate_number(:age, greater_than: 0, less_than: 200)
    |> validate_acceptance(:accepted_terms)
  end
end
defmodule CustomUser do
  # define the struct keys
  defstruct []

  # define the types
  @types %{}

  def changeset(user, params) do
    # Enter your solution
  end
end

LiveView

Next, create a Live View that lets the user enter their information and displays errors for invalid information.

Requirements

  • The name field should be a TextField.
  • The email field should be a TextField.
  • The password field should be a SecureField.
  • The age field should be a TextField with a .numberPad keyboard or a Slider.
  • The accepted_terms field should be a Toggle.
  • The birthdate field should be a DatePicker.

Example Solution

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    
    <.simple_form for={@form} id="form" phx-submit="submit" phx-change="validate">
      <.input field={@form[:name]} type="TextField" placeholder="name" />
      <.input field={@form[:email]} type="TextField" placeholder="email" />
      <.input field={@form[:password]} type="SecureField" placeholder="password" />
      <.input field={@form[:age]} type="TextField" placeholder="age" style="keyboardType(.numberPad)" />
      <.input field={@form[:accepted_terms]} type="Toggle"/>
      <.input field={@form[:birthdate]} type="DatePicker"/>
      
      <:actions>
        <.button type="submit">
          Submit
        
      
    
    """
  end
end

defmodule ServerWeb.ExampleLive do
  use ServerWeb, :live_view
  use ServerNative, :live_view

  @impl true
  def mount(_params, _session, socket) do
    changeset = User.changeset(%CustomUser{}, %{})
    {:ok, assign(socket, form: to_form(changeset, as: :user))}
  end

  @impl true
  def render(assigns), do: ~H""

  @impl true
  def handle_event("submit", %{"user" => params}, socket) do
    changeset =
      CustomUser.changeset(%CustomUser{}, params)
      # Faking a Database insert action
      |> Map.put(:action, :insert)
      |> IO.inspect(label: "Form Field Values")

    {:noreply, assign(socket, form: to_form(changeset, as: :user))}
  end

  @impl true
  def handle_event("validate", %{"user" => params}, socket) do
    IO.inspect(params)
    changeset =
      CustomUser.changeset(%CustomUser{}, params)
      |> Map.put(:action, :validate)
      |> IO.inspect()

    {:noreply, assign(socket, form: to_form(changeset, as: :user))}
  end
end
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    
    """
  end
end

defmodule ServerWeb.ExampleLive do
  use ServerWeb, :live_view
  use ServerNative, :live_view

  @impl true
  def mount(_params, _session, socket) do
    # Remember to assign the form
    {:ok, socket}
  end

  @impl true
  def render(assigns), do: ~H""

  # Event handlers for form validation and submission go here
end
|> Server.SmartCells.LiveViewNative.register("/")

import Server.Livebook, only: []
import Kernel
:ok