Current section
Files
Jump to
Current section
Files
lib/hologram/component.ex
defmodule Hologram.Component do
alias Hologram.Commons.MapUtils
alias Hologram.Commons.Types, as: T
alias Hologram.Compiler.AST
alias Hologram.Component
alias Hologram.Server
defstruct emitted_context: %{}, next_action: nil, next_command: nil, next_page: nil, state: %{}
defmodule Action do
defstruct delay: 0, name: nil, params: %{}, target: nil
@type t :: %__MODULE__{
delay: non_neg_integer,
name: :atom,
params: %{atom => any},
target: String.t() | nil
}
end
defmodule Command do
defstruct name: nil, params: %{}, target: nil
@type t :: %__MODULE__{name: :atom, params: %{atom => any}, target: String.t() | nil}
end
@type t :: %__MODULE__{
emitted_context: %{atom => any} | %{{module, atom} => any},
next_action: Action.t() | nil,
next_command: Command.t() | nil,
next_page: module | {module, keyword},
state: %{atom => any}
}
@doc """
Initializes component and server structs (when run on the server).
"""
@callback init(%{atom => any}, Component.t(), Server.t()) ::
{Component.t(), Server.t()} | Component.t() | Server.t()
@doc """
Returns a template in the form of an anonymous function that given variable bindings returns a DOM.
"""
@callback template() :: (map -> list)
defmacro __using__(_opts) do
template_path = colocated_template_path(__CALLER__.file)
[
quote do
import Hologram.Component,
only: [
prop: 2,
prop: 3,
put_action: 2,
put_action: 3,
put_command: 2,
put_command: 3,
put_context: 3,
put_page: 2,
put_page: 3,
put_state: 2,
put_state: 3
]
import Hologram.JS, only: [sigil_JS: 2]
import Hologram.Router.Helpers, only: [asset_path: 1, page_path: 1, page_path: 2]
import Hologram.Server,
only: [
delete_cookie: 2,
delete_session: 2,
get_cookie: 2,
get_cookie: 3,
get_session: 2,
get_session: 3,
put_cookie: 3,
put_cookie: 4,
put_session: 3
]
import Hologram.Template, only: [sigil_HOLO: 2]
alias Hologram.Component
alias Hologram.Component.Action
alias Hologram.Component.Command
@before_compile Component
@external_resource unquote(template_path)
@behaviour Component
@doc """
Returns true to indicate that the callee module is a component module (has "use Hologram.Component" directive).
## Examples
iex> __is_hologram_component__()
true
"""
@spec __is_hologram_component__() :: boolean
def __is_hologram_component__, do: true
@impl Component
def init(_props, component, server), do: {component, server}
defoverridable init: 3
end,
maybe_register_colocated_template_markup(template_path),
register_props_accumulator()
]
end
defmacro __before_compile__(env) do
template_clause = maybe_build_colocated_template_clause(env, Component)
props_clause =
quote do
@doc """
Returns the list of property definitions for the compiled component.
"""
@spec __props__() :: list({atom, atom, keyword})
def __props__, do: Enum.reverse(@__props__)
end
[template_clause, props_clause]
end
@doc """
Resolves the colocated template path for the given component module given its file path.
"""
@spec colocated_template_path(String.t()) :: String.t()
def colocated_template_path(templatable_path) do
Path.rootname(templatable_path) <> ".holo"
end
@doc """
Builds the template clause for colocated template if markup is registered in module attribute.
Returns nil if no colocated template is found.
"""
@spec maybe_build_colocated_template_clause(Macro.Env.t(), module) :: AST.t()
def maybe_build_colocated_template_clause(env, behaviour) do
markup = Module.get_attribute(env.module, :__colocated_template_markup__)
if markup do
quote do
@impl unquote(behaviour)
def template do
Hologram.Template.sigil_HOLO(unquote(markup), [])
end
end
end
end
@doc """
Registers colocated template markup in a module attribute if the template file exists.
Returns nil if the template file doesn't exist.
"""
@spec maybe_register_colocated_template_markup(String.t()) :: AST.t() | nil
def maybe_register_colocated_template_markup(template_path) do
if File.exists?(template_path) do
markup = File.read!(template_path)
quote do
@__colocated_template_markup__ unquote(markup)
end
end
end
@doc """
Accumulates the given property definition in __props__ module attribute.
"""
@spec prop(atom, atom, T.opts()) :: Macro.t()
defmacro prop(name, type, opts \\ []) do
quote do
Module.put_attribute(__MODULE__, :__props__, {unquote(name), unquote(type), unquote(opts)})
end
end
@doc """
Puts the given action spec to the component or server struct's next_action field.
Next action will be executed by the client-side runtime after the specified delay (in milliseconds, defaults to 0).
"""
@spec put_action(Component.t() | Server.t(), atom | keyword) :: Component.t() | Server.t()
def put_action(struct, name_or_spec)
def put_action(struct, name) when is_atom(name) do
%{struct | next_action: %Action{name: name}}
end
def put_action(struct, spec) when is_list(spec) do
name = spec[:name]
params = Map.new(spec[:params] || [])
target = spec[:target]
delay = spec[:delay] || 0
%{struct | next_action: %Action{name: name, params: params, target: target, delay: delay}}
end
@doc """
Puts the given action spec to the component or server struct's next_action field.
Next action will be executed by the client-side runtime after the specified delay (in milliseconds, defaults to 0).
"""
@spec put_action(Component.t() | Server.t(), atom, keyword) :: Component.t() | Server.t()
def put_action(struct, name, params) do
%{struct | next_action: %Action{name: name, params: Map.new(params)}}
end
@doc """
Puts the given command spec to the component's next_command field.
Next command will be sent asynchronously to the server.
"""
@spec put_command(Component.t(), atom | keyword) :: Component.t()
def put_command(component, name_or_spec)
def put_command(%Component{} = component, name) when is_atom(name) do
%{component | next_command: %Command{name: name}}
end
def put_command(%Component{} = component, spec) when is_list(spec) do
name = spec[:name]
params = Map.new(spec[:params] || [])
target = spec[:target]
%{component | next_command: %Command{name: name, params: params, target: target}}
end
@doc """
Puts the given command spec to the component's next_command field.
Next command will be sent asynchronously to the server.
"""
@spec put_command(Component.t(), atom, keyword) :: Component.t()
def put_command(%Component{} = component, name, params) do
%{component | next_command: %Command{name: name, params: Map.new(params)}}
end
@doc """
Puts the given key-value pair to the component's emitted_context field.
Context emitted by a component is available to all of its child nodes.
"""
@spec put_context(Component.t(), any, any) :: Component.t()
def put_context(%{emitted_context: context} = component, key, value) do
%{component | emitted_context: Map.put(context, key, value)}
end
@doc """
Puts the given page module to the component's next_page field.
The client will navigate to this page asynchronously after the current action finished executing.
"""
@spec put_page(Component.t(), module) :: Component.t()
def put_page(component, page_module) do
%{component | next_page: page_module}
end
@doc """
Puts the given page module and params to the component's next_page field (as a tuple).
The client will navigate to this page asynchronously after the current action finished executing.
"""
@spec put_page(Component.t(), module, keyword) :: Component.t()
def put_page(component, page_module, params) do
%{component | next_page: {page_module, params}}
end
@doc """
Puts the given key-value entries to the component state.
"""
@spec put_state(Component.t(), keyword | map) :: Component.t()
def put_state(component, entries)
def put_state(component, entries) when is_list(entries) do
put_state(component, Enum.into(entries, %{}))
end
def put_state(%{state: state} = component, entries) when is_map(entries) do
%{component | state: Map.merge(state, entries)}
end
@doc """
If the second arg is a list of keys representing a component state path
it puts the value in the nested component state path,
otherwise it puts the given key-value pair to the component state.
"""
@spec put_state(Component.t(), atom | list(atom), any) :: Component.t()
def put_state(component, keys, value) when is_list(keys) do
%{component | state: MapUtils.put_nested(component.state, keys, value)}
end
def put_state(%{state: state} = component, key, value) do
%{component | state: Map.put(state, key, value)}
end
@doc """
Returns the AST of code that registers __props__ module attribute.
"""
@spec register_props_accumulator() :: AST.t()
def register_props_accumulator do
quote do
Module.register_attribute(__MODULE__, :__props__, accumulate: true)
end
end
end