defmodule SelectoComponents.Components.NestedTable do
@moduledoc """
Component for rendering nested tables from subselect results.
Provides expandable/collapsible sections for related data.
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc """
Renders a nested table for subselect results.
## Attributes
- data: The subselect data (JSON array or list of maps)
- config: Configuration for the nested table display
- row_id: Unique identifier for this row (for expand/collapse)
"""
attr(:data, :any, required: true)
attr(:config, :map, required: true)
attr(:row_id, :string, required: true)
attr(:expanded, :boolean, default: false)
def nested_table(assigns) do
assigns =
assigns
|> Map.put(:parsed_data, parse_subselect_data(assigns.data, assigns.config))
|> Map.put(:table_id, "nested_#{assigns.row_id}")
|> Map.put(:column_headers, get_column_headers(assigns))
~H"""
<%= if length(@parsed_data) > 0 do %>
<%= for header <- @column_headers do %>
{header}
<% end %>
<%= for {item, idx} <- Enum.with_index(@parsed_data) do %>
<%= if idx < max_display_rows(@config) do %>
<%= for key <- get_data_keys(@parsed_data, @config) do %>
{format_value(Map.get(item, key, ""))}
<% end %>
<% end %>
<% end %>
<%= if length(@parsed_data) > max_display_rows(@config) do %>
... and {length(@parsed_data) - max_display_rows(@config)} more items
<% end %>
<% else %>
No related {String.downcase(@config.title)} found
<% end %>
"""
end
@doc """
Renders multiple nested tables for a row with subselect data
"""
attr(:row, :map, required: true)
attr(:subselect_configs, :list, default: [])
attr(:row_id, :string, required: true)
def nested_tables(assigns) do
~H"""
<%= for config <- @subselect_configs do %>
<.nested_table
data={Map.get(@row, config.key, [])}
config={config}
row_id={@row_id}
expanded={Map.get(config, :initial_state, :collapsed) == :expanded}
/>
<% end %>
"""
end
# Helper functions (made public for inline rendering)
def parse_subselect_data(data, config \\ %{})
def parse_subselect_data(nil, _config), do: []
def parse_subselect_data(data, config) when is_list(data) do
normalize_subselect_rows(data, config)
end
def parse_subselect_data(data, config) when is_binary(data) do
# Try to parse JSON string
case Jason.decode(data) do
{:ok, parsed} when is_list(parsed) -> normalize_subselect_rows(parsed, config)
_ -> []
end
end
def parse_subselect_data(_, _config), do: []
defp max_display_rows(config) do
Map.get(config, :max_rows, 10)
end
defp toggle_nested_table(table_id) do
JS.toggle(to: "##{table_id}")
|> JS.toggle_class("rotate-90", to: "[data-table='#{table_id}']")
end
defp get_column_headers(assigns) do
case ordered_keys_from_config(assigns.config) do
[] ->
case parse_subselect_data(assigns.data, assigns.config) do
[first | _] when is_map(first) ->
Map.keys(first)
|> Enum.map(&humanize_key/1)
_ ->
[]
end
keys ->
Enum.map(keys, &humanize_key/1)
end
end
def get_data_keys(parsed_data, config \\ %{}) do
case ordered_keys_from_config(config) do
[] ->
case parsed_data do
[first | _] when is_map(first) -> Map.keys(first)
_ -> []
end
keys ->
keys
end
end
def humanize_key(key) when is_binary(key) do
key
|> String.replace("_", " ")
|> String.capitalize()
end
def humanize_key(key), do: to_string(key)
defp extract_field_name(field) when is_binary(field) do
field
|> String.split(".")
|> List.last()
|> String.trim()
end
defp normalize_subselect_rows([], _config), do: []
defp normalize_subselect_rows([first | _] = rows, _config) when is_map(first), do: rows
defp normalize_subselect_rows(rows, config) do
scalar_key = infer_scalar_column_key(config)
Enum.map(rows, fn
item when is_map(item) -> item
item -> %{scalar_key => item}
end)
end
defp infer_scalar_column_key(config) when not is_map(config), do: "value"
defp infer_scalar_column_key(config) do
field_name =
case Map.get(config, :columns, []) do
[first_column | _] ->
case first_column do
{_, field, _} -> extract_field_name(field)
%{field: field} -> extract_field_name(field)
field when is_binary(field) -> extract_field_name(field)
_ -> nil
end
_ ->
nil
end
case field_name do
name when is_binary(name) and name != "" -> name
_ -> "value"
end
end
defp ordered_keys_from_config(config) when is_map(config) do
config
|> Map.get(:columns, [])
|> Enum.map(fn
{_, field, _} -> extract_field_name(field)
%{field: field} -> extract_field_name(field)
field when is_binary(field) -> extract_field_name(field)
_ -> nil
end)
|> Enum.reject(&is_nil/1)
end
defp ordered_keys_from_config(_), do: []
def format_value(value) when is_binary(value), do: value
def format_value(value) when is_number(value), do: to_string(value)
def format_value(nil), do: ""
def format_value(value), do: inspect(value)
@doc """
Renders an inline nested table for subselect results.
Designed to appear as part of the parent table column.
"""
attr(:data, :any, required: true)
attr(:config, :map, required: true)
attr(:row_id, :string, required: true)
def inline_nested_table(assigns) do
assigns =
assigns
|> Map.put(:parsed_data, parse_subselect_data(assigns.data, assigns.config))
~H"""
<%= if length(@parsed_data) > 0 do %>
<%= for key <- get_data_keys(@parsed_data, @config) do %>
{humanize_key(key)}
<% end %>
<%= for {item, _idx} <- Enum.with_index(@parsed_data) do %>
<%= for key <- get_data_keys(@parsed_data, @config) do %>