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 --%>
<%!-- 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