defmodule LiveModel do @moduledoc """ Create and manage a LiveView model. A model allows you to define your LiveView assigns declaratively, similar to how you would use a `Phoenix.Component` and its `attr/2,3` macro. To create a model, import this module and use the `defmodel` macro, like so: ```elixir defmodule MyAppWeb.MyLive.Model do import LiveModel defmodel do field :user, MyApp.User.t(), required: true field :grocery_list, [MyApp.Food.t()], default: [] field :sale_text, String.t() # defaults to `nil` end end ``` Then, in your LiveView module: ```elixir defmodule MyAppWeb.MyLive do use MyAppWeb, :live_view alias MyAppWeb.MyLive.Model # un-import `assign/2,3` and friends that are included in # `use MyAppWeb, :live_view` to avoid accidental use. # (You should use the model helper functions instead, explained # later in this moduledoc) import Phoenix.Component, except: [assign: 2, assign: 3, assign_new: 3, update: 3] # alternatively, if you don't use other functions in # `Phoenix.Component` and you get an "unused import" warning # from the above, you can do something like this instead: # import Phoenix.Component, only: [] @impl true def mount(_params, %{"user_id" => user_id}, socket) do user = MyApp.get_user!(user_id) # assigns a new model struct under the `@model` assign {:ok, Model.assign_new(socket, user)} end @impl true def handle_event("new_sale", _params, sockt) do # update assigns with `Model.put/2,3` and `Model.update/3` {:noreply, Model.put(socket, :sale_text, "Apples are now 10% off!")} end @impl true def handle_event("some_event", _params, sockt) do # Dialyzer will warn you when trying to put/update invalid keys {:noreply, Model.put(socket, :bad_key, :uhoh)} end ``` You would then access assigns in your render function/template via `@model.assign`, instead of `@assign`. The `defmodel` macro will create a struct and `t()` type for you. It will also create the following helper functions: - `new/x`: creates a struct, where arity `x` is the number of required fields plus `1`. Required fields are passed as individual arguments to `new/x`, and optional fields are passed in a Keyword list (or another Enumerable like a map) - `assign_new/x`: similar to `new/x`, but takes the socket as the first argument and assigns the new struct under `@model`. - `put/2-3`: given a LiveView socket, updates the `:model` assign with the given field(s) and value(s). This function is meant to replace use of `Phoenix.Component.assign` in your LiveView - `update/2-3`: given a LiveView socket, updates the `:model` assign by passing the current value under `field` to the given `updater` function. The result then replaces the original value. This function is meant to replace use of `Phoenix.Component.update` in your LiveView Please read each function's documentation for more information. Much of the implementation of `defmodel` is heavily inspired by Lucas San Román's `typedstruct` macro. You can read more about it (and Elixir's AST/macros in general) in their [blogpost](https://dorgan.netlify.app/posts/2021/04/the_elixir_ast_typedstruct/). The "model" naming scheme is inspired by the [Elm architecture/programming language](https://elm-lang.org/). """ @doc """ Define a LiveView model. This macro should be given a do block, whose contents are `field`s: ```elixir defmodel do field :my_string_assign, String.t(), default: "" field :my_number_assign, integer(), required: true end ``` See the `LiveModel` documentation for more info and examples. """ @spec defmodel(do_block :: list()) :: Macro.t() defmacro defmodel(do: ast) do fields_ast = case ast do {:__block__, [], fields} -> fields field -> [field] end fields_data = Enum.map(fields_ast, &get_field_data/1) field_names = Enum.map(fields_data, & &1.name) enforced_fields = for field <- fields_data, field.required do field.name end typespecs = Enum.map(fields_data, fn %{name: name, typespec: typespec, required: true} -> {name, typespec} %{name: name, typespec: typespec, default: default} when not is_nil(default) -> {name, typespec} %{name: name, typespec: typespec} -> { name, {:|, [], [typespec, nil]} } end) fields = for %{name: name, default: default} <- fields_data do {name, default} end enforced_field_vars = Enum.map(enforced_fields, &(&1 |> Atom.to_string() |> Code.string_to_quoted!())) quote location: :keep do import Phoenix.Component, only: [assign: 3] @type t() :: %__MODULE__{unquote_splicing(typespecs)} @enforce_keys unquote(enforced_fields) defstruct unquote(fields) @doc """ Create a new model struct. If the first argument is a `t:Phoenix.LiveView.Socket`, the model is assigned to the socket under `@model`. Otherwise, the model struct is returned. """ def new(%Phoenix.LiveView.Socket{} = socket, unquote_splicing(enforced_field_vars), optional_fields) do assign( socket, :model, new(unquote_splicing(enforced_field_vars), optional_fields) ) end def new(%Phoenix.LiveView.Socket{} = socket, unquote_splicing(enforced_field_vars)) do new(socket, unquote_splicing(enforced_field_vars), []) end def new(unquote_splicing(enforced_field_vars), optional_fields) do struct( %__MODULE__{unquote_splicing(Enum.zip(enforced_fields, enforced_field_vars))}, optional_fields ) end def new(unquote_splicing(enforced_field_vars)), do: new(unquote_splicing(enforced_field_vars), []) @doc """ Updates the model in the assigns of the given `socket`. The `value` will be put under `field` in the Model, overwriting any existing value. This function will raise if `field` is not a field in your model's `defmodel` """ @spec put(Phoenix.LiveView.Socket.t(), atom(), any()) :: Phoenix.LiveView.Socket.t() def put(socket, field, value) when field in unquote(field_names) do assign( socket, :model, struct(socket.assigns.model, [{field, value}]) ) end @doc """ Updates the model in the assigns of the given `socket`. This function is similar to `put/3`, but is used for updating multiple fields at once. `fields` should be an `Enumerable` that emits 2-element tuples. If the `fields` contains keys that don't exist in the model, they will be ignored. """ @spec put(Phoenix.LiveView.Socket.t(), Enumerable.t({atom(), any()})) :: Phoenix.LiveView.Socket.t() def put(socket, fields) do assign( socket, :model, struct(socket.assigns.model, fields) ) end @doc """ Updates the model in the assigns of the given `socket`. `updater` will be called and passed the value under `field`, and the result will replace the original value. This function will raise if `field` is not a field in your model's `defmodel` """ @spec update(Phoenix.LiveView.Socket.t(), atom(), function()) :: Phoenix.LiveView.Socket.t() def update(socket, field, updater) when field in unquote(field_names) and is_function(updater) do assign( socket, :model, Map.update!(socket.assigns.model, field, updater) ) end end end defp get_field_data({:field, _meta, [name, typespec]}) do get_field_data({:field, [], [name, typespec, []]}) end defp get_field_data({:field, _meta, [name, typespec, opts]}) do default = Keyword.get(opts, :default) required = Keyword.get(opts, :required, false) %{ name: name, typespec: typespec, default: default, required: required } end end