Current section

Files

Jump to
selecto_components lib selecto_components enhanced_table sorting.ex
Raw

lib/selecto_components/enhanced_table/sorting.ex

defmodule SelectoComponents.EnhancedTable.Sorting do
@moduledoc """
Provides sorting functionality for SelectoComponents tables.
Supports single and multi-column sorting with visual indicators.
"""
use Phoenix.Component
alias SelectoComponents.SafeAtom
alias SelectoComponents.Theme
@doc """
Initialize sort state in the socket assigns.
"""
def init_sort_state(socket) do
Phoenix.Component.assign(socket,
# List of {column, direction} tuples
sort_by: [],
# :single or :multi
sort_mode: :single
)
end
@doc """
Handle sort column click event.
## Parameters
- column: The column identifier to sort by
- socket: The LiveView socket
- multi: Boolean indicating if multi-column sort (shift-click)
"""
def handle_sort_click(column, socket, multi \\ false) do
current_sort = socket.assigns[:sort_by] || []
new_sort =
if multi && socket.assigns[:sort_mode] == :multi do
# Multi-column sorting
update_multi_sort(current_sort, column)
else
# Single column sorting
update_single_sort(current_sort, column)
end
Phoenix.Component.assign(socket, :sort_by, new_sort)
end
@doc """
Apply sorting to Selecto query.
Column-based sorting takes priority over query-based sorting.
"""
def apply_sort_to_query(selecto, sort_by) when is_list(sort_by) and length(sort_by) > 0 do
# Build the new order expressions
order_expressions =
Enum.map(sort_by, fn {column, direction} ->
case direction do
:asc -> column
:desc -> {:desc, column}
_ -> column
end
end)
# Replace the order_by entirely with column-based sorting
# This ensures column sorting takes priority
put_in(selecto.set.order_by, order_expressions)
end
def apply_sort_to_query(selecto, _), do: selecto
@doc """
Get sort indicator for a column.
Returns :asc, :desc, or nil
"""
def get_sort_indicator(column, sort_by) do
case List.keyfind(sort_by || [], column, 0) do
{^column, direction} -> direction
_ -> nil
end
end
@doc """
Get sort position for multi-column sorting.
Returns the position number or nil.
"""
def get_sort_position(column, sort_by) do
sort_by = sort_by || []
case Enum.find_index(sort_by, fn {col, _} -> col == column end) do
nil -> nil
index -> index + 1
end
end
@doc """
Render sort indicator component.
"""
def sort_indicator(assigns) do
assigns = Phoenix.Component.assign_new(assigns, :theme, fn -> Theme.default_theme(:light) end)
indicator = get_sort_indicator(assigns.column, assigns[:sort_by])
position =
if assigns[:show_position] do
get_sort_position(assigns.column, assigns[:sort_by])
else
nil
end
assigns = Phoenix.Component.assign(assigns, indicator: indicator, position: position)
~H"""
<span class="ml-1 inline-flex items-center">
<%= if @position do %>
<span class="mr-1 text-xs" style="color: var(--sc-text-muted);"><%= @position %></span>
<% end %>
<%= case @indicator do %>
<% :asc -> %>
<svg class="h-4 w-4" style="color: var(--sc-accent);" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 10l5-5 5 5H7z"/>
</svg>
<% :desc -> %>
<svg class="h-4 w-4" style="color: var(--sc-accent);" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 10l5 5 5-5H7z"/>
</svg>
<% _ -> %>
<svg class="h-4 w-4 opacity-50" style="color: var(--sc-text-muted);" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 3l-7 7h4v7h6v-7h4L10 3z" opacity="0.3"/>
<path d="M10 17l7-7h-4V3H7v7H3l7 7z" opacity="0.3"/>
</svg>
<% end %>
</span>
"""
end
@doc """
Render sortable column header.
"""
def sortable_header(assigns) do
assigns = Phoenix.Component.assign_new(assigns, :theme, fn -> Theme.default_theme(:light) end)
# Check if resizable is enabled
if Map.get(assigns, :resizable, false) && Map.get(assigns, :column_config) do
column_config =
Map.get(assigns.column_config, assigns.column, %{
width: 150,
min_width: 50,
max_width: 500
})
assigns = Phoenix.Component.assign(assigns, :col_config, column_config)
~H"""
<th
class={"relative px-2 py-3 text-left text-xs font-medium uppercase tracking-wider select-none #{if get_sort_indicator(@column, @sort_by), do: "font-bold", else: ""}"}
style={"width: #{@col_config.width}px; min-width: #{@col_config.min_width}px; max-width: #{@col_config.max_width}px; background: var(--sc-surface-bg-alt); color: var(--sc-text-secondary); border-bottom: 1px solid var(--sc-surface-border);"}
data-column-id={@column}
>
<div
class="flex items-center justify-between cursor-pointer rounded px-2"
style="color: inherit;"
phx-click="sort_column"
phx-value-column={@column}
phx-value-multi={@multi || false}
phx-target={@target}
title={"Click to sort by #{@label}#{if @multi, do: " (Shift+Click for multi-column sort)", else: ""}"}
draggable={if Map.get(assigns, :reorderable, false), do: "true", else: "false"}
phx-hook={if Map.get(assigns, :reorderable, false), do: ".ColumnReorder", else: nil}
id={"col-header-#{@column}"}
data-column-id={@column}
>
<span class="truncate"><%= @label %></span>
<.sort_indicator theme={@theme} column={@column} sort_by={@sort_by} show_position={@multi} />
</div>
<%!-- Resize handle --%>
<%= if Map.get(assigns, :resizable, false) do %>
<div
class="absolute top-0 right-0 bottom-0 w-1 cursor-col-resize transition-colors"
style="background: transparent;"
phx-hook=".ColumnResize"
id={"resize-#{@column}"}
data-column-id={@column}
>
<div class="absolute inset-y-0 -left-1 -right-1 z-10"></div>
</div>
<% end %>
<script :type={Phoenix.LiveView.ColocatedHook} name=".ColumnResize">
export default {
mounted() {
const columnId = this.el.dataset.columnId;
let startX = 0;
let startWidth = 0;
let currentTable = null;
let currentColumn = null;
const handleMouseDown = (event) => {
event.preventDefault();
event.stopPropagation();
currentTable = this.el.closest('table');
if (!currentTable) return;
currentColumn = currentTable.querySelector(`th[data-column-id="${columnId}"]`) || this.el.closest('th');
if (!currentColumn) return;
startX = event.pageX;
startWidth = currentColumn.offsetWidth;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
this.el.classList.add('bg-blue-500');
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (event) => {
if (!currentColumn) return;
const diff = event.pageX - startX;
const newWidth = Math.max(50, Math.min(500, startWidth + diff));
currentColumn.style.width = `${newWidth}px`;
currentColumn.style.minWidth = `${newWidth}px`;
currentColumn.style.maxWidth = `${newWidth}px`;
const columnIndex = Array.from(currentColumn.parentElement.children).indexOf(currentColumn);
const rows = currentTable.querySelectorAll('tbody tr');
rows.forEach((row) => {
const cell = row.children[columnIndex];
if (cell) {
cell.style.width = `${newWidth}px`;
cell.style.minWidth = `${newWidth}px`;
cell.style.maxWidth = `${newWidth}px`;
}
});
};
const handleMouseUp = () => {
if (currentColumn) {
this.pushEvent('column_resized', {
column_id: columnId,
width: currentColumn.offsetWidth
});
}
document.body.style.cursor = '';
document.body.style.userSelect = '';
this.el.classList.remove('bg-blue-500');
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
currentColumn = null;
currentTable = null;
};
this.el.addEventListener('mousedown', handleMouseDown);
this.handleMouseDown = handleMouseDown;
this.handleMouseMove = handleMouseMove;
this.handleMouseUp = handleMouseUp;
},
destroyed() {
if (this.handleMouseMove) {
document.removeEventListener('mousemove', this.handleMouseMove);
}
if (this.handleMouseUp) {
document.removeEventListener('mouseup', this.handleMouseUp);
}
if (this.handleMouseDown) {
this.el.removeEventListener('mousedown', this.handleMouseDown);
}
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
};
</script>
<script :type={Phoenix.LiveView.ColocatedHook} name=".ColumnReorder">
export default {
mounted() {
this.handleDragStart = (event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', this.el.dataset.columnId || '');
this.el.classList.add('opacity-60');
};
this.handleDragOver = (event) => {
event.preventDefault();
this.el.classList.add('bg-blue-50');
};
this.handleDragLeave = () => {
this.el.classList.remove('bg-blue-50');
};
this.handleDragEnd = () => {
this.el.classList.remove('opacity-60');
this.el.classList.remove('bg-blue-50');
};
this.handleDrop = (event) => {
event.preventDefault();
this.el.classList.remove('bg-blue-50');
const sourceColumnId = event.dataTransfer.getData('text/plain');
const targetColumnId = this.el.dataset.columnId;
if (!sourceColumnId || !targetColumnId || sourceColumnId === targetColumnId) {
return;
}
const table = this.el.closest('table');
const headerRow = this.el.closest('tr');
const sourceHeader = table?.querySelector(`th[data-column-id="${sourceColumnId}"]`);
const targetHeader = this.el.closest('th');
if (!table || !headerRow || !sourceHeader || !targetHeader) {
return;
}
const headerCells = Array.from(headerRow.children);
const sourceIndex = headerCells.indexOf(sourceHeader);
const targetIndex = headerCells.indexOf(targetHeader);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
return;
}
if (sourceIndex < targetIndex) {
targetHeader.after(sourceHeader);
} else {
targetHeader.before(sourceHeader);
}
table.querySelectorAll('tbody tr').forEach((row) => {
const cells = Array.from(row.children);
const sourceCell = cells[sourceIndex];
const targetCell = cells[targetIndex];
if (!sourceCell || !targetCell) {
return;
}
if (sourceIndex < targetIndex) {
targetCell.after(sourceCell);
} else {
targetCell.before(sourceCell);
}
});
const orderedColumns = Array.from(headerRow.querySelectorAll('th[data-column-id]')).map((cell) => cell.dataset.columnId);
this.pushEvent('reorder_columns', {columns: orderedColumns});
};
this.el.addEventListener('dragstart', this.handleDragStart);
this.el.addEventListener('dragover', this.handleDragOver);
this.el.addEventListener('dragleave', this.handleDragLeave);
this.el.addEventListener('dragend', this.handleDragEnd);
this.el.addEventListener('drop', this.handleDrop);
},
destroyed() {
this.el.removeEventListener('dragstart', this.handleDragStart);
this.el.removeEventListener('dragover', this.handleDragOver);
this.el.removeEventListener('dragleave', this.handleDragLeave);
this.el.removeEventListener('dragend', this.handleDragEnd);
this.el.removeEventListener('drop', this.handleDrop);
}
};
</script>
</th>
"""
else
# Original non-resizable header
~H"""
<th
class={"cursor-pointer select-none px-6 py-3 text-left text-xs font-medium uppercase tracking-wider #{if get_sort_indicator(@column, @sort_by), do: "font-bold", else: ""}"}
style="background: var(--sc-surface-bg-alt); color: var(--sc-text-secondary); border-bottom: 1px solid var(--sc-surface-border);"
phx-click="sort_column"
phx-value-column={@column}
phx-value-multi={@multi || false}
phx-target={@target}
title={"Click to sort by #{@label}#{if @multi, do: " (Shift+Click for multi-column sort)", else: ""}"}
>
<div class="flex items-center justify-between">
<span><%= @label %></span>
<.sort_indicator theme={@theme} column={@column} sort_by={@sort_by} show_position={@multi} />
</div>
</th>
"""
end
end
# Private functions
defp update_single_sort(current_sort, column) do
case List.keyfind(current_sort, column, 0) do
{^column, :asc} -> [{column, :desc}]
# Remove sort
{^column, :desc} -> []
_ -> [{column, :asc}]
end
end
defp update_multi_sort(current_sort, column) do
case List.keyfind(current_sort, column, 0) do
{^column, :asc} ->
# Change to desc
List.keyreplace(current_sort, column, 0, {column, :desc})
{^column, :desc} ->
# Remove from sort
List.keydelete(current_sort, column, 0)
nil ->
# Add to sort
current_sort ++ [{column, :asc}]
end
end
@doc """
Serialize sort state for URL or storage.
"""
def serialize_sort(sort_by) do
Enum.map(sort_by, fn {col, dir} ->
%{"column" => to_string(col), "direction" => to_string(dir)}
end)
end
@doc """
Deserialize sort state from URL or storage.
"""
def deserialize_sort(nil), do: []
def deserialize_sort(sort_data) when is_list(sort_data) do
Enum.map(sort_data, fn
%{"column" => col, "direction" => dir} ->
# Use SafeAtom to prevent atom table exhaustion from user input
col_atom = SafeAtom.to_existing(col)
dir_atom = SafeAtom.to_sort_direction(dir)
if col_atom do
{col_atom, dir_atom}
else
nil
end
_ ->
nil
end)
|> Enum.reject(&is_nil/1)
end
def deserialize_sort(_), do: []
end