defmodule Luminous.Variable do
@moduledoc """
A variable is defined inside a Dashboard and its values are
determined at runtime. The variable also stores a current value
that can be updated. A variable value can be simple (just a value)
or descriptive in that it contains a label (for display purposes)
and the actual value.
Variables are visualized as dropdowns in the dashboard view.
There are two Variable types:
- `single`: only one value can be selected by the user (default type)
- `multi`: multiple values can be selected by the user
A Variable can also be hidden in which case:
- it will not be rendered as a dropdown in the dashboard
- it will not be included in the URL params
Hidden variables are a means for keeping some kind of state for
framework clients. A typical use case is implementing custom panels
which need some state (e.g. pagination).
"""
alias Luminous.Attributes
@doc """
A module must implement this behaviour to be passed as an argument to `define!/1`.
The function receives the variable id and the LV socket assigns.
"""
@callback variable(atom(), map()) :: [simple_value() | descriptive_value()]
@type t :: map()
@type simple_value :: binary()
@type descriptive_value :: %{label: binary(), value: binary()}
@attributes [
id: [type: :atom, required: true],
label: [type: :string, required: true],
module: [type: :atom, required: true],
type: [type: {:in, [:single, :multi]}, default: :single],
hidden: [type: :boolean, default: false]
]
@doc """
Defines a new variable and returns a map. The following options can be passed:
#{NimbleOptions.docs(@attributes)}
"""
@spec define!(keyword()) :: t()
def define!(opts) do
variable = Attributes.parse!(opts, @attributes)
if variable.id in [:from, :to] do
raise ":from and :to are reserved atoms in luminous and can not be used as variable IDs"
end
variable
end
@doc """
Find and return the variable with the specified id in the supplied variables.
"""
@spec find([t()], atom()) :: t() | nil
def find(variables, id), do: Enum.find(variables, fn v -> v.id == id end)
@doc """
Uses the callback to populate the variables's values and returns the
updated variable. Additionally, it sets the current value to be the
first of the available values in the case of a single variable or
all of the available values in the case of a multi variable.
"""
@spec populate(t(), map()) :: t()
def populate(var, socket_assigns) do
values =
var.module
|> apply(:variable, [var.id, socket_assigns])
|> Enum.map(fn
m when is_map(m) -> m
s when is_binary(s) -> %{label: s, value: s}
end)
case var.type do
:single ->
var
|> Map.put(:values, values)
|> Map.put(:current, List.first(values))
:multi ->
var
|> Map.put(:values, values)
|> Map.put(:current, values)
end
end
@doc """
Returns the variable's current (descriptive) value(s) or `nil`.
"""
@spec get_current(t()) :: descriptive_value() | [descriptive_value()] | nil
def get_current(nil), do: nil
def get_current(%{current: value}), do: value
@doc """
Find the variable with the supplied `id` in the supplied variables
and return its current extracted value.
"""
@spec get_current_and_extract_value([t()], atom()) :: binary() | [binary()] | nil
def get_current_and_extract_value(variables, variable_id) do
variables
|> find(variable_id)
|> get_current()
|> extract_value()
end
@doc """
Returns the label based on the variable type and current value selection
"""
@spec get_current_label(t()) :: binary() | nil
def get_current_label(%{current: nil}), do: nil
def get_current_label(%{current: %{label: label}}), do: label
def get_current_label(%{current: []}), do: "None"
def get_current_label(%{current: [value]}), do: value.label
def get_current_label(%{current: current} = var) when is_list(current) do
if length(current) == length(var.values) do
"All"
else
"#{length(current)} selected"
end
end
@doc """
Extract and return the value from the descriptive variable value.
"""
@spec extract_value(descriptive_value()) :: binary() | [binary()] | nil
def extract_value(nil), do: nil
def extract_value(%{value: value}), do: value
def extract_value(values) when is_list(values), do: Enum.map(values, & &1.value)
@doc """
Replaces the variables current value with the new value and returns the map.
It performs a check whether the supplied value is a valid value (i.e. exists in values).
If it's not, then it returns the map unchanged.
The special "none" case is for when the variable's type is :multi and none of the
values are selected (empty list)
"""
@spec update_current(t(), nil | binary() | [binary()], map()) :: t()
def update_current(var, nil, assigns), do: populate(var, assigns)
def update_current(%{type: :multi} = var, "none", _), do: %{var | current: []}
def update_current(%{hidden: hidden} = var, new_value, _) when is_binary(new_value) do
new_val =
if hidden do
%{value: new_value, label: new_value}
else
Enum.find(var.values, fn val -> val.value == new_value end)
end
if is_nil(new_val), do: var, else: %{var | current: new_val}
end
def update_current(%{hidden: hidden} = var, new_values, _) when is_list(new_values) do
new_values = Enum.map(new_values, fn v -> %{value: v, label: v} end)
new_values =
if hidden do
new_values
else
legitimate_values = Enum.map(var.values, & &1.value)
if Enum.all?(new_values, &(&1.value in legitimate_values)) do
new_values
else
var.current
end
end
%{var | current: new_values}
end
end