Application.put_env(:sample, Example.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 5001],
server: true,
live_view: [signing_salt: "aaaaaaaa"],
secret_key_base: String.duplicate("a", 64)
)
Mix.install([
{:plug_cowboy, "~> 2.5"},
{:jason, "~> 1.0"},
{:phoenix, "~> 1.7"},
{:phoenix_ecto, "~> 4.5.1"},
# please test your issue using the latest version of LV from GitHub!
{:phoenix_live_view, "~> 0.20.14"}
])
# build the LiveView JavaScript assets (this needs mix and npm available in your path!)
path = Phoenix.LiveView.__info__(:compile)[:source] |> Path.dirname() |> Path.join("../")
System.cmd("mix", ["deps.get"], cd: path, into: IO.binstream())
System.cmd("npm", ["install"], cd: Path.join(path, "./assets"), into: IO.binstream())
System.cmd("mix", ["assets.build"], cd: path, into: IO.binstream())
This livebook gives examples on how to use Liveview forms with and without a Changeset.
An important note before we begin from the docs linked below:
- LiveView can better optimize your code if you access the form fields using @form[:field] rather than through the let-variable form
- https://hexdocs.pm/ecto/3.11.2/Ecto.Changeset.html#module-schemaless-changesets
- https://hexdocs.pm/phoenix_live_view/0.20.14/Phoenix.Component.html#to_form/2
- https://hexdocs.pm/phoenix_live_view/0.20.14/Phoenix.Component.html#form/1-a-note-on-errors
- https://hexdocs.pm/phoenix_live_view/0.20.14/form-bindings.html#error-feedback
- https://hexdocs.pm/phoenix_html/4.1.1/Phoenix.HTML.Form.html
- https://hexdocs.pm/phoenix_html/4.1.1/Phoenix.HTML.FormData.html
The CoreComponents file below is included with new phoenix projects once a generator command is used and is why I need to include the code below.
defmodule ExampleWeb.CoreComponents do
@moduledoc """
Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as modals, tables, and
forms. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
The default components use Tailwind CSS, a utility-first CSS framework.
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
how to customize them or feel free to swap in another framework altogether.
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc """
Renders a button.
## Examples
<.button>Send!</.button>
<.button phx-click="go" class="ml-2">Send!</.button>
"""
attr(:type, :string, default: nil)
attr(:class, :string, default: nil)
attr(:rest, :global, include: ~w(disabled form name value))
slot(:inner_block, required: true)
def button(assigns) do
~H"""
<button
type={@type}
class={[
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
"text-sm font-semibold leading-6 text-white active:text-white/80",
@class
]}
{@rest}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr(:id, :any, default: nil)
attr(:name, :any)
attr(:label, :string, default: nil)
attr(:value, :any)
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)
)
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(:rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
)
slot(:inner_block)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(field.errors, &elem(&1, 0)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div phx-feedback-for={@name}>
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
<input type="hidden" name={@name} value="false" />
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
{@rest}
/>
<%= @label %>
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<select
id={@id}
name={@name}
class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value=""><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
</select>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<textarea
id={@id}
name={@name}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
"min-h-[6rem] phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
@doc """
Renders a label.
"""
attr(:for, :string, default: nil)
slot(:inner_block, required: true)
def label(assigns) do
~H"""
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
<%= render_slot(@inner_block) %>
</label>
"""
end
@doc """
Generates a generic error message.
"""
slot(:inner_block, required: true)
def error(assigns) do
~H"""
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600 phx-no-feedback:hidden">
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
<%= render_slot(@inner_block) %>
</p>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles – outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in your `assets/tailwind.config.js`.
## Examples
<.icon name="hero-x-mark-solid" />
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
"""
attr(:name, :string, required: true)
attr(:class, :string, default: nil)
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
end
Next we will walk through using a form with a Changeset.
When using a changeset, it should never be stored into assigns and a new one should be generated on every validation attempt.
Ecto changesets are meant to be single use. By never storing the changeset in the assign, you will be less tempted to use it across operations - Source
timeline
%% Section denotes the lifecycle of Phoenix LiveView
section LiveView Changeset Form Lifecycle
Mount: Initalize HTML.Form struct from Ecto.Changeset, only store the Form struct in assigns.
Validate: Create a new changeset with the form params as attrs, validate and convert back to HTML.Form struct
Submit: Create a new changeset with the form params as attrs and insert.
A more complex example uses nested embeded schema's in a liveview form.
defmodule Tag do
use Ecto.Schema
@primary_key false
embedded_schema do
field(:description, :string)
end
def changeset(struct, params) do
Ecto.Changeset.cast(struct, params, ~w(description)a)
end
end
defmodule Person do
use Ecto.Schema
embedded_schema do
field(:name, :string)
field(:email, :string)
embeds_many(:tags, Tag, on_replace: :delete)
end
@allowed ~w(name email)a
def changeset(struct, params) do
struct
|> Ecto.Changeset.cast(params, @allowed)
|> Ecto.Changeset.cast_embed(:tags,
required: true,
sort_param: :tags_sort,
drop_param: :tags_drop
)
|> Ecto.Changeset.validate_required(@allowed)
end
end
defmodule EmbeddedSchemaLive do
use Phoenix.LiveView, layout: {__MODULE__, :live}
import Phoenix.Component
import ExampleWeb.CoreComponents
@form_as :person
def mount(_params, _session, socket) do
default_form_values = %{"name" => "", "email" => "", "tags" => []}
form = validate_params(default_form_values) |> to_form(as: @form_as)
addl_assigns = %{
form: form,
form_as: @form_as
}
{:ok, assign(socket, addl_assigns)}
end
defp validate_params(params) do
Person.changeset(%Person{}, params)
end
def handle_event("validate", %{"person" => params}, socket) do
form =
validate_params(params)
|> to_form(as: @form_as, action: :validate)
{:noreply, assign(socket, :form, form)}
end
def handle_event("submit", %{"person" => params}, socket) do
case validate_params(params) do
%{valid?: true} ->
{:noreply, Phoenix.LiveView.put_flash(socket, :info, "EmbeddedSchema form submitted.")}
changeset ->
form = to_form(changeset, action: :validate)
{:noreply, assign(socket, :form, form)}
end
end
def render(assigns) do
~H"""
<h2>Embedded Schema</h2>
<p id="info-embedded" class="text-base text-lime-700"><%= Phoenix.Flash.get(@flash, :info) %></p>
<.form for={@form} id="embedded-schema-form" phx-change="validate" phx-submit="submit" as={@form_as}>
<.input label="name" type="text" field={@form[:name]} />
<.input label="email" type="email" field={@form[:email]} />
<.inputs_for :let={f_nested} field={@form[:tags]} >
<div>
<input type="checkbox" class="hidden" name={"#{@form_as}[tags_sort][]"} value={f_nested.index} />
<.input type="text" field={f_nested[:description]} label={"Tag Description #{f_nested.index}"} />
<button
name={"#{@form_as}[tags_drop][]"}
value={f_nested.index}
phx-click={Phoenix.LiveView.JS.dispatch("change")}
class="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded text-sm absolute top-0 right-0"
>
X
</button>
</div>
</.inputs_for>
<input type="checkbox" class="hidden" name={"#{@form_as}[tags_drop][]"} value="" />
<div class="flex justify-between items-center">
<button
class="h-11 bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded text-sm"
type="button"
name={"#{@form_as}[tags_sort][]"}
value="new"
phx-click={Phoenix.LiveView.JS.dispatch("change")}>
add tag
</button>
<.submit_button form={@form} />
</div>
</.form>
"""
end
defp submit_button(%{form: %{errors: []}} = assigns), do: ~H{<.button>submit</.button>}
defp submit_button(assigns), do: ~H{<.button disabled="true">submit</.button>}
end
First we have to define the struct we are going to back our changeset with.
defmodule User do
defstruct [:name, :email]
end
defmodule ChangesetLive do
use Phoenix.LiveView, layout: {__MODULE__, :live}
import Phoenix.Component
import ExampleWeb.CoreComponents
def mount(_params, _session, socket) do
struct = %User{}
default_form_values = %{}
form =
validate_params(struct, default_form_values)
|> to_form(as: :user)
{:ok, assign(socket, :form, form)}
end
defp validate_params(struct, params) do
types = %{name: :string, email: :string}
{struct, types}
|> Ecto.Changeset.cast(params, Map.keys(types))
|> Ecto.Changeset.validate_required([:name, :email])
end
def handle_event("validate", %{"user" => params}, socket) do
form =
validate_params(%User{}, params)
|> to_form(as: :user, action: :validate)
{:noreply, assign(socket, :form, form)}
end
def handle_event("submit", %{"user" => params}, socket) do
case validate_params(%User{}, params) do
%{valid?: true} ->
{:noreply, Phoenix.LiveView.put_flash(socket, :info, "Changeset form submitted.")}
changeset ->
form = to_form(changeset, action: :validate)
{:noreply, assign(socket, :form, form)}
end
end
def render(assigns) do
~H"""
<h2>Changeset Data</h2>
<p id="info-changeset"- class="text-base text-lime-700"><%= Phoenix.Flash.get(@flash, :info) %></p>
<.form for={@form} phx-change="validate" phx-submit="submit" as={:user}>
<.input label="name" type="text" field={@form[:name]} />
<.input label="email" type="email" field={@form[:email]} />
<.submit_button form={@form} />
</.form>
"""
end
defp submit_button(%{form: %{errors: []}} = assigns), do: ~H{<.button>submit</.button>}
defp submit_button(assigns), do: ~H{<.button disabled="true">submit</.button>}
end
Sometimes when we don't have complex form validations; we don't need a changeset. Simple forms can be used without a changeset and the process becomes a little bit simplier.
timeline
%% Section denotes the lifecycle of Phoenix LiveView
section LiveView MAP Data Form Lifecycle
Mount: Initalize HTML.Form struct from empty map.
Validate: Convert params to HTML.Form struct, supplying errors and action to to_form.
Submit: Process form params from save event
defmodule MapDataLive do
use Phoenix.LiveView
import ExampleWeb.CoreComponents
import Phoenix.Component
def mount(_params, _session, socket) do
map_data = %{"name" => "", "email" => ""}
form = to_form(map_data)
{:ok, assign(socket, :form, form)}
end
def handle_event("validate", params, socket) do
errors = validate_params(params)
form = to_form(params, errors: errors, action: :validate)
{:noreply, assign(socket, :form, form)}
end
def handle_event("submit", params, socket) do
case validate_params(params) do
[] ->
{:noreply, Phoenix.LiveView.put_flash(socket, :info, "Map Data form submitted.")}
errors ->
form = to_form(params, errors: errors, action: :validate)
{:noreply, assign(socket, :form, form)}
end
end
defp validate_params(%{"name" => ""}) do
Keyword.put([], :name, {"can't be blank", []})
end
defp validate_params(_params), do: []
def render(assigns) do
~H"""
<h2>MAP Data</h2>
<p id="info-map" class="text-base text-lime-700"><%= Phoenix.Flash.get(@flash, :info) %></p>
<.form id="map-data-form" for={@form} phx-change="validate" phx-submit="submit">
<.input label="name" type="text" field={@form[:name]} />
<.submit_button form={@form} />
</.form>
"""
end
defp submit_button(%{form: %{errors: []}} = assigns), do: ~H{<.button>submit</.button>}
defp submit_button(assigns), do: ~H{<.button disabled="true">submit</.button>}
end
Because we don't use a normal Phoenix Install, which includes the custom tailwind (or bulma) classes; we have to define the rule to hide errors for inputs that haven't been interacted with yet. This shouldn't have to be done anywhere besides this livebook.
defmodule Example.HomeLive do
use Phoenix.LiveView, layout: {Example.Layout, :live}
def mount(_params, _session, socket) do
{:ok, socket}
end
def render(assigns) do
~H"""
<style>
#embedded-schema-form div {
position: relative;
margin-top: .5rem;
}
.phx-no-feedback.phx-no-feedback\:hidden,
.phx-no-feedback .phx-no-feedback\:hidden {
display: none!important;
}
form button {
margin-top: 1rem;
}
button[disabled="true"], button[disabled="true"]:hover {
background: gray;
}
</style>
<div class="bg-slate-100 p-5">
<h1 class="text-center">Liveview Forms</h1>
<div class="mx-auto max-w-600 flex justify-center items-start gap-2">
<div class="p-5 m-5 border-2 border-slate-400">
<%= live_render(@socket, EmbeddedSchemaLive, id: "embedded_schema") %>
</div>
<div class="p-5 m-5 border-2 border-slate-400">
<%= live_render(@socket, ChangesetLive, id: "changeset") %>
</div>
<div class="p-5 m-5 border-2 border-slate-400">
<%= live_render(@socket, MapDataLive, id: "map_data") %>
</div>
</div>
</div>
"""
end
end
Now you can start the server and visit http://localhost:5001 to interact with the forms.
defmodule Example.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug(:accepts, ["html"])
end
scope "/", Example do
pipe_through(:browser)
live("/", HomeLive, :index)
end
end
defmodule Example.Endpoint do
use Phoenix.Endpoint, otp_app: :sample
socket("/live", Phoenix.LiveView.Socket)
plug(Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix")
plug(Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view")
plug(Example.Router)
end
defmodule Example.ErrorView do
def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end
defmodule Example.Layout do
use Phoenix.LiveView
def render("live.html", assigns) do
~H"""
<script src="/assets/phoenix/phoenix.js"></script>
<script src="/assets/phoenix_live_view/phoenix_live_view.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
liveSocket.connect()
</script>
<style>
* { font-size: 1.1em; }
</style>
<%= @inner_content %>
"""
end
def render(assigns) do
~H""
end
end
{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)