defmodule SelectoComponents.EnhancedTable.BulkActions do
@moduledoc """
Bulk actions interface for performing operations on multiple selected records.
"""
use Phoenix.LiveComponent
alias SelectoComponents.EnhancedTable.RowSelection
alias Phoenix.LiveView.JS
@export_icon_svg ~s()
@delete_icon_svg ~s()
@trusted_icons MapSet.new([@export_icon_svg, @delete_icon_svg])
@impl true
def mount(socket) do
{:ok,
socket
|> RowSelection.init_selection()
|> assign(
bulk_action: nil,
processing: false,
processed_count: 0,
total_to_process: 0,
errors: [],
show_confirmation: false,
confirmation_message: nil
)}
end
@impl true
def update(assigns, socket) do
actions = assigns[:actions] || default_actions()
socket =
socket
|> assign(assigns)
|> assign(actions: actions)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<%!-- Bulk Actions Toolbar --%>
<%!-- Selection Info --%>
<%= if @selection_count > 0 do %>
{@selection_count} selected
<% end %>
<%!-- Actions Dropdown --%>
<%= for action <- @actions do %>
{render_action_item(assigns, action)}
<% end %>
<%!-- Quick Actions --%>
<%= for action <- Enum.filter(@actions, & &1.quick_action) do %>
<% end %>
<%!-- Progress Bar --%>
<%= if @processing do %>
Processing {@processed_count} of {@total_to_process} items...
<% end %>
<%!-- Error Display --%>
<%= if length(@errors) > 0 do %>
{length(@errors)} {if length(@errors) == 1, do: "error", else: "errors"} occurred
<%= for error <- Enum.take(@errors, 3) do %>
- • {error}
<% end %>
<%= if length(@errors) > 3 do %>
- ...and {length(@errors) - 3} more
<% end %>
<% end %>
<%!-- Confirmation Dialog --%>
<%= if @show_confirmation do %>
Confirm Bulk Action
{@confirmation_message}
This will affect {@selection_count} {if @selection_count == 1,
do: "item",
else: "items"}.
<% end %>
"""
end
defp render_action_item(assigns, action) do
assigns = assign(assigns, :action, action)
~H"""
<%= if @action.description do %>
{@action.description}
<% end %>
"""
end
# Event handlers
@impl true
def handle_event("execute_action", %{"action" => action_id}, socket) do
action = Enum.find(socket.assigns.actions, &(&1.id == action_id))
if action && action.requires_confirmation do
{:noreply,
socket
|> assign(
show_confirmation: true,
confirmation_message:
action.confirmation_message || "Are you sure you want to perform this action?",
bulk_action: action
)}
else
{:noreply, execute_bulk_action(socket, action)}
end
end
def handle_event("confirm_action", _params, socket) do
{:noreply,
socket
|> assign(show_confirmation: false)
|> execute_bulk_action(socket.assigns.bulk_action)}
end
def handle_event("cancel_action", _params, socket) do
{:noreply, assign(socket, show_confirmation: false, bulk_action: nil)}
end
def handle_event("cancel_processing", _params, socket) do
send(self(), :cancel_bulk_processing)
{:noreply, assign(socket, processing: false)}
end
def handle_event("dismiss_errors", _params, socket) do
{:noreply, assign(socket, errors: [])}
end
def handle_event("toggle_row_selection", %{"id" => row_id}, socket) do
{:noreply, RowSelection.toggle_row_selection(socket, row_id)}
end
def handle_event("select_all", _params, socket) do
all_ids = get_all_row_ids(socket)
{:noreply, RowSelection.select_all_rows(socket, all_ids)}
end
def handle_event("select_none", _params, socket) do
{:noreply, RowSelection.clear_selection(socket)}
end
def handle_event("clear_selection", _params, socket) do
{:noreply, RowSelection.clear_selection(socket)}
end
def handle_event("invert_selection", _params, socket) do
all_ids = get_all_row_ids(socket)
{:noreply, RowSelection.invert_selection(socket, all_ids)}
end
def handle_event("select_range", %{"from" => from_id, "to" => to_id}, socket) do
all_ids = get_all_row_ids(socket)
{:noreply, RowSelection.select_range(socket, from_id, to_id, all_ids)}
end
# Bulk action execution
defp execute_bulk_action(socket, nil), do: socket
defp execute_bulk_action(socket, action) do
selected_ids = RowSelection.get_selected_ids(socket)
if length(selected_ids) > 0 do
socket
|> assign(
processing: true,
processed_count: 0,
total_to_process: length(selected_ids),
errors: []
)
|> start_batch_processing(action, selected_ids)
else
socket
end
end
defp start_batch_processing(socket, action, selected_ids) do
batch_size = action[:batch_size] || 10
batches = Enum.chunk_every(selected_ids, batch_size)
# Send first batch for processing
send(self(), {:process_batch, action, batches, 0})
socket
end
def handle_info({:process_batch, action, batches, index}, socket) do
if index < length(batches) do
batch = Enum.at(batches, index)
# Process batch
case process_batch(action, batch) do
{:ok, _results} ->
new_count = socket.assigns.processed_count + length(batch)
socket = assign(socket, processed_count: new_count)
# Schedule next batch
if index + 1 < length(batches) do
Process.send_after(self(), {:process_batch, action, batches, index + 1}, 100)
else
# All done
send(self(), :bulk_processing_complete)
end
{:noreply, socket}
{:error, errors} ->
{:noreply,
socket
|> assign(errors: socket.assigns.errors ++ errors)
|> assign(processing: false)}
end
else
{:noreply, socket}
end
end
def handle_info(:bulk_processing_complete, socket) do
send(self(), {:bulk_action_complete, socket.assigns.bulk_action})
{:noreply,
socket
|> assign(processing: false)
|> RowSelection.clear_selection()}
end
def handle_info(:cancel_bulk_processing, socket) do
{:noreply, assign(socket, processing: false)}
end
# Helper functions
defp process_batch(action, batch_ids) do
# Call the configured action handler if available, otherwise return success
case action do
%{handler: handler} when is_function(handler, 1) ->
# Call custom handler function
handler.(batch_ids)
%{handler: {module, function}} when is_atom(module) and is_atom(function) ->
# Call module function
apply(module, function, [batch_ids])
%{id: action_id} ->
# Send to parent process for handling
send(self(), {:bulk_action_process_batch, action_id, batch_ids})
# Return success - actual processing happens in parent
{:ok, batch_ids}
_ ->
{:ok, batch_ids}
end
end
defp get_all_row_ids(socket) do
# Get row IDs from various possible sources
cond do
# Explicitly provided all_row_ids
is_list(socket.assigns[:all_row_ids]) and length(socket.assigns[:all_row_ids]) > 0 ->
socket.assigns[:all_row_ids]
# Extract from rows data (list of maps with id field)
is_list(socket.assigns[:rows]) ->
extract_ids_from_rows(socket.assigns[:rows])
# Extract from query results (Selecto format)
is_map(socket.assigns[:query_results]) ->
extract_ids_from_query_results(socket.assigns[:query_results])
# Extract from stream data
is_map(socket.assigns[:streams]) and is_map(socket.assigns[:streams][:rows]) ->
# Streams store data differently, try to extract
extract_ids_from_stream(socket.assigns[:streams][:rows])
# Ask parent component for IDs
true ->
send(self(), {:request_all_row_ids, self()})
# Return empty list, parent will send back the IDs
[]
end
end
defp extract_ids_from_rows(rows) when is_list(rows) do
rows
|> Enum.map(fn
%{id: id} ->
id
%{"id" => id} ->
id
row when is_map(row) ->
Map.get(row, :id) || Map.get(row, "id") || Map.get(row, :_id) || Map.get(row, "_id")
_ ->
nil
end)
|> Enum.reject(&is_nil/1)
end
defp extract_ids_from_rows(_), do: []
defp extract_ids_from_query_results(%{rows: rows, columns: columns}) do
# Find id column index
id_index =
Enum.find_index(columns, fn col ->
col_str = to_string(col)
col_str in ["id", "_id", "pk", "primary_key"]
end) || 0
rows
|> Enum.map(fn row ->
case row do
row when is_list(row) -> Enum.at(row, id_index)
row when is_tuple(row) -> elem(row, id_index)
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
end
defp extract_ids_from_query_results(_), do: []
defp extract_ids_from_stream(stream_data) when is_map(stream_data) do
# Phoenix streams store items with DOM IDs as keys
stream_data
|> Map.values()
|> Enum.map(fn
%{id: id} -> id
%{"id" => id} -> id
_ -> nil
end)
|> Enum.reject(&is_nil/1)
end
defp extract_ids_from_stream(_), do: []
@doc """
Set all row IDs for selection operations.
Call this from the parent component to provide IDs for select all functionality.
## Example
def handle_info({:request_all_row_ids, pid}, socket) do
ids = Enum.map(socket.assigns.rows, & &1.id)
send_update(SelectoComponents.EnhancedTable.BulkActions,
id: "bulk-actions",
all_row_ids: ids
)
{:noreply, socket}
end
"""
def set_all_row_ids(component_id, row_ids) do
Phoenix.LiveView.send_update(__MODULE__, id: component_id, all_row_ids: row_ids)
end
defp safe_icon(icon) when is_binary(icon) do
if MapSet.member?(@trusted_icons, icon) do
Phoenix.HTML.raw(icon)
else
nil
end
end
defp safe_icon(_icon), do: nil
defp default_actions do
[
%{
id: "export",
label: "Export",
icon: @export_icon_svg,
quick_action: true,
requires_confirmation: false
},
%{
id: "delete",
label: "Delete",
icon: @delete_icon_svg,
icon_class: "text-red-500",
text_class: "text-red-700",
quick_action: true,
requires_confirmation: true,
confirmation_message:
"This will permanently delete the selected items. This action cannot be undone."
},
%{
divider: true,
id: "archive",
label: "Archive",
description: "Move to archive",
requires_confirmation: true
},
%{
id: "duplicate",
label: "Duplicate",
requires_confirmation: false
},
%{
id: "merge",
label: "Merge",
badge: "Beta",
requires_confirmation: true
}
]
end
defp action_button_class(action, selection_count) do
base = "transition-colors"
disabled =
if selection_count == 0 do
"opacity-50 cursor-not-allowed"
else
""
end
color =
case action.id do
"delete" -> "bg-red-100 text-red-700 hover:bg-red-200"
"export" -> "bg-green-100 text-green-700 hover:bg-green-200"
_ -> "bg-gray-100 text-gray-700 hover:bg-gray-200"
end
"#{base} #{color} #{disabled}"
end
defp progress_percentage(0, 0), do: 0
defp progress_percentage(processed, total) do
round(processed / total * 100)
end
defp toggle_actions_menu do
JS.toggle(
to: "#bulk-actions-menu",
in: {"ease-out duration-100", "opacity-0 scale-95", "opacity-100 scale-100"},
out: {"ease-in duration-75", "opacity-100 scale-100", "opacity-0 scale-95"}
)
end
@doc """
JavaScript hooks for bulk actions.
"""
def __hooks__() do
%{
"BulkActions" => %{
mounted: """
// Handle keyboard shortcuts
this.handleKeydown = (e) => {
// Delete key for delete action
if (e.key === 'Delete' && !e.target.matches('input, textarea')) {
const selectedCount = parseInt(this.el.dataset.selectedCount || '0');
if (selectedCount > 0) {
e.preventDefault();
this.pushEventTo(this.el, 'execute_action', {action: 'delete'});
}
}
};
document.addEventListener('keydown', this.handleKeydown);
""",
destroyed: """
document.removeEventListener('keydown', this.handleKeydown);
"""
},
"ConfirmationDialog" => %{
mounted: """
// Focus on confirm button
const confirmBtn = this.el.querySelector('button[phx-click="confirm_action"]');
if (confirmBtn) {
confirmBtn.focus();
}
// Handle ESC key
this.handleKeydown = (e) => {
if (e.key === 'Escape') {
this.pushEventTo(this.el, 'cancel_action', {});
}
};
document.addEventListener('keydown', this.handleKeydown);
""",
destroyed: """
document.removeEventListener('keydown', this.handleKeydown);
"""
}
}
end
end