defmodule MvWeb.MemberLive.Index do @moduledoc """ LiveView for displaying and managing the member list. ## Features - Full-text search across member profiles using PostgreSQL tsvector - Sortable columns (name, email, address fields) - Bulk selection for future batch operations - Real-time updates via LiveView - Bookmarkable URLs with query parameters ## URL Parameters - `query` - Search query string for full-text search - `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date) - `sort_order` - Sort direction (:asc or :desc) ## Events - `delete` - Remove a member from the database - `select_member` - Toggle individual member selection - `select_all` - Toggle selection of all visible members ## Implementation Notes - Search uses PostgreSQL full-text search (plainto_tsquery) - Sort state is synced with URL for bookmarkability - Components communicate via `handle_info` for decoupling """ use MvWeb, :live_view @doc """ Initializes the LiveView state. Sets up initial assigns for page title, search query, sort configuration, and member selection. Actual data loading happens in `handle_params/3`. """ @impl true def mount(_params, _session, socket) do socket = socket |> assign(:page_title, gettext("Members")) |> assign(:query, "") |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, []) # We call handle params to use the query from the URL {:ok, socket} end # ----------------------------------------------------------------- # Handle Events # ----------------------------------------------------------------- @doc """ Handles member-related UI events. ## Supported events: - `"delete"` - Removes a member from the database - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members """ @impl true def handle_event("delete", %{"id" => id}, socket) do member = Ash.get!(Mv.Membership.Member, id) Ash.destroy!(member) updated_members = Enum.reject(socket.assigns.members, &(&1.id == id)) {:noreply, assign(socket, :members, updated_members)} end @impl true def handle_event("select_member", %{"id" => id}, socket) do selected = if id in socket.assigns.selected_members do List.delete(socket.assigns.selected_members, id) else [id | socket.assigns.selected_members] end {:noreply, assign(socket, :selected_members, selected)} end @impl true def handle_event("select_all", _params, socket) do members = socket.assigns.members all_ids = Enum.map(members, & &1.id) selected = if Enum.sort(socket.assigns.selected_members) == Enum.sort(all_ids) do [] else all_ids end {:noreply, assign(socket, :selected_members, selected)} end # ----------------------------------------------------------------- # Handle Infos from Child Components # ----------------------------------------------------------------- @doc """ Handles messages from child components. ## Supported messages: - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL """ @impl true def handle_info({:sort, field_str}, socket) do field = String.to_existing_atom(field_str) {new_field, new_order} = determine_new_sort(field, socket) socket |> update_sort_components(socket.assigns.sort_field, new_field, new_order) |> push_sort_url(new_field, new_order) end @impl true def handle_info({:search_changed, q}, socket) do socket = load_members(socket, q) existing_field_query = socket.assigns.sort_field existing_sort_query = socket.assigns.sort_order # Build the URL with queries query_params = %{ "query" => q, "sort_field" => existing_field_query, "sort_order" => existing_sort_query } # Set the new path with params new_path = ~p"/members?#{query_params}" # Push the new URL {:noreply, push_patch(socket, to: new_path, replace: true )} end # ----------------------------------------------------------------- # Handle Params from the URL # ----------------------------------------------------------------- @doc """ Handles URL parameter changes. Parses query parameters for search query, sort field, and sort order, then loads members accordingly. This enables bookmarkable URLs and browser back/forward navigation. """ @impl true def handle_params(params, _url, socket) do socket = socket |> maybe_update_search(params) |> maybe_update_sort(params) |> load_members(params["query"]) {:noreply, socket} end # ------------------------------------------------------------- # FUNCTIONS # ------------------------------------------------------------- # Determines new sort field and order based on current state defp determine_new_sort(field, socket) do if socket.assigns.sort_field == field do {field, toggle_order(socket.assigns.sort_order)} else {field, :asc} end end # Updates both the active and old SortHeader components defp update_sort_components(socket, old_field, new_field, new_order) do active_id = :"sort_#{new_field}" old_id = :"sort_#{old_field}" # Update the new SortHeader send_update(MvWeb.Components.SortHeaderComponent, id: active_id, sort_field: new_field, sort_order: new_order ) # Reset the current SortHeader send_update(MvWeb.Components.SortHeaderComponent, id: old_id, sort_field: new_field, sort_order: new_order ) socket end # Builds sort URL and pushes navigation patch defp push_sort_url(socket, field, order) do query_params = %{ "query" => socket.assigns.query, "sort_field" => Atom.to_string(field), "sort_order" => Atom.to_string(order) } new_path = ~p"/members?#{query_params}" {:noreply, push_patch(socket, to: new_path, replace: true )} end # Load members eg based on a query for sorting defp load_members(socket, search_query) do query = Mv.Membership.Member |> Ash.Query.new() |> Ash.Query.select([ :id, :first_name, :last_name, :email, :street, :house_number, :postal_code, :city, :phone_number, :join_date ]) # Apply the search filter first query = apply_search_filter(query, search_query) # Apply sorting based on current socket state query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order) members = Ash.read!(query) assign(socket, :members, members) end # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- # Function to apply search query defp apply_search_filter(query, search_query) do if search_query && String.trim(search_query) != "" do query |> Mv.Membership.Member.fuzzy_search(%{ query: search_query }) else query end end # Functions to toggle sorting order defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc defp toggle_order(nil), do: :asc # Function to sort the column if needed defp maybe_sort(query, nil, _), do: query defp maybe_sort(query, field, :asc) when not is_nil(field), do: Ash.Query.sort(query, [{field, :asc}]) defp maybe_sort(query, field, :desc) when not is_nil(field), do: Ash.Query.sort(query, [{field, :desc}]) defp maybe_sort(query, _, _), do: query # Validate that a field is sortable defp valid_sort_field?(field) when is_atom(field) do valid_fields = [ :first_name, :last_name, :email, :street, :house_number, :postal_code, :city, :phone_number, :join_date ] field in valid_fields end defp valid_sort_field?(_), do: false # Function to maybe update the sort defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) socket |> assign(:sort_field, field) |> assign(:sort_order, order) end defp maybe_update_sort(socket, _), do: socket defp determine_field(default, sf) do case sf do "" -> default nil -> default sf when is_binary(sf) -> sf |> String.to_existing_atom() |> handle_atom_conversion(default) sf when is_atom(sf) -> handle_atom_conversion(sf, default) _ -> default end end defp handle_atom_conversion(val, default) when is_atom(val) do if valid_sort_field?(val), do: val, else: default end defp handle_atom_conversion(_, default), do: default defp determine_order(default, so) do case so do "" -> default nil -> default so when so in ["asc", "desc"] -> String.to_atom(so) _ -> default end end # Function to update search parameters defp maybe_update_search(socket, %{"query" => query}) when query != "" do assign(socket, :query, query) end defp maybe_update_search(socket, _params) do # Keep the previous search query if no new one is provided socket end end