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..8599d4a 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -1,208 +1,210 @@ - <.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} - id="search-bar" - query={@query} - placeholder={gettext("Search...")} - /> + <.live_component + module={MvWeb.Components.SearchBarComponent} + id="search-bar" + query={@query} + placeholder={gettext("Search...")} + /> - <.table - id="members" - rows={@members} - row_click={fn member -> JS.navigate(~p"/members/#{member}") end} - dynamic_cols={@dynamic_cols} - sort_field={@sort_field} - sort_order={@sort_order} - > - + <.table + id="members" + rows={@members} + row_click={fn member -> JS.navigate(~p"/members/#{member}") end} + dynamic_cols={@dynamic_cols} + sort_field={@sort_field} + sort_order={@sort_order} + > + - <:col - :let={member} - label={ - ~H""" + <:col + :let={member} + label={ + ~H""" + <.input + type="checkbox" + name="select_all" + phx-click="select_all" + checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()} + aria-label={gettext("Select all members")} + role="checkbox" + /> + """ + } + > <.input type="checkbox" - name="select_all" - phx-click="select_all" - checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()} - aria-label={gettext("Select all members")} + name={member.id} + phx-click="select_member" + phx-value-id={member.id} + checked={member.id in @selected_members} + phx-capture-click + phx-stop-propagation + aria-label={gettext("Select member")} role="checkbox" /> - """ - } - > - <.input - type="checkbox" - name={member.id} - phx-click="select_member" - phx-value-id={member.id} - checked={member.id in @selected_members} - phx-capture-click - phx-stop-propagation - aria-label={gettext("Select member")} - role="checkbox" - /> - - <:col - :let={member} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_first_name} - field={:first_name} - label={gettext("First name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.first_name} {member.last_name} - - <:col - :let={member} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_email} - field={:email} - label={gettext("Email")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.email} - - <:col - :let={member} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_street} - field={:street} - label={gettext("Street")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.street} - - <:col - :let={member} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_house_number} - field={:house_number} - label={gettext("House Number")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.house_number} - - <:col - :let={member} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_postal_code} - field={:postal_code} - label={gettext("Postal Code")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.postal_code} - - <:col - :let={member} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_city} - field={:city} - label={gettext("City")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.city} - - <:col - :let={member} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_phone_number} - field={:phone_number} - label={gettext("Phone Number")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.phone_number} - - <:col - :let={member} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_join_date} - field={:join_date} - label={gettext("Join Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.join_date} - - <:action :let={member}> -
- <.link navigate={~p"/members/#{member}"}>{gettext("Show")} -
- - <.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")} - - - <:action :let={member}> - <.link - phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")} - data-confirm={gettext("Are you sure?")} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_first_name} + field={:first_name} + label={gettext("First name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } > - {gettext("Delete")} - - - + {member.first_name} {member.last_name} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_email} + field={:email} + label={gettext("Email")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.email} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_street} + field={:street} + label={gettext("Street")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.street} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_house_number} + field={:house_number} + label={gettext("House Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.house_number} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_postal_code} + field={:postal_code} + label={gettext("Postal Code")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_city} + field={:city} + label={gettext("City")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.city} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_phone_number} + field={:phone_number} + label={gettext("Phone Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.phone_number} + + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_join_date} + field={:join_date} + label={gettext("Join Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.join_date} + + <:action :let={member}> +
+ <.link navigate={~p"/members/#{member}"}>{gettext("Show")} +
+ + <.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")} + + + <:action :let={member}> + <.link + phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")} + data-confirm={gettext("Are you sure?")} + > + {gettext("Delete")} + + + +