View Source LiveUI protocol (LiveUI v0.1.0)

Protocol for Ecto.Schema struct to configure related Phoenix.LiveView modules.

Simple setup

defimpl LiveUI, for: MyApp.Admin.Company do
  use LiveUI.Protocol
end

Calling use LiveUI.Protocol will define all function which are implementing the LiveUI protocol. It is a convenience to eliminate the boilerplate code required by the protocol. Most of the values set by the macro are the ones you would expect, like using the first string field as a title or building relation links using foreign keys. When that is not the case, we can override these functions to change how the data will be presented and processed.

Custom setup

defimpl LiveUI, for: MyApp.Admin.User do
  use LiveUI.Protocol,
    flop: [
      filterable: [:name, :email, :activated_at],
      sortable: [:activated_at],
      default_order: %{order_by: [:activated_at], order_directions: [:desc]}
    ]

  def title(user), do: record.name
  def description(user), do: user.email
  def filter_operators(_), do: [age: [:<=, :>=]]
  def input_hints(_), do: [website: "Should begin with https://"]
  def uploads(_), do: [image: [accept: ~w(.jpg .jpeg),max_file_size: 800_000]]

  def index_view(user) do
    super(user)
    |> ignore_fields(:new, [:confirmed_at])
    |> add_batch_action(:deactivate, "Deactivate", MyAppWeb.Admin.UserLive.Deactivate)
    |> add_formatters(website: {&link_/1, %{name: "Web"}})
  end

  def show_view(user) do
    super(user)
    |> add_formatters(bio: &markdown/1, website: &link_/1)
  end
end

NOTE: flop option will derive Flop.Schema protocol unless it is already derived in Ecto schema module:

Protocol.derive(Flop.Schema, MyApp.Admin.User,
  filterable: <all schema fields>,
  sortable: [<:updated_at or :id>],
  default_order: %{order_by: [<:updated_at or :id>], order_directions: [:desc]}
)

:updated_at is the only sort filed set with a fallback to :id if it doesn't exist. Also, all fields are searchable by default. Please check Flop.Schema for more info.

Extending live view modules

Customizing default views via protocol is sometimes not enough as we might need extra markup and logic in these views.

Default template can be extended by overriding Phoenix.LiveView.render/1 callback. We could do the same with Phoenix.LiveView.mount/3 if we need to put extra assigns into socket. Finally, the data could be processed with custom Phoenix.LiveView.handle_event/3 callbacks.

defmodule LiveUIWeb.Admin.UserLive.Index do
  use LiveUI.Views.Index, for: LiveUI.Admin.User

  def mount(params, session, socket) do
    {:ok, socket} = super(params, session, socket)
    {:ok, socket |> assign(:greeting, "Hello")}
  end

  def render(assigns) do
    ~H"""
    <.p phx-click="bigger-greeting"><%= @greeting %> from extra markup before the table!</.p>
    <%= super(assigns) %>
    """
  end

  def handle_event("bigger-greeting", _, socket) do
    {:noreply, socket |> assign(greeting: String.upcase(socket.assigns.greeting))}
  end
end

Summary

Types

t()

All the types that implement this protocol.

Functions

Defines a description for the record, it is used in HEEX templates.

Configures Flop.Filter.op/0 operators for fields in search form.

Renders Show page heading as a component instead of title/1.

List of ignored fields, it is applied to both Index and Show views and their forms.

Configuration that controls how the data is displayed in the table and how is processed with create form. It also holds an info about custom actions, ie. Deactivate action in the example.

Add input_hints to form inputs.

Name of the parent namespace for nested routes.

Configures ownership relation for database queries, default is false.

Resource name that is used in routes and templates.

Resource name in plural that is used in routes and templates.

The field name selected in query which value is shown in LiveSelect dropdown list as record description.

The field name selected in query which value is shown in LiveSelect dropdown list as record title.

Search function used by LiveSelect to search for parent records in forms.

Configuration that controls how the data is displayed for a single record and how is processed with edit form. The fields are documented in LiveUI.index_view/1.

Defines a title for the record, it is set as page title and used in HEEX templates.

Configures upload fields. For single file upload use Ecto string type; for multiple files use array of strings.

Types

@type t() :: term()

All the types that implement this protocol.

Functions

Defines a description for the record, it is used in HEEX templates.

Default implementation is the value of the second string field from the Ecto struct.

Link to this function

filter_operators(record)

View Source

Configures Flop.Filter.op/0 operators for fields in search form.

Default operator for string fields is :ilike, for numbers and dates it is :==.

Example

# add extra inputs to search form
def filter_operators(_user), do: [age: [:<=, :>=]]

NOTE: This setting is not applicable to boolean and enum fields; they are always rendered as drop-down list. Also, any searchable foreign key field is rendered as LiveSelect component.

Renders Show page heading as a component instead of title/1.

Default implementation is false which will prevent it from rendering.

Example

use Phoenix.Component
import LiveUI.Components.Core

def heading(assigns) do
  ~H"""
  <.h3><%= @name %></.h3>
  <.p><%= @email %></.p>
  """
end

NOTE: p and h3 components are from LiveUI.Components.Core which delegates the calls to PetalComponents.

List of ignored fields, it is applied to both Index and Show views and their forms.

NOTE: fields can be also ignored for all schema modules via application config:

  config :live_ui,
    ignored_fields: [:token, :first_version_id, :current_version_id]

Configuration that controls how the data is displayed in the table and how is processed with create form. It also holds an info about custom actions, ie. Deactivate action in the example.

# LiveUI.index_view(%User{})
[
  formatters: [website: {&LiveUI.Formatters.link_/1, %{name: "Web"}}],
  actions: [
    new: [
      name: "New user",
      allowed: true,
      fields: [:name, :email, :bio, :age, :website, :company_id, :department_id, :role, :active],
      optional_fields: [:age],
      inputs: [],
      function: &LiveUI.Queries.create/2,
      changeset: &LiveUI.Changeset.create_changeset/3,
      validate_changeset: &LiveUI.Changeset.create_changeset/3
    ]
  ],
  batch_actions: [
    deactivate: [
      name: "Deactivate",
      component: LiveUIWeb.Admin.UserLive.Deactivate
    ],
    delete: [
      name: "Delete",
      allowed: true,
      function: &LiveUI.Queries.delete_ids/2
    ]
  ],
  fields: [:id, :name, :email, :website, :company_id, :department_id, :role, :active, :confirmed_at],
  preload: [:company, :department],
  function: &LiveUI.Queries.find_by_filter/3
]

This structure can be updated directly or by using built-in functions from LiveUI.Protocol.Utils.

Use put_in/3 function:

# disable update form
def show_view(session) do
  super(session)
  |> put_in([:actions, :edit, :allowed], false)
end

# override changeset function for create form
def index_view(contact) do
  super(contact)
  |> put_in([:actions, :new, :changeset], &MyApp.Member.Contact.create_changeset/3)
end

# create_changeset in my_app/member/contact.ex
def create_changeset(contact, params, socket) do
  LiveUI.Changeset.create_changeset(contact, params, socket)
  |> validate_format(:email, ~r/^[^ ]+@[^ ]+$/, message: "must have the @ sign and no spaces")
end

Use LiveUI.Protocol.Utils.ignore_fields/2 helper function:

def index_view(user) do
  super(user)
  |> ignore_fields([:updated_at, :inserted_at])
end

Formatters

List of formatters to control how the fields are displayed on the screen. They are added with LiveUI.Protocol.Utils.add_formatters/2.

Actions

Built-in and custom actions are rendered inside the modal. Custom actions are added with LiveUI.Protocol.Utils.add_action/5.

New action

Built-in action that renders a form to create records.

  • name

    Used as text in action button and page title.

  • allowed

    Boolean or a function to allow access to the action. The function accepts socket assign and should return a boolean.

  • fields

    List of fields which are rendered in the form. All fields are included unless ignored with LiveUI.Protocol.Utils.ignore_fields/3.

  • optional_fields

    All fields are required unless they are set as optional with LiveUI.Protocol.Utils.set_optional_fields/3.

  • inputs

    Configures form input types via LiveUI.Protocol.Utils.configure_inputs/3.

  • function

    Function that saves the record. Override if extra logic is needed.

  • changeset

    Changeset function for phx-submit that validates all fields as required. Override to add custom validation.

  • validate_changeset

    Changeset function for phx-change that points to changset for phx-submit by default. Override if it behaves differently.

Batch actions

Built-in and custom batch actions are rendered inside the modal and they operate on selected records. Custom batch actions are added with LiveUI.Protocol.Utils.add_batch_action/5.

Delete action

Built-in batch action that renders a dialog to delete selected records.

  • name

    Used as text in action button and page title.

  • allowed

    Boolean or a function to allow access to the action. The function accepts socket assign and should return a boolean.

  • function

    Function that deletes selected records. Override if extra logic is needed.

Fields

List of fields to display in the table. Defaults to all fields. Fields could be removed from the list with LiveUI.Protocol.Utils.ignore_fields/2.

Preload

List of parent relation that are preloaded and shown in the table with links to their own Show view. Defaults to all belongs_to relations.

Function

Function that loads the records which are filtered with Flop.Filter via search form. It will scope the result to current user if configured with ownership/1

Add input_hints to form inputs.

Example

def input_hints(_user), do: [website: "Should begin with https://"]

Name of the parent namespace for nested routes.

Default is the name of second to last module of the Ecto struct. For example, MyApp.Admin.User will produce routes starting with /admin/users.

Configures ownership relation for database queries, default is false.

This will scope queries to :user_id foreign key that is equal to :id key of the :current_user socket assign:

def ownership(_contact), do: {:user_id, :current_user}

Resource name that is used in routes and templates.

Default is the name of the last module of the Ecto struct.

Resource name in plural that is used in routes and templates.

Default is the name of the last module of the Ecto struct with appended s character. This should be overriden for plurals like companies or fish.

Link to this function

search_field_for_description(record)

View Source

The field name selected in query which value is shown in LiveSelect dropdown list as record description.

Default is the description field.

Link to this function

search_field_for_title(record)

View Source

The field name selected in query which value is shown in LiveSelect dropdown list as record title.

Default is the title field.

Search function used by LiveSelect to search for parent records in forms.

It uses configured search_field_for_title/1 and search_field_for_description/1 fields in select statement.

Default implementation will scope the result to current user from the socket assign if configured via ownership/1. It will also apply the scope of selected parent to other relations if they share the same parent as the record being created, ie. when selecting a company with one LiveSelect input only departments from that company will be searched in second LiveSelect input.

Custom function accepts socket assigns and search term and should return a map with id, label and description fields used in LiveSelect dropdown list.

def search_function(_department), do: &search_by_name/2

# scope department search to current user's company
def search_by_name(assigns, text) do
  import Ecto.Query
  company_id = Map.get(assigns[:current_user], :company_id)
  ilike = "#{text}%"

  from(d in MyApp.Department,
    where: [company_id: ^company_id],
    where: ilike(d.name, ^ilike),
    select: %{
      value: d.id,
      label: d.name,
      description: d.location
    },
    limit: 5
  )
  |> LiveUI.Repo.all()
end

Default implementation will apply the same scope to :company_id since User and Department schemas have the same belongs_to :company relation.

Configuration that controls how the data is displayed for a single record and how is processed with edit form. The fields are documented in LiveUI.index_view/1.

# LiveUI.show_view(%User{})
[
  formatters: [
    email: &LiveUI.Formatters.copy/1,
    bio: &LiveUI.Formatters.markdown/1,
    website: &LiveUI.Formatters.link_/1,
    role: &String.upcase/1
  ],
  actions: [
    edit: [
      name: "Edit",
      allowed: true,
      fields: [:name, :email, :bio, :age, :website, :company_id, :department_id, :role, :active, :confirmed_at],
      optional_fields: [],
      inputs: [],
      function: &LiveUI.Queries.update/2,
      changeset: &LiveUI.Changeset.update_changeset/3,
      validate_changeset: &LiveUI.Changeset.update_changeset/3
    ],
    delete: [
      name: "Delete",
      allowed: true,
      function: &LiveUI.Queries.delete/2
    ]
  ],
  fields: [:id, :name, :email, :bio, :age, :website, :company_id, :department_id, :role, :active, :confirmed_at, :inserted_at, :updated_at],
  preload: [:company, :department],
  function: &LiveUI.Queries.find_by_id/2
]

Defines a title for the record, it is set as page title and used in HEEX templates.

Default is the value of the first string field from the Ecto struct.

# custom field
def title(user), do: record.name

# computed value
def title(user), do: "#{user.first_name} #{user.last_name}"

Configures upload fields. For single file upload use Ecto string type; for multiple files use array of strings.

Examples

def uploads(_) do
  [
    # ecto string
    image: [
      accept: ~w(.jpg .jpeg),
      max_file_size: 800_000
    ],
    # ecto array of strings
    extra_images: [
      accept: ~w(.jpg .jpeg),
      max_entries: 3,
      max_file_size: 800_000
    ]
  ]
end

In case of local upload, files are saved using priv/static/uploads/<resource>/<field>/<uuid>-<file-name> path, path for image field in product schema might look like this:

priv/static/uploads/product/image/1a64228b-54a4-4824-a90f-b236806aecbb-elixir.jpg

which is served from this url:

http://localhost:4010/uploads/product/image/1a64228b-54a4-4824-a90f-b236806aecbb-elixir.jpg

Configure static path and live reloader

To enable serving from uploads folder add it to the static_paths list in my_app_web.ex:

def static_paths, do: ~w(assets fonts images favicon.ico robots.txt uploads)

In dev.ex disable live reload when files are added to uploads folder:

~r"priv/static/(?!uploads).*(js|css|png|jpeg|jpg|gif|svg)$"