if Code.ensure_loaded?(Phoenix.LiveView) do
defmodule Bandera.Dashboard.FlagsLive do
@moduledoc "The Bandera flag dashboard LiveView."
use Phoenix.LiveView
import Bandera.Dashboard.Components
alias Bandera.Dashboard.Theme
@constraint_operators ~w(eq neq in not_in contains gt gte lt lte matches)a
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: subscribe_to_changes()
socket =
socket
|> assign(
search: "",
expanded: MapSet.new(),
collapsed_groups: MapSet.new(),
actor_drafts: %{},
group_drafts: %{},
theme: Bandera.Config.theme(),
flash_error: nil
)
|> load_flags()
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<.styles theme={@theme} />
<div class={Theme.class(@theme, :wrap)}>
<h1 class={Theme.class(@theme, :heading)}>Bandera</h1>
<div :if={@flash_error} class={Theme.class(@theme, :flash)}>{@flash_error}</div>
<form phx-change="search" phx-submit="search">
<input
class={Theme.class(@theme, :search)}
type="text"
name="q"
value={@search}
placeholder="Search flags…"
autocomplete="off"
phx-debounce="150"
/>
</form>
<details
:for={{group, members} <- @groups}
class={Theme.class(@theme, :group)}
open={not group_collapsed?(@collapsed_groups, group)}
>
<summary
class={Theme.class(@theme, :group_summary)}
phx-click="toggle_group"
phx-value-group={group}
>
{group} <span class={Theme.class(@theme, :count)}>({length(members)})</span>
</summary>
<div :for={{display, flag} <- members}>
<div class={Theme.class(@theme, :row)}>
<span>
<span class={Theme.class(@theme, :name)}>{display}</span>
<.state_summary flag={flag} theme={@theme} />
</span>
<span>
<button
type="button"
class={Theme.class(@theme, toggle_role(flag))}
phx-click="toggle_boolean"
phx-value-flag={flag.name}
>{if boolean_on?(flag), do: "on", else: "off"}</button>
<button
type="button"
class={Theme.class(@theme, :icon_button)}
phx-click="toggle_row"
phx-value-flag={flag.name}
>
{if expanded?(@expanded, flag), do: "â–´", else: "â–ľ"}
</button>
</span>
</div>
<div :if={expanded?(@expanded, flag)} class={Theme.class(@theme, :editor)}>
{render_editor(assigns, flag)}
</div>
</div>
</details>
</div>
"""
end
@impl true
def handle_event("search", %{"q" => q}, socket) do
{:noreply, socket |> assign(search: q) |> recompute_groups()}
end
def handle_event("toggle_row", %{"flag" => name}, socket) do
expanded = socket.assigns.expanded
expanded =
if MapSet.member?(expanded, name),
do: MapSet.delete(expanded, name),
else: MapSet.put(expanded, name)
{:noreply, socket |> assign(:flash_error, nil) |> assign(:expanded, expanded)}
end
def handle_event("toggle_group", %{"group" => group}, socket) do
collapsed = socket.assigns.collapsed_groups
collapsed =
if MapSet.member?(collapsed, group),
do: MapSet.delete(collapsed, group),
else: MapSet.put(collapsed, group)
{:noreply, assign(socket, :collapsed_groups, collapsed)}
end
def handle_event("toggle_boolean", %{"flag" => name}, socket) do
flag_name = String.to_existing_atom(name)
if currently_on?(socket, name),
do: Bandera.disable(flag_name),
else: Bandera.enable(flag_name)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
def handle_event("actor_input", %{"flag" => name, "actor" => actor}, socket) do
{:noreply, update(socket, :actor_drafts, &Map.put(&1, name, actor))}
end
def handle_event("add_actor", %{"flag" => name, "actor" => actor}, socket) do
actor = String.trim(actor)
if actor == "" do
{:noreply, assign(socket, :flash_error, "Actor id can't be blank.")}
else
Bandera.enable(String.to_existing_atom(name), for_actor: actor)
{:noreply,
socket
|> assign(:flash_error, nil)
|> update(:actor_drafts, &Map.delete(&1, name))
|> refresh()}
end
end
def handle_event("remove_actor", %{"flag" => name, "actor" => actor}, socket) do
Bandera.clear(String.to_existing_atom(name), for_actor: actor)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
def handle_event("group_input", %{"flag" => name, "group" => group}, socket) do
{:noreply, update(socket, :group_drafts, &Map.put(&1, name, group))}
end
def handle_event("add_group", %{"flag" => name, "group" => group}, socket) do
group = String.trim(group)
if group == "" do
{:noreply, assign(socket, :flash_error, "Group name can't be blank.")}
else
Bandera.enable(String.to_existing_atom(name), for_group: group)
{:noreply,
socket
|> assign(:flash_error, nil)
|> update(:group_drafts, &Map.delete(&1, name))
|> refresh()}
end
end
def handle_event("remove_group", %{"flag" => name, "group" => group}, socket) do
Bandera.clear(String.to_existing_atom(name), for_group: group)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
def handle_event(
"set_percentage",
%{"flag" => name, "percent" => percent, "kind" => kind},
socket
) do
with {pct, ""} <- Integer.parse(String.trim(percent)),
true <- pct >= 1 and pct <= 99,
{:ok, gate_kind} <- percentage_kind(kind) do
Bandera.enable(String.to_existing_atom(name), for_percentage_of: {gate_kind, pct / 100})
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
else
_ ->
{:noreply,
assign(socket, :flash_error, "Percentage must be a whole number between 1 and 99.")}
end
end
def handle_event("clear_percentage", %{"flag" => name}, socket) do
Bandera.clear(String.to_existing_atom(name), for_percentage: true)
{:noreply, refresh(socket)}
end
def handle_event(
"add_variant",
%{"flag" => name, "variant" => variant, "weight" => weight},
socket
) do
with variant when variant != "" <- String.trim(variant),
{:ok, w} when w > 0 <- parse_number(String.trim(weight)) do
weights = name |> current_weights(socket) |> Map.put(variant, w)
Bandera.put_variants(String.to_existing_atom(name), weights)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
else
_ ->
{:noreply, assign(socket, :flash_error, "Variant needs a name and a positive weight.")}
end
end
def handle_event("remove_variant", %{"flag" => name, "variant" => variant}, socket) do
flag_name = String.to_existing_atom(name)
weights = name |> current_weights(socket) |> Map.delete(variant)
if map_size(weights) == 0,
do: Bandera.clear(flag_name, variant: true),
else: Bandera.put_variants(flag_name, weights)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
def handle_event(
"add_constraint",
%{"flag" => name, "attribute" => attr, "operator" => op, "values" => values},
socket
) do
with attr when attr != "" <- String.trim(attr),
{:ok, operator} <- parse_operator(op) do
constraint = Bandera.Constraint.new(attr, operator, parse_values(values))
constraints = current_constraints(name, socket) ++ [constraint]
Bandera.enable(String.to_existing_atom(name), when: constraints)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
else
_ ->
{:noreply,
assign(socket, :flash_error, "Rule needs an attribute and a valid operator.")}
end
end
def handle_event("remove_constraint", %{"flag" => name, "index" => index}, socket) do
case Integer.parse(index) do
{i, ""} ->
flag_name = String.to_existing_atom(name)
constraints = current_constraints(name, socket) |> List.delete_at(i)
if constraints == [],
do: Bandera.clear(flag_name, rule: true),
else: Bandera.enable(flag_name, when: constraints)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
_ ->
{:noreply, socket}
end
end
def handle_event("add_segment", %{"flag" => name, "segment" => segment}, socket) do
case String.trim(segment) do
"" ->
{:noreply, assign(socket, :flash_error, "Segment name can't be blank.")}
seg ->
Bandera.enable(String.to_existing_atom(name), for_segment: seg)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
end
def handle_event("remove_segment", %{"flag" => name, "segment" => segment}, socket) do
Bandera.clear(String.to_existing_atom(name), for_segment: segment)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
def handle_event(
"add_prerequisite",
%{"flag" => name, "parent" => parent, "required" => required},
socket
) do
case String.trim(parent) do
"" ->
{:noreply, assign(socket, :flash_error, "Pick a prerequisite flag.")}
parent ->
Bandera.enable(String.to_existing_atom(name),
requires: {String.to_existing_atom(parent), required == "on"}
)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
end
def handle_event("remove_prerequisite", %{"flag" => name, "parent" => parent}, socket) do
Bandera.clear(String.to_existing_atom(name), requires: String.to_existing_atom(parent))
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
def handle_event("set_schedule", %{"flag" => name, "from" => from, "until" => until}, socket) do
from = blank_to_nil(from)
until = blank_to_nil(until)
if is_nil(from) and is_nil(until) do
{:noreply, assign(socket, :flash_error, "Set a start or an end for the schedule.")}
else
Bandera.enable(String.to_existing_atom(name), schedule: {from, until})
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
end
def handle_event("clear_schedule", %{"flag" => name}, socket) do
Bandera.clear(String.to_existing_atom(name), schedule: true)
{:noreply, socket |> assign(:flash_error, nil) |> refresh()}
end
def handle_event("clear_flag", %{"flag" => name}, socket) do
flag_name = String.to_existing_atom(name)
Bandera.clear(flag_name)
{:noreply,
socket
|> update(:expanded, &MapSet.delete(&1, name))
|> refresh()}
end
@impl true
def handle_info({:bandera_change, _flag, _id}, socket) do
{:noreply, refresh(socket)}
end
def handle_info(_msg, socket), do: {:noreply, socket}
# ---- editor (inline; extract into Components later if it grows) ----
defp render_editor(assigns, flag) do
assigns = Phoenix.Component.assign(assigns, :flag, flag)
~H"""
<fieldset class={Theme.class(@theme, :fieldset)}>
<legend class={Theme.class(@theme, :legend)}>Actors</legend>
<ul class={Theme.class(@theme, :gate_list)}>
<li :for={id <- actor_targets(@flag)} class={Theme.class(@theme, :gate_item)}>
<code>{id}</code>
<button
type="button"
class={Theme.class(@theme, :danger_button)}
phx-click="remove_actor"
phx-value-flag={@flag.name}
phx-value-actor={id}
>remove</button>
</li>
</ul>
<form phx-submit="add_actor" phx-change="actor_input">
<input type="hidden" name="flag" value={@flag.name} />
<input
type="text"
name="actor"
value={Map.get(@actor_drafts, to_string(@flag.name), "")}
placeholder="actor id"
class={Theme.class(@theme, :input)}
/>
<button class={Theme.class(@theme, :primary_button)}>add actor</button>
</form>
</fieldset>
<fieldset class={Theme.class(@theme, :fieldset)}>
<legend class={Theme.class(@theme, :legend)}>Groups</legend>
<ul class={Theme.class(@theme, :gate_list)}>
<li :for={name <- group_targets(@flag)} class={Theme.class(@theme, :gate_item)}>
<code>{name}</code>
<button
type="button"
class={Theme.class(@theme, :danger_button)}
phx-click="remove_group"
phx-value-flag={@flag.name}
phx-value-group={name}
>remove</button>
</li>
</ul>
<form phx-submit="add_group" phx-change="group_input">
<input type="hidden" name="flag" value={@flag.name} />
<input
type="text"
name="group"
value={Map.get(@group_drafts, to_string(@flag.name), "")}
placeholder="group name"
class={Theme.class(@theme, :input)}
/>
<button class={Theme.class(@theme, :primary_button)}>add group</button>
</form>
</fieldset>
<fieldset class={Theme.class(@theme, :fieldset)}>
<legend class={Theme.class(@theme, :legend)}>Percentage</legend>
<form phx-submit="set_percentage">
<input type="hidden" name="flag" value={@flag.name} />
<input
type="number"
name="percent"
min="1"
max="99"
placeholder="%"
class={Theme.class(@theme, :input)}
/>
<select name="kind" class={Theme.class(@theme, :select)}>
<option value="actors">of actors</option>
<option value="time">of time</option>
</select>
<button class={Theme.class(@theme, :primary_button)}>set</button>
<button
type="button"
class={Theme.class(@theme, :neutral_button)}
phx-click="clear_percentage"
phx-value-flag={@flag.name}
>
clear
</button>
</form>
</fieldset>
{render_variants(assigns, @flag)}
{render_rule(assigns, @flag)}
{render_segments(assigns, @flag)}
{render_prerequisites(assigns, @flag)}
{render_schedule(assigns, @flag)}
<button
type="button"
class={Theme.class(@theme, :danger_button)}
phx-click="clear_flag"
phx-value-flag={@flag.name}
>
Clear whole flag
</button>
"""
end
defp render_variants(assigns, flag) do
assigns = Phoenix.Component.assign(assigns, :flag, flag)
~H"""
<fieldset class={Theme.class(@theme, :fieldset)}>
<legend class={Theme.class(@theme, :legend)}>Variants</legend>
<ul class={Theme.class(@theme, :gate_list)}>
<li :for={{name, weight} <- variant_weights(@flag)} class={Theme.class(@theme, :gate_item)}>
<code>{name} ({weight})</code>
<button
type="button"
class={Theme.class(@theme, :danger_button)}
phx-click="remove_variant"
phx-value-flag={@flag.name}
phx-value-variant={name}
>remove</button>
</li>
</ul>
<form phx-submit="add_variant">
<input type="hidden" name="flag" value={@flag.name} />
<input type="text" name="variant" placeholder="variant name" class={Theme.class(@theme, :input)} />
<input type="number" name="weight" min="1" step="any" placeholder="weight" class={Theme.class(@theme, :input)} />
<button class={Theme.class(@theme, :primary_button)}>add variant</button>
</form>
</fieldset>
"""
end
defp render_rule(assigns, flag) do
assigns =
assigns
|> Phoenix.Component.assign(:flag, flag)
|> Phoenix.Component.assign(:constraints, rule_constraints(flag))
|> Phoenix.Component.assign(:operators, @constraint_operators)
~H"""
<fieldset class={Theme.class(@theme, :fieldset)}>
<legend class={Theme.class(@theme, :legend)}>Rule</legend>
<ul class={Theme.class(@theme, :gate_list)}>
<li :for={{c, i} <- Enum.with_index(@constraints)} class={Theme.class(@theme, :gate_item)}>
<code>{c.attribute} {c.operator} {Enum.join(c.values, ", ")}</code>
<button
type="button"
class={Theme.class(@theme, :danger_button)}
phx-click="remove_constraint"
phx-value-flag={@flag.name}
phx-value-index={i}
>remove</button>
</li>
</ul>
<form phx-submit="add_constraint">
<input type="hidden" name="flag" value={@flag.name} />
<input type="text" name="attribute" placeholder="attribute" class={Theme.class(@theme, :input)} />
<select name="operator" class={Theme.class(@theme, :select)}>
<option :for={op <- @operators} value={op}>{op}</option>
</select>
<input type="text" name="values" placeholder="values (comma-separated)" class={Theme.class(@theme, :input)} />
<button class={Theme.class(@theme, :primary_button)}>add constraint</button>
</form>
</fieldset>
"""
end
defp render_segments(assigns, flag) do
assigns = Phoenix.Component.assign(assigns, :flag, flag)
~H"""
<fieldset class={Theme.class(@theme, :fieldset)}>
<legend class={Theme.class(@theme, :legend)}>Segments</legend>
<ul class={Theme.class(@theme, :gate_list)}>
<li :for={seg <- segment_targets(@flag)} class={Theme.class(@theme, :gate_item)}>
<code>{seg}</code>
<button
type="button"
class={Theme.class(@theme, :danger_button)}
phx-click="remove_segment"
phx-value-flag={@flag.name}
phx-value-segment={seg}
>remove</button>
</li>
</ul>
<form phx-submit="add_segment">
<input type="hidden" name="flag" value={@flag.name} />
<input type="text" name="segment" placeholder="segment name" class={Theme.class(@theme, :input)} />
<button class={Theme.class(@theme, :primary_button)}>add segment</button>
</form>
</fieldset>
"""
end
defp render_prerequisites(assigns, flag) do
assigns =
assigns
|> Phoenix.Component.assign(:flag, flag)
|> Phoenix.Component.assign(:candidates, prerequisite_candidates(assigns.all_flags, flag))
~H"""
<fieldset class={Theme.class(@theme, :fieldset)}>
<legend class={Theme.class(@theme, :legend)}>Prerequisites</legend>
<ul class={Theme.class(@theme, :gate_list)}>
<li :for={g <- prerequisite_gates(@flag)} class={Theme.class(@theme, :gate_item)}>
<code>{g.for} (must be {if g.enabled, do: "on", else: "off"})</code>
<button
type="button"
class={Theme.class(@theme, :danger_button)}
phx-click="remove_prerequisite"
phx-value-flag={@flag.name}
phx-value-parent={g.for}
>remove</button>
</li>
</ul>
<form phx-submit="add_prerequisite">
<input type="hidden" name="flag" value={@flag.name} />
<select name="parent" class={Theme.class(@theme, :select)}>
<option value="">flag…</option>
<option :for={f <- @candidates} value={f}>{f}</option>
</select>
<select name="required" class={Theme.class(@theme, :select)}>
<option value="on">on</option>
<option value="off">off</option>
</select>
<button class={Theme.class(@theme, :primary_button)}>add prerequisite</button>
</form>
</fieldset>
"""
end
defp render_schedule(assigns, flag) do
assigns =
assigns
|> Phoenix.Component.assign(:flag, flag)
|> Phoenix.Component.assign(:window, schedule_window(flag))
~H"""
<fieldset class={Theme.class(@theme, :fieldset)}>
<legend class={Theme.class(@theme, :legend)}>Schedule</legend>
<form phx-submit="set_schedule">
<input type="hidden" name="flag" value={@flag.name} />
<input
type="text"
name="from"
value={@window["from"]}
placeholder="from (ISO 8601)"
class={Theme.class(@theme, :input)}
/>
<input
type="text"
name="until"
value={@window["until"]}
placeholder="until (ISO 8601)"
class={Theme.class(@theme, :input)}
/>
<button class={Theme.class(@theme, :primary_button)}>set</button>
<button
type="button"
class={Theme.class(@theme, :neutral_button)}
phx-click="clear_schedule"
phx-value-flag={@flag.name}
>clear</button>
</form>
</fieldset>
"""
end
# ---- assigns helpers ----
defp load_flags(socket) do
flags =
case Bandera.all_flags() do
{:ok, flags} -> flags
{:error, _} -> []
end
socket |> assign(:all_flags, flags) |> recompute_groups()
end
defp recompute_groups(socket) do
separator = Bandera.Config.group_separator()
filtered =
for flag <- socket.assigns.all_flags,
matches?(flag, socket.assigns.search),
do: flag
assign(socket, :groups, Bandera.Dashboard.Grouping.group(filtered, separator))
end
defp matches?(_flag, ""), do: true
defp matches?(flag, search) do
String.contains?(String.downcase(to_string(flag.name)), String.downcase(search))
end
defp boolean_on?(flag) do
Enum.any?(flag.gates, fn g -> Bandera.Gate.boolean?(g) and g.enabled end)
end
defp expanded?(expanded, flag), do: MapSet.member?(expanded, to_string(flag.name))
defp group_collapsed?(collapsed, group), do: MapSet.member?(collapsed, group)
defp toggle_role(flag), do: if(boolean_on?(flag), do: :toggle_on, else: :toggle_off)
defp actor_targets(flag) do
for g <- flag.gates, Bandera.Gate.actor?(g), do: g.for
end
defp group_targets(flag) do
for g <- flag.gates, Bandera.Gate.group?(g), do: g.for
end
defp segment_targets(flag), do: for(g <- flag.gates, Bandera.Gate.segment?(g), do: g.for)
defp prerequisite_gates(flag), do: for(g <- flag.gates, Bandera.Gate.prerequisite?(g), do: g)
defp prerequisite_candidates(all_flags, flag),
do: for(f <- all_flags, f.name != flag.name, do: f.name)
defp current_flag(socket, name),
do: Enum.find(socket.assigns.all_flags, &(to_string(&1.name) == name))
defp variant_weights(flag) do
case Enum.find(flag.gates, &Bandera.Gate.variant?/1) do
nil -> %{}
gate -> gate.value
end
end
defp current_weights(name, socket) do
case current_flag(socket, name) do
nil -> %{}
flag -> variant_weights(flag)
end
end
defp rule_constraints(flag) do
case Enum.find(flag.gates, &Bandera.Gate.rule?/1) do
nil -> []
gate -> gate.value
end
end
defp current_constraints(name, socket) do
case current_flag(socket, name) do
nil -> []
flag -> rule_constraints(flag)
end
end
defp coerce_value(token) do
case parse_number(token) do
{:ok, n} -> n
:error -> token
end
end
defp parse_values(str) do
str
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.map(&coerce_value/1)
end
defp parse_operator(op) do
case Enum.find(@constraint_operators, &(Atom.to_string(&1) == op)) do
nil -> :error
atom -> {:ok, atom}
end
end
defp parse_number(str) do
case Integer.parse(str) do
{i, ""} ->
{:ok, i}
_ ->
case Float.parse(str) do
{f, ""} -> {:ok, f}
_ -> :error
end
end
end
defp currently_on?(socket, name) do
Enum.any?(socket.assigns.all_flags, fn flag ->
to_string(flag.name) == name and boolean_on?(flag)
end)
end
defp refresh(socket), do: load_flags(socket)
@change_topic "bandera:changes"
defp subscribe_to_changes do
with true <- Bandera.Config.notifications_adapter() == Bandera.Notifications.PhoenixPubSub,
client when not is_nil(client) <- Keyword.get(Bandera.Config.notifications(), :client) do
Phoenix.PubSub.subscribe(client, @change_topic)
else
_ -> :ok
end
end
defp percentage_kind("actors"), do: {:ok, :actors}
defp percentage_kind("time"), do: {:ok, :time}
defp percentage_kind(_), do: :error
defp schedule_window(flag) do
case Enum.find(flag.gates, &Bandera.Gate.schedule?/1) do
nil -> %{"from" => nil, "until" => nil}
gate -> gate.value
end
end
defp blank_to_nil(str) do
case String.trim(str) do
"" -> nil
s -> s
end
end
end
end