Current section

Files

Jump to
mavu_list lib mavu_list.ex
Raw

lib/mavu_list.ex

defmodule MavuList do
@moduledoc false
@derive {Inspect, only: [:conf]}
defstruct data: [],
metadata: %{},
tweaks: %{},
conf: %{},
source_id: nil
use Accessible
defguard is_atom_or_binary(value) when is_atom(value) or is_binary(value)
@default_per_page 20
require(Ecto.Query)
def process_list(source, source_id, conf, tweaks \\ %{}) do
generate_state(source_id, conf, tweaks)
|> handle_data(source)
end
def generate_state(source_id, conf \\ %{}, tweaks \\ %{}) do
%__MODULE__{
data: [],
metadata: %{},
conf: conf,
tweaks: handle_incoming_tweaks(tweaks),
source_id: source_id
}
end
def handle_incoming_tweaks(encoded_tweaks) when is_binary(encoded_tweaks) do
encoded_tweaks
|> decode_tweaks_from_string()
# |> MavuUtils.log("mwuits-debug 2022-07-19_09:11 TWEAKS IN", :info)
end
def handle_incoming_tweaks(tweaks), do: tweaks || %{}
def get_columns_from_conf(%__MODULE__{} = state) do
state.conf.columns
|> handle_hidden_field_in_columns_conf()
end
defp handle_hidden_field_in_columns_conf(columns) when is_list(columns) do
columns
|> Enum.map(&handle_hidden_field_in_single_column_conf/1)
end
defp handle_hidden_field_in_single_column_conf(%{hidden: hidden} = conf) do
case hidden do
a when a in ["no", "false", false, :no] -> %{visible: true, editable: true}
a when a in ["yes", "true", true, :yes] -> %{visible: false, editable: true}
"never" -> %{visible: true, editable: false}
"always" -> %{visible: false, editable: false}
end
|> Map.merge(conf)
end
defp handle_hidden_field_in_single_column_conf(conf) when is_map(conf) do
%{visible: true, editable: true}
|> Map.merge(conf)
end
def get_columns_with_tweaks_applied(%__MODULE__{} = state) do
get_columns_from_conf(state)
|> apply_column_tweaks(get_column_tweaks(state))
end
def apply_column_tweaks(columns, column_tweaks)
when is_list(columns) and is_list(column_tweaks) do
tweaks_w_index = Enum.with_index(column_tweaks)
Enum.with_index(columns, 100)
|> Enum.map(fn {col, col_idx} ->
Enum.find(tweaks_w_index, fn {i, _tweak_idx} -> "#{i.name}" == "#{col.name}" end)
|> case do
{%{visible: visible_from_tweak}, tweak_idx} ->
{Map.put(col, :visible, visible_from_tweak), tweak_idx}
_ ->
{col, col_idx}
end
end)
|> Enum.sort_by(fn {_, idx} -> idx end)
|> Enum.map(fn {col, _} -> col end)
end
def prepare_metadata(state, _source) do
state
|> put_in(
[:metadata, :columns],
get_columns_with_tweaks_applied(state) |> Enum.filter(& &1.visible)
)
|> put_in(
[:metadata, :page],
get_in(state, [:tweaks, :page]) |> MavuUtils.if_nil(1)
)
|> put_in(
[:metadata, :per_page],
cond do
is_integer(get_in(state, [:tweaks, :per_page])) -> get_in(state, [:tweaks, :per_page])
is_integer(get_in(state, [:conf, :per_page])) -> get_in(state, [:conf, :per_page])
true -> @default_per_page
end
)
end
def update_metadata(state, source) do
state
|> update_in([:metadata], fn metadata ->
Map.merge(
metadata,
%{total_count: get_length(source, state.conf), count: length(state.data)}
)
end)
end
def handle_data(%__MODULE__{} = state, source) do
state = state |> prepare_metadata(source)
filtered_source =
source
|> apply_filter(state.conf, state.tweaks)
|> MavuList.Ash.apply_ash_filterform(state)
|> MavuList.Ash.autoload_fields(state)
|> apply_sort(state.conf, get_sort_tweaks(state))
paged_source =
filtered_source
|> apply_paging(state.conf, state.metadata.per_page, state.metadata.page)
state
|> update_data(source, paged_source)
|> update_metadata(filtered_source)
end
def apply_filter(source, %{filter: filter} = conf, tweaks) do
apply(filter, [source, conf, tweaks])
end
def apply_filter(source, _, _), do: source
def apply_sort(data, conf, [[colname, direction]]) when is_map(conf) and is_list(data),
do: Enum.sort_by(data, &get_sortable_colval(&1, conf, colname), direction)
def apply_sort(%Ecto.Query{} = query, conf, sort_definitions)
when is_list(sort_definitions) and is_map(conf) do
sort_definitions_for_query = Enum.map(sort_definitions, &handle_sort_definition(&1, conf))
query
|> Ecto.Query.exclude(:order_by)
|> Ecto.Query.order_by(^sort_definitions_for_query)
end
if Code.ensure_loaded?(Ash) do
def apply_sort(%Ash.Query{} = query, conf, sort_definitions)
when is_list(sort_definitions) and is_map(conf) do
sort_definitions_for_query =
Enum.map(sort_definitions, &handle_sort_definition_for_ash(&1, conf))
%{
sort_definitions: sort_definitions,
sort_definitions_for_query: sort_definitions_for_query
}
query
|> Ash.Query.unset(:sort)
|> Ash.Query.sort(sort_definitions_for_query)
end
end
def apply_sort(data, _, _), do: data
def handle_sort_definition([colname, direction], conf) do
db_colname = get_db_colname(conf, colname)
{direction, db_colname}
end
if Code.ensure_loaded?(Ash) do
def handle_sort_definition_for_ash([colname, direction], conf) do
db_colname = get_db_colname(conf, colname)
case Ash.Resource.Info.field(conf.ash_resource, db_colname) do
%Ash.Resource.Calculation{} = calc_conf ->
{db_colname, {get_args_for_calculation(calc_conf, conf[:preset_args]), direction}}
_ ->
case direction do
:asc ->
db_colname
_ ->
{db_colname, direction}
end
end
end
def get_args_for_calculation(%Ash.Resource.Calculation{} = calc_conf, preset_args) do
keys_for_calculation = Enum.map(calc_conf.arguments, & &1.name)
preset_args
|> Map.take(keys_for_calculation)
end
def apply_paging(%Ash.Query{} = query, _conf, per_page, page)
when is_integer(per_page) and is_integer(page) do
query
|> Ash.read!(page: [limit: per_page, offset: per_page * (page - 1)])
|> case do
items when is_list(items) -> items
%{results: items} -> items
end
end
end
def apply_paging(%Ecto.Query{} = query, %{repo: repo} = _conf, per_page, page)
when is_integer(per_page) and is_integer(page) do
query
# |> IO.inspect(label: "mwuits-debug 2021-02-26_17:34 ")
|> Ecto.Query.limit(^per_page)
|> Ecto.Query.offset(^(per_page * (page - 1)))
|> repo.all()
end
def apply_paging(data, _conf, per_page, page)
when is_integer(per_page) and is_integer(page) and is_list(data) do
Enum.slice(data, per_page * (page - 1), per_page)
end
def sort_by(data, conf, _) when is_map(conf) do
data
end
def is_user_sortable?(conf, name) when is_map(conf) and is_atom_or_binary(name) do
if get_col_conf(conf, name)[:user_sortable] == false do
false
else
case get_db_colname(conf, name) do
nil ->
false
valid_colname ->
if "#{valid_colname}" =~ "." do
false
else
true
end
end
end
end
def get_sortable_colval(row, conf, name) when is_map(row) do
get_colval(row, conf, name)
|> case do
str when is_binary(str) -> String.downcase(str)
val -> val
end
end
def get_colval(row, conf, name) when is_map(row) do
get_in(
row,
get_col_path(conf, name)
)
end
def get_col_path(conf, name) when is_map(conf) and is_atom_or_binary(name) do
get_col_conf(conf, name)
|> case do
%{path: path} when is_list(path) -> path
_ -> [name]
end
end
def get_col_path(conf, path) when is_map(conf) and is_list(path), do: path
def get_db_colname(conf, name) when is_map(conf) and is_atom_or_binary(name) do
get_col_conf(conf, name)
|> case do
%{order_by_field: order_by_field} when not is_nil(order_by_field) ->
order_by_field
%{path: [single_field_name]} when is_atom_or_binary(single_field_name) ->
single_field_name
%{path: multiple_fieldnames} when is_list(multiple_fieldnames) ->
nil
_ ->
name
end
end
def get_col_conf(conf, name) when is_map(conf) and is_atom_or_binary(name) do
get_in(conf, [
:columns,
Access.filter(&("#{&1.name}" == "#{name}"))
])
|> List.first()
end
def update_data(%__MODULE__{} = state, _source, filtered_data) do
state
|> put_in([:data], filtered_data)
end
def get_length(source, _) when is_list(source), do: length(source)
if Code.ensure_loaded?(Ash) do
def get_length(%Ash.Query{} = query, _conf) do
Ash.read!(query, page: [limit: 1, count: true])
|> Map.get(:count, 0)
end
end
def get_length(%Ecto.Query{} = query, %{repo: repo} = _conf) do
MavuList.Totals.total_entries(query, repo, [])
# query |> repo.aggregate(:count)
end
def generate_assigns_for_label_component(%__MODULE__{} = state, name)
when is_atom_or_binary(name) do
col = get_col(state, name)
%{
label: get_label(col, name),
direction: get_sort_direction_of_column(state, name),
name: name,
target: get_target(state),
is_user_sortable: is_user_sortable?(state.conf, name)
}
end
def generate_assigns_for_pagination_component(%__MODULE__{} = state) do
%{
items_per_page: state.metadata.per_page,
page: state.metadata.page,
pages_total: ceil(state.metadata.total_count / state.metadata.per_page),
items_total: state.metadata.total_count,
items_from: state.metadata.per_page * (state.metadata.page - 1) + 1,
items_to: min(state.metadata.total_count, state.metadata.per_page * state.metadata.page),
has_next: state.metadata.page < ceil(state.metadata.total_count / state.metadata.per_page),
has_prev: state.metadata.page > 1,
target: get_target(state)
}
end
def generate_assigns_for_searchbox_component(%__MODULE__{} = state) do
%{
keyword: state.tweaks[:keyword] || "",
target: get_target(state)
}
end
def get_target(%__MODULE__{} = state), do: "##{state.source_id}"
def get_sort_direction_of_column(%__MODULE__{} = state, name)
when is_atom_or_binary(name) do
get_sort_tweaks(state)
|> Enum.filter(fn [col_name, _dir] -> col_name == name end)
|> Enum.map(fn [_col_name, dir] -> dir end)
|> List.first()
|> case do
nil -> nil
dir when is_binary(dir) -> dir |> List.to_existing_atom()
dir -> dir
end
end
def get_sort_tweaks(%__MODULE__{} = state) do
case state.tweaks[:sort_by] do
items when is_list(items) -> items
_ -> []
end
end
def get_column_tweaks(%__MODULE__{} = state) do
case state.tweaks[:columns] do
items when is_list(items) -> items
_ -> []
end
end
def get_label(col, name) when is_atom_or_binary(name) and is_map(col),
do: col[:label] || get_label(nil, name)
def get_label(nil, name) when is_atom_or_binary(name),
do: Phoenix.Naming.humanize(name)
def get_col(%__MODULE__{} = state, name) when is_atom_or_binary(name),
do: (state.metadata.columns ++ state.conf.columns) |> Enum.find(&("#{&1.name}" == "#{name}"))
def handle_event(event, msg, source, %__MODULE__{} = state) do
# version A) handle state only
handle_event_in_state(event, msg, source, state)
end
def handle_event(socket, event, msg, source, fieldname)
when is_atom_or_binary(fieldname) do
# version B) handle socket
socket
|> Phoenix.Component.assign(
fieldname,
handle_event_in_state(
event,
msg,
source,
socket.assigns[fieldname]
)
)
|> update_param_in_url(fieldname)
end
def handle_event_in_state("toggle_column", msg, source, %__MODULE__{} = state) do
name = msg["name"] |> String.to_existing_atom()
new_direction =
case get_sort_direction_of_column(state, name) do
:asc -> :desc
:desc -> :asc
_ -> default_sort(state)
end
# state.tweaks |> IO.inspect(label: "mwuits-debug 2021-02-07_23:12 TWEAKS")
state
|> put_in([:tweaks, :sort_by], [[name, new_direction]])
|> put_in([:tweaks, :page], 1)
|> handle_data(source)
end
def handle_event_in_state(
"set_columns",
%{"fdata" => %{"columns" => incoming_columns, "col_order" => col_order_str}},
source,
%__MODULE__{} = state
) do
cols_with_index =
incoming_columns
|> Map.to_list()
|> Enum.map(fn {idx, col} -> {MavuUtils.to_int(idx), col} end)
cols_with_index =
if(col_order_str) do
cols_with_index
|> apply_order_to_cols_with_index(col_order_str)
else
cols_with_index
end
columns =
cols_with_index
|> Enum.sort_by(fn {idx, _} -> idx end)
|> Enum.map(fn {_, col} ->
%{name: col["name"], visible: MavuUtils.true?(col["visible"])}
end)
state
|> put_in([:tweaks, :columns], columns)
|> handle_data(source)
end
def handle_event_in_state("set_page", %{"page" => page}, source, %__MODULE__{} = state) do
pagenum =
MavuUtils.to_int(page)
|> case do
0 -> 1
num -> num
end
state
|> put_in([:tweaks, :page], pagenum)
|> handle_data(source)
end
def handle_event_in_state("set_keyword", %{"keyword" => keyword}, source, %__MODULE__{} = state) do
state
|> put_in([:tweaks, :keyword], String.trim(keyword))
|> put_in([:tweaks, :page], 1)
|> handle_data(source)
end
def handle_event_in_state(
"set_filter",
%{"filter_updates" => new_filter_values},
source,
%__MODULE__{} = state
)
when is_map(new_filter_values) do
cleaned_new_filter_values =
new_filter_values
|> AtomicMap.convert(safe: true, ignore: true)
state
|> update_in([:tweaks, :filters], fn old_filter_values ->
(old_filter_values || %{})
|> Map.merge(cleaned_new_filter_values)
|> Map.to_list()
|> Enum.filter(fn {_key, val} -> MavuUtils.present?(val) end)
|> Map.new()
end)
|> put_in([:tweaks, :page], 1)
|> handle_data(source)
end
def handle_event_in_state(
"set_ash_filterform",
%{"filterform_params" => filterform_params},
source,
%__MODULE__{} = state
) do
state
|> put_in([:tweaks, :ash_filterform], filterform_params)
|> put_in([:tweaks, :page], 1)
|> handle_data(source)
end
def handle_event_in_state("reprocess", _, source, %__MODULE__{} = state) do
state
|> handle_data(source)
end
def update_param_in_url(
%{assigns: %{context: %{current_url: current_url}}} = socket,
fieldname
)
when is_atom_or_binary(fieldname) do
# version A: use real push-event if @context is given in assigns
next_url =
MavuUtils.update_params_in_url(current_url, [
{get_url_param_name(fieldname),
encode_tweaks_to_string(socket.assigns[fieldname][:tweaks])}
])
next_path =
next_url |> URI.parse() |> Map.take(~w(path query)a) |> Map.values() |> Enum.join("?")
socket
|> Phoenix.LiveView.push_patch(to: next_path)
end
def update_param_in_url(socket, fieldname) when is_atom_or_binary(fieldname) do
# version B: update via JS only if no @context is given
socket
|> Phoenix.LiveView.push_event("update_param_in_url", %{
name: get_url_param_name(fieldname),
value: encode_tweaks_to_string(socket.assigns[fieldname][:tweaks])
})
end
def get_url_param_name(fieldname) when is_atom_or_binary(fieldname) do
"#{fieldname}_tweaks"
end
def encode_tweaks_to_string(tweaks) when is_map(tweaks) do
Jason.encode!(tweaks)
end
defp decode_tweaks_from_string(tweaks_str) when is_binary(tweaks_str) do
Jason.decode!(tweaks_str)
|> decode_sort_tweaks()
|> decode_page_tweaks()
|> decode_column_tweaks()
|> decode_keyword_tweaks()
|> decode_filter_tweaks()
|> decode_ash_filterform_tweaks()
end
defp decode_sort_tweaks(%{"sort_by" => sort_by} = tweaks) do
tweaks
|> Map.drop(["sort_by"])
|> Map.put(
:sort_by,
sort_by
|> Enum.map(fn [col, dir] ->
[String.to_existing_atom(col), String.to_existing_atom(dir)]
end)
)
end
defp decode_sort_tweaks(tweaks), do: tweaks
defp decode_page_tweaks(%{"page" => page} = tweaks) do
tweaks
|> Map.drop(["page"])
|> Map.put(:page, page)
end
defp decode_page_tweaks(tweaks), do: tweaks
defp decode_keyword_tweaks(%{"keyword" => keyword} = tweaks) do
tweaks
|> Map.drop(["keyword"])
|> Map.put(:keyword, keyword)
end
defp decode_keyword_tweaks(tweaks), do: tweaks
defp decode_filter_tweaks(%{"filters" => filters} = tweaks) do
tweaks
|> Map.drop(["filters"])
|> Map.put(:filters, filters |> AtomicMap.convert(safe: true, ignore: true))
end
defp decode_filter_tweaks(tweaks), do: tweaks
defp decode_ash_filterform_tweaks(%{"ash_filterform" => ash_filterform} = tweaks) do
tweaks
|> Map.drop(["ash_filterform"])
|> Map.put(:ash_filterform, ash_filterform)
end
defp decode_ash_filterform_tweaks(tweaks), do: tweaks
defp decode_column_tweaks(%{"columns" => columns} = tweaks) do
tweaks
|> Map.drop(["columns"])
|> Map.put(:columns, columns |> AtomicMap.convert(safe: true, ignore: true))
end
defp decode_column_tweaks(tweaks), do: tweaks
def default_sort(%__MODULE__{} = _state), do: :asc
def handle_event_in_state(event, msg, %__MODULE__{} = state) do
{event, msg} |> IO.inspect(label: "unknown mavu_list event")
state
end
defp apply_order_to_cols_with_index(cols_with_index, col_order_str)
when is_list(cols_with_index) and is_binary(col_order_str) do
pos_map = col_order_str |> String.split(",")
cols_with_index
|> Enum.map(fn {idx, col} ->
case Enum.find_index(pos_map, &("#{&1}" == "#{col["name"]}")) do
nil -> {idx, col}
new_idx -> {new_idx, col}
end
end)
end
@builtin_short_names [
map: "Ash.Type.Map",
keyword: "Ash.Type.Keyword",
term: "Ash.Type.Term",
atom: "Ash.Type.Atom",
string: "Ash.Type.String",
integer: "Ash.Type.Integer",
float: "Ash.Type.Float",
duration_name: "Ash.Type.DurationName",
function: "Ash.Type.Function",
boolean: "Ash.Type.Boolean",
struct: "Ash.Type.Struct",
uuid: "Ash.Type.UUID",
binary: "Ash.Type.Binary",
date: "Ash.Type.Date",
time: "Ash.Type.Time",
decimal: "Ash.Type.Decimal",
ci_string: "Ash.Type.CiString",
naive_datetime: "Ash.Type.NaiveDatetime",
utc_datetime: "Ash.Type.UtcDatetime",
utc_datetime_usec: "Ash.Type.UtcDatetimeUsec",
url_encoded_binary: "Ash.Type.UrlEncodedBinary",
union: "Ash.Type.Union",
module: "Ash.Type.Module",
uuid: "AshUUID.UUID"
]
@custom_short_names Application.compile_env(:ash, :custom_types, [])
@short_names @custom_short_names ++ @builtin_short_names
def to_ash_shortname({:array, item_type}) do
"array_of_#{to_ash_shortname(item_type)}"
end
def to_ash_shortname(long_type) do
long_type_str =
long_type
|> to_string()
|> String.replace_prefix("Elixir.", "")
@short_names
|> Enum.find_value(fn {shortname, longname} ->
if longname == long_type_str do
shortname
end
end)
|> MavuUtils.if_nil(long_type_str |> String.replace(".", "") |> Macro.underscore())
end
def get_columns_from_ash_resource(resource_name) when is_atom(resource_name) do
Ash.Resource.Info.attributes(resource_name)
|> Enum.map(fn attr ->
%{
name: attr.name,
type: attr.type |> to_ash_shortname(),
label: label_for_ash_attribute(resource_name, attr.name)
}
end)
end
def label_for_ash_attribute(resource, fieldname)
when is_atom_or_binary(fieldname) do
Ash.Resource.Info.attribute(resource, fieldname)
|> case do
%{description: d} when is_binary(d) -> d
_ -> get_human_name_for_atom(fieldname)
end
end
def get_human_name_for_atom(thing) when is_atom_or_binary(thing) do
"#{thing}"
|> String.split("_")
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
end
end