defmodule Pyro.Component do
@moduledoc ~S'''
This is basically the same thing as `Phoenix.Component`, but Pyro extends the `attr/3` macro with:
* `:tails_classes` type
* `:overridable` flag
* `:values` supports an atom value (override key)
Pyro also provides `assign_overridables/1`, which automatically assigns all flagged `overridable` attrs with defaults from `Pyro.Overrides`
## Example
```elixir
defmodule MyApp.Components.ExternalLink do
@moduledoc """
An external link component.
"""
use Pyro.Component
attr :overrides, :list, default: nil, doc: @overrides_attr_doc
attr :class, :tails_classes, overridable: true, required: true
attr :href, :string, required: true
attr :rest, :global, include: ~w[download hreflang referrerpolicy rel target type]
slot :inner_block, required: true
def external_link(assigns) do
assigns = assign_overridables(assigns)
~H"""
<a class={@class} href={@href}} {@rest}>
<%= render_slot(@inner_block) %>
</a>
"""
end
end
```
> #### Note: {: .info}
>
> Only additional features will be documented here. Please see the `Phoenix.Component` docs for the rest, as they will not be duplicated here.
'''
@overrides_attr_doc "Manually set the overrides for this component (instead of config/default)"
defmacro __using__(opts \\ []) do
conditional =
if __CALLER__.module != Phoenix.LiveView.Helpers do
quote do: import(Phoenix.LiveView.Helpers)
end
component =
quote bind_quoted: [opts: opts] do
import Kernel, except: [def: 2, defp: 2]
import Phoenix.Component, except: [attr: 2, attr: 3]
import Phoenix.Component.Declarative
require Phoenix.Template
for {prefix_match, value} <-
Phoenix.Component.Declarative.__setup__(
__MODULE__,
Keyword.take(opts, [:global_prefixes])
) do
@doc false
def __global__?(unquote(prefix_match)), do: unquote(value)
end
end
pyro =
quote do
@overrides_attr_doc unquote(@overrides_attr_doc)
Module.register_attribute(__MODULE__, :__overridable_attrs__, accumulate: true)
Module.register_attribute(__MODULE__, :__assign_overridables_calls__, accumulate: true)
Module.put_attribute(__MODULE__, :__overridable_components__, %{})
import unquote(__MODULE__)
import unquote(__MODULE__).Helpers
alias Phoenix.LiveView.JS
@on_definition unquote(__MODULE__)
@before_compile unquote(__MODULE__)
Module.delete_attribute(__MODULE__, :__overridable_attrs__)
Module.delete_attribute(__MODULE__, :__assign_overridables_calls__)
end
[conditional, component, pyro]
end
@doc """
There are only a few things added to `Phoenix.Component.attr/3` by Pyro:
* `:tails_classes` type
* merges overridable defaults with passed prop values via `Tails`
* prevents weird precedence conflicts
* less bloated HTML
* `:overridable` flag (marks attribute to be overridden by `Pyro.Overrides`)
* `:values` supports an atom value (override key, loaded by `Pyro.Overrides`)
There are compile time checks to ensure the following, but of note:
* Attrs flagged as `overridable` cannot have a `default` - That's what overrides are for! 🚀
* If flagged as `overridable` and `required`, a runtime exception will be raised if no configured overrides provide a default
* If any attrs are flagged as overridable
* The first attribute must be:
```
attr :overrides, :list, default: nil, doc: @overrides_attr_doc
```
* `assign_overridables/1` must be called
Everything else is handled by `Phoenix.Component.attr/3`, so please consult those docs for the rest.
"""
defmacro attr(name, type, opts \\ []) do
type =
if Macro.quoted_literal?(type) do
Macro.prewalk(type, &expand_alias(&1, __CALLER__))
else
type
end
if type == :tails_classes && !opts[:overridable] do
invalid_overridable_attr_option!(
__CALLER__,
name,
":tails_classes type is only available for overridable props",
"attr #{inspect(name)}, :tails_classes, overridable: true"
)
end
if opts[:overridable] && opts[:default] do
invalid_overridable_attr_option!(
__CALLER__,
name,
"attr #{inspect(name)}, default: #{inspect(opts[:default])}",
"""
remove the default from the attr options.
Overridable defaults *must* be set via override files, not attribute options.
"""
)
end
# Append overridable info to docs
opts =
if opts[:overridable] do
Keyword.put(
opts,
:doc,
[
opts[:doc],
"(#{["overridable", if(type == :tails_classes, do: "`#{inspect(type)}`"), if(opts[:required], do: "required")] |> Enum.filter(& &1) |> Enum.join(", ")})"
]
|> Enum.filter(& &1)
|> Enum.join(" ")
)
else
opts
end
phoenix_opts =
if opts[:overridable] do
drops =
case opts[:values] do
values when not is_nil(values) and is_atom(values) ->
[:values]
_ ->
[]
end ++ [:overridable, :required]
Keyword.drop(opts, drops)
else
opts
end
# Phoenix doesn't support the `:tails_classes` type natively
phoenix_type =
case type do
:tails_classes -> :any
type -> type
end
quote bind_quoted: [
name: name,
type: type,
phoenix_type: phoenix_type,
opts: opts,
phoenix_opts: phoenix_opts
] do
Pyro.Component.__overridable_attr__!(
__MODULE__,
name,
type,
opts,
__ENV__.line,
__ENV__.file
)
Phoenix.Component.Declarative.__attr__!(
__MODULE__,
name,
phoenix_type,
phoenix_opts,
__ENV__.line,
__ENV__.file
)
end
end
@doc """
This macro automatically assigns all the overridable attrs, and handles merging classes for `:tails_classes` type attrs.
It *must* be called once in any component that contains overridable attrs.
## Example
```
def external_link(assigns) do
assigns = assign_overridables(assigns)
```
"""
defmacro assign_overridables(assigns) do
module = __CALLER__.module
{component_name, 1} = __CALLER__.function
# TODO: Check that it isn't already defined, implying it got called twice in the same component
Module.put_attribute(module, :__assign_overridables_calls__, component_name)
quote bind_quoted: [assigns: assigns, module: module, component_name: component_name] do
__overridable_components__()[component_name][:overridable_attrs]
|> Enum.reduce(assigns, fn %{name: name, required: required} = opts, assigns ->
# TODO: Validate values at runtime; load overridable values if atom instead of list.
override =
Map.get(assigns, :overrides) ||
Pyro.Overrides.configured_overrides()
|> Enum.reduce_while(nil, fn override_module, _ ->
override_module.overrides()
|> Map.fetch({{__MODULE__, component_name}, name})
|> case do
{:ok, value} -> {:halt, value}
:error -> {:cont, nil}
end
end)
|> case do
{:pass_assigns_to, override} ->
override = maybe_merge_classes(assigns, name, override.(assigns), opts)
assign(assigns, name, override)
override when not is_nil(override) ->
assign(assigns, name, maybe_merge_classes(assigns, name, override, opts))
_ ->
if required do
raise """
No override set for "attr #{inspect(name)}"
* Component: #{__MODULE__}.#{component_name}/1
* Prop: "attr #{inspect(name)}"
* Problem: override is required to be set
"""
else
assign(assigns, name, maybe_merge_classes(assigns, name, nil, opts))
end
end
end)
end
end
@doc ~S'''
Encode a flash message as a JSON binary with extra metadata options. This is necessary because Phoenix only allows binary messages, but many flash messages would be vastly improved by bespoke presentation.
This allows you to override the defaults for:
* `:title` - The title above the message
* `:close` - Auto-close the flash after `:ttl`
* `:ttl` - The time-to-live in milliseconds
* `:icon_name` - Name of the icon displayed in the title
* `:style_for_kind` - Override which kind of style this flash should have
## Examples
```elixir
socket
|> put_flash(
"success",
encode_flash(
"""
This flash closes when it *wants to*.
And has a custom title and icon.
""",
title: "TOTALLY CUSTOM",
ttl: 6_000,
icon_name: "hero-beaker"
)
)
```
'''
@type encode_flash_opts ::
{:ttl, pos_integer}
| {:title, binary}
| {:icon_name, binary}
| {:close, boolean}
| {:style_for_kind, binary}
@spec encode_flash(binary, [encode_flash_opts]) :: binary()
def encode_flash(message, opts) do
Jason.encode!(%{
"ttl" => opts[:ttl],
"title" => opts[:title],
"icon_name" => opts[:icon_name],
"close" => opts[:close],
"style_for_kind" => opts[:style_for_kind],
"message" => message
})
end
@doc false
# Internal tooling to merge classes at runtime
def maybe_merge_classes(assigns, attr, override, %{class?: true}) do
Tails.classes([override, assigns[attr]])
end
def maybe_merge_classes(assigns, attr, override, %{type: :tails_classes}) do
Tails.classes([override, assigns[attr]])
end
def maybe_merge_classes(assigns, attr, override, _opts) do
case assigns[attr] do
nil -> override
value -> value
end
end
@doc false
def __on_definition__(env, kind, name, args, _guards, _body) do
if length(args) == 1 && not String.starts_with?(to_string(name), "__") &&
Enum.find(args, fn {arg, _line, _} -> arg == :assigns end) do
# Get list of attribute line numbers for this component
attr_lines =
Module.get_attribute(env.module, :__components__)[name][:attrs]
|> Enum.map(& &1.line)
# Only include overrides that have the same line
attrs =
Module.get_attribute(env.module, :__overridable_attrs__)
|> Enum.filter(&(&1.line in attr_lines))
# We need to preserve definition order
|> Enum.reverse()
overrides = Enum.filter(attrs, & &1.overridable)
first_attr = List.first(attrs)
# Automatically mark the doc type as a component
Module.put_attribute(env.module, :doc, {first_attr.line - 1, type: :component})
unless Enum.empty?(overrides) do
case first_attr do
%{name: :overrides, type: :list, opts: [default: nil, doc: _]} ->
:ok
attr ->
raise CompileError,
line: attr.line,
file: attr.file,
description: """
Pyro.Component - Missing :overrides Prop
* Prop: attr #{inspect(attr.name)}
* Problem: The first prop of the component must be :overrides
* Solution:
attr :overrides, :list, default: nil, doc: @overrides_attr_doc
attr #{inspect(attr.name)} # ...
# ... other props
#{kind} #{name} (assigns) do
"""
end
components = Module.get_attribute(env.module, :__overridable_components__)
if Map.get(components, name) do
raise """
Pyro.Component: Component #{module_label(env.module)}.#{name}/1 already defined.
This is probably a Pyro bug, as this should never be possible.
"""
end
Module.put_attribute(
env.module,
:__overridable_components__,
Map.put(components, name, %{kind: kind, overridable_attrs: overrides})
)
else
:ok
end
:ok
else
:ok
end
end
@doc false
defmacro __before_compile__(env) do
assign_overridable_calls = Module.get_attribute(env.module, :__assign_overridables_calls__)
overridable_components =
env.module
|> Module.get_attribute(:__overridable_components__)
overridable_components
|> Enum.each(fn {name, opts} ->
unless name in assign_overridable_calls do
raise CompileError,
file: env.file,
description: """
Pyro.Component - Missing Call to assign_overridables/1
* Component: #{module_label(env.module)}.#{name}/1
* Problem: assign_overridables/1 must be called by components with overridable props
* Solution:
#{opts[:kind]} #{name} (assigns) do
assigns = assign_overridables(assigns)
"""
end
end)
override_docs =
if overridable_components && overridable_components != %{} do
"""
## Overridable Component Attributes
You can customize the components in this module by [configuring overrides](`Pyro.Overrides`).
The components in this module support the following overridable attributes:
#{overridable_components |> Enum.map(fn {component, %{overridable_attrs: attrs}} -> """
- `#{component}/1`
#{Enum.map_join(attrs, "\n", fn %{name: name, type: type, required: required} -> " - `#{inspect(name)}` `#{inspect(type)}`" <> if required, do: " (required)", else: "" end)}
""" end) |> Enum.join("\n")}
"""
else
""
end
quote do
@moduledoc (case @moduledoc do
false ->
false
nil ->
name =
__MODULE__
|> Module.split()
|> List.last()
unquote(override_docs)
docs ->
docs <> "\n" <> unquote(override_docs)
end)
def __overridable_components__() do
@__overridable_components__
end
end
end
@doc false
def __overridable_attr__!(module, name, type, opts, line, file)
when is_atom(name) and is_list(opts) do
{overridable, opts} = Keyword.pop(opts, :overridable, false)
{required, opts} = Keyword.pop(opts, :required, false)
overridable = %{
name: name,
overridable: overridable,
type: type,
required: required,
opts: opts,
file: file,
line: line
}
Module.put_attribute(
module,
:__overridable_attrs__,
overridable
)
:ok
end
defp expand_alias({:__aliases__, _, _} = alias, env),
do: Macro.expand(alias, %{env | function: {:__attr__, 3}})
defp expand_alias(other, _env), do: other
defp invalid_overridable_attr_option!(env, attr_name, problem, solution) do
raise CompileError,
line: env.line,
file: env.file,
description: """
Pyro.Component - Invalid Overridable Option
* Prop: attr #{inspect(attr_name)}
* Problem: #{problem}
* Solution: #{solution}
"""
end
@spec module_label(module) :: String.t()
defp module_label(module),
do:
module
|> Module.split()
|> Enum.join(".")
end