defmodule Periscope do
@moduledoc """
Tools for dealing with liveview processes, components, sockets, assigns. Pulls this information directly from the list of BEAM processes.
"""
@doc ~S"""
liveview_pids returns the PID of every process if that process contains a liveview.
"""
@spec liveview_pids :: [pid] | []
def liveview_pids do
Process.list()
|> Enum.map(
&{
&1,
Process.info(&1, [:dictionary])
|> hd()
|> elem(1)
|> Keyword.get(:"$initial_call", {})
}
)
|> Enum.filter(fn {_, proc} ->
proc != nil && proc != {} &&
elem(proc, 0) == Phoenix.LiveView.Channel
end)
|> Enum.map(&elem(&1, 0))
end
# Helper function for extracting state from a list of pids.
defp component_states do
Enum.map(liveview_pids(), &:sys.get_state/1)
end
@doc ~S"""
Returns the sockets for all active liveviews in a 0-indexed map. So all_sockets(0) will return the first socket in the map.
"""
@spec all_sockets :: map
def all_sockets do
component_states()
|> Enum.map(& &1.socket)
|> Enum.with_index(fn socket, index -> {index, socket} end)
|> Enum.into(%{})
end
@doc ~S"""
Returns a list of liveview module names. Expect to see stuff like MyApp.CustomerWorkflow or some such name. This does NOT list the names of components. Use components/0 for that.
"""
@spec all_liveviews :: [module] | []
def all_liveviews do
Enum.map(component_states(), & &1.socket)
|> Enum.with_index(fn socket, index -> {index, socket.view} end)
|> Enum.into(%{})
end
@doc ~S"""
Returns a single socket. You can access the assigns using socket.assigns. However, socket.assigns will not show the assigns on the components (see component_names).
"""
@spec socket(non_neg_integer) :: map
def socket(socket_index \\ 0) do
Map.get(all_sockets(), socket_index)
end
@doc ~S"""
as socket/1, but for liveview names.
"""
@spec which_liveview(non_neg_integer) :: map
def which_liveview(socket_index \\ 0) do
socket(socket_index).view
end
@doc ~S"""
Returns a list of active component names. These are module names, so you only see one per module. Even if one component is rendered many times, you will only see its name once. If you want to see how many instances of a component are rendered. use components/0.
Note that components have their own assigns. If you want to see the assigns for a component, you can use assigns_for/1.
"""
@spec component_names :: list
def component_names do
Enum.flat_map(
component_states(),
&(&1.components |> elem(1))
)
|> Enum.map(&elem(&1, 0))
end
@doc ~S"""
Takes the last part of a schema module name and returns all the fields in that schema. So running `schema_fields("Comments")` in an app called MyBlog will return all fields for MyBlog.Schemas.Comments..
"""
@spec schema_fields(binary) :: list
def schema_fields(schema_module) do
schema_name = Module.concat(application_name(), "Schemas." <> schema_module)
schema_name.__schema__(:fields)
end
@doc ~S"""
Searches through a nested map (such as socket.assigns) and checks if `key` exists at any point in the tree. Useful for finding out if a given key is somewhere in your assigns without scrolling through pages of socket info.
"""
@spec deep_has_key?(map, atom) :: boolean
def deep_has_key?(map, key), do: Enum.member?(all_keys(map), key)
@doc ~S"""
Will return a map that takes liveviews to lists of paths to those views. Useful if you want to access a component fast without having to scroll through the router and figure out its URL.
"""
@spec liveviews_to_paths :: list
def liveviews_to_paths do
app = application_name() <> "Web"
Module.concat([app, "Router"])
|> liveviews_to_paths()
end
defp liveviews_to_paths(your_app_web) do
your_app_web.__routes__()
|> Enum.filter(&is_a_liveview_route?/1)
|> Enum.map(&liveview_route_to_path/1)
|> Enum.reduce(%{}, &aggregate_merge(&1, &2))
end
defp liveview_route_to_path(route) do
{liveview, path} = {
route.metadata.phoenix_live_view |> elem(0),
route.path
}
%{liveview => [path]}
end
@doc ~S"""
Returns a map whose keys are component names (as those found in component_names) and whose values are the assigns for those components.
"""
@spec components_to_assigns :: map
def components_to_assigns do
components = (component_states() |> hd).components
components
|> elem(0)
|> Map.values()
|> Enum.map(&{elem(&1, 0), elem(&1, 2)})
|> Enum.into(%{})
end
@doc ~S"""
Returns the assigns for the fully-qualified component name, e.g. assigns_for(MyappWeb.MainView.Table) will return the assigns for that component.
"""
@spec assigns_for(binary) :: map
def assigns_for(component) do
Map.get(components_to_assigns(), component)
end
@doc ~S"""
Takes a route and returns true if it's a route to a liveview module.
"""
@spec is_a_liveview_route?(atom | map) :: boolean
def is_a_liveview_route?(route) do
Map.has_key?(route.metadata, :phoenix_live_view)
end
defp all_keys(some_map) do
keys = Map.keys(some_map)
next_layer = keys
|> Enum.filter(fn key -> is_map(Map.get(some_map, key)) end)
Enum.flat_map(next_layer, &all_keys(Map.get(some_map, &1))) ++ keys
end
defp aggregate_merge(a, b) do
Map.merge(a, b, fn _k, v1, v2 -> List.flatten([v1, v2]) end)
end
@spec application_name :: String.t()
defp application_name do
{:ok, lib_dir} =
(Path.expand("") <> "/lib")
|> File.ls()
lib_dir
|> Enum.filter(&String.ends_with?(&1, "web"))
|> hd
|> String.split("_")
|> Enum.drop(-1)
|> Enum.map(&String.capitalize(&1))
|> Enum.join
end
end