diff --git a/assets/js/app.js b/assets/js/app.js index e55a06d..5b3f462 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -43,14 +43,46 @@ Hooks.ComboBox = { destroyed() { this.el.removeEventListener("keydown", this.handleKeyDown) + }, +}; + +// MemberSortPersistence hook: Persists sorting order to a cookie +Hooks.MemberSortPersistence = { + mounted() { + this.handleEvent("persist_sort", ({ sort_field, sort_order }) => { + const setCookie = (name, value) => { + const secure = window.location.protocol === "https:" ? "Secure" : ""; + document.cookie = `${name}=${encodeURIComponent( + value + )}; path=/; SameSite=Lax; ${secure}`; + }; + setCookie("member_sort_field", sort_field); + setCookie("member_sort_order", sort_order); + }); + }, +}; + +// Helper to read and decode cookie value +const getCookie = (name) => { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) { + return decodeURIComponent(parts.pop().split(";").shift()); } -} + return null; +}; let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken}, - hooks: Hooks -}) + params: () => { + return { + _csrf_token: csrfToken, + member_sort_field: getCookie("member_sort_field"), + member_sort_order: getCookie("member_sort_order"), + }; + }, + hooks: Hooks, +}); // Listen for custom events from LiveView window.addEventListener("phx:set-input-value", (e) => { diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 85ee4fb..4eb8673 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -39,6 +39,7 @@ defmodule MvWeb.MemberLive.Index do Sets up initial assigns for page title, search query, sort configuration, and member selection. Actual data loading happens in `handle_params/3`. + Reads sorting preferences from cookies if available. """ @impl true def mount(_params, _session, socket) do @@ -52,12 +53,15 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.sort(name: :asc) |> Ash.read!() + # Read sorting preferences from cookies (sent via connect_params) + {sort_field, sort_order} = get_sort_from_cookies(socket) + 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_new(:sort_field, fn -> sort_field end) + |> assign_new(:sort_order, fn -> sort_order end) |> assign(:selected_members, []) |> assign(:custom_fields_visible, custom_fields_visible) @@ -139,9 +143,16 @@ defmodule MvWeb.MemberLive.Index do {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) + socket = + socket + |> update_sort_components(socket.assigns.sort_field, new_field, new_order) + # Persist sorting to cookie via JavaScript hook + |> push_event("persist_sort", %{ + sort_field: new_field, + sort_order: new_order + }) + + push_sort_url(socket, new_field, new_order) end @impl true @@ -733,4 +744,56 @@ defmodule MvWeb.MemberLive.Index do nil end end + + # Retrieves sorting preferences from cookies. + # + # Reads the member_sort_field and member_sort_order from connect_params (sent by JavaScript). + # Falls back to default values (:first_name, :asc) if cookies are not present or invalid. + # + # Parameters: + # - `socket` - The LiveView socket containing connect_params + # + # Returns: + # - `{field, order}` tuple where field is an atom or string, and order is :asc or :desc + defp get_sort_from_cookies(socket) do + connect_params = get_connect_params(socket) || %{} + + sort_field_str = connect_params["member_sort_field"] + sort_order_str = connect_params["member_sort_order"] + + field = parse_and_validate_field(sort_field_str) + order = parse_and_validate_order(sort_order_str) + + {field, order} + end + + # Parses and validates a sort field with configurable default. + # + # Handles both regular member fields (converted to atoms) and custom fields + # (kept as strings). Validates against allowed sort fields. + # + # Parameters: + # - `field` - Field to parse (string or nil) + # - `default` - Default field to use if parsing fails (default: :first_name) + # + # Returns a valid sort field (atom or string for custom fields). + defp parse_and_validate_field(field, default \\ :first_name) + + defp parse_and_validate_field(field_str, default) when is_binary(field_str) do + if valid_sort_field?(field_str), do: field_str, else: default + end + + defp parse_and_validate_field(_, default), do: default + + # Parses and validates a sort order with configurable default. + # + # Ensures the order is either :asc or :desc. + # + # Parameters: + # - `order` - Order to parse, as a string or nil + # + # Returns `:asc` or `:desc`. + defp parse_and_validate_order("asc"), do: :asc + defp parse_and_validate_order("desc"), do: :desc + defp parse_and_validate_order(_), do: :asc end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 67fa804..3fb7a22 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -1,12 +1,13 @@ - <.header> - {gettext("Members")} - <:actions> - <.button variant="primary" navigate={~p"/members/new"}> - <.icon name="hero-plus" /> {gettext("New Member")} - - - +
+ <.header> + {gettext("Members")} + <:actions> + <.button variant="primary" navigate={~p"/members/new"}> + <.icon name="hero-plus" /> {gettext("New Member")} + + + <.live_component module={MvWeb.Components.SearchBarComponent} @@ -205,4 +206,5 @@ +