WIP: Persist member overview sort settings #228
3 changed files with 304 additions and 207 deletions
|
|
@ -43,14 +43,46 @@ Hooks.ComboBox = {
|
||||||
|
|
||||||
destroyed() {
|
destroyed() {
|
||||||
this.el.removeEventListener("keydown", this.handleKeyDown)
|
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, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {_csrf_token: csrfToken},
|
params: () => {
|
||||||
hooks: Hooks
|
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
|
// Listen for custom events from LiveView
|
||||||
window.addEventListener("phx:set-input-value", (e) => {
|
window.addEventListener("phx:set-input-value", (e) => {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
Sets up initial assigns for page title, search query, sort configuration,
|
Sets up initial assigns for page title, search query, sort configuration,
|
||||||
and member selection. Actual data loading happens in `handle_params/3`.
|
and member selection. Actual data loading happens in `handle_params/3`.
|
||||||
|
Reads sorting preferences from cookies if available.
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -52,12 +53,15 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!()
|
||||||
|
|
||||||
|
# Read sorting preferences from cookies (sent via connect_params)
|
||||||
|
{sort_field, sort_order} = get_sort_from_cookies(socket)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Members"))
|
|> assign(:page_title, gettext("Members"))
|
||||||
|> assign(:query, "")
|
|> assign(:query, "")
|
||||||
|> assign_new(:sort_field, fn -> :first_name end)
|
|> assign_new(:sort_field, fn -> sort_field end)
|
||||||
|> assign_new(:sort_order, fn -> :asc end)
|
|> assign_new(:sort_order, fn -> sort_order end)
|
||||||
|> assign(:selected_members, [])
|
|> assign(:selected_members, [])
|
||||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
|> 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)
|
{new_field, new_order} = determine_new_sort(field, socket)
|
||||||
|
|
||||||
socket
|
socket =
|
||||||
|> update_sort_components(socket.assigns.sort_field, new_field, new_order)
|
socket
|
||||||
|> push_sort_url(new_field, new_order)
|
|> 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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -733,4 +744,56 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,208 +1,210 @@
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<div id="member-sort-persistence" phx-hook="MemberSortPersistence">
|
||||||
{gettext("Members")}
|
<.header>
|
||||||
<:actions>
|
{gettext("Members")}
|
||||||
<.button variant="primary" navigate={~p"/members/new"}>
|
<:actions>
|
||||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
<.button variant="primary" navigate={~p"/members/new"}>
|
||||||
</.button>
|
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||||
</:actions>
|
</.button>
|
||||||
</.header>
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SearchBarComponent}
|
module={MvWeb.Components.SearchBarComponent}
|
||||||
id="search-bar"
|
id="search-bar"
|
||||||
query={@query}
|
query={@query}
|
||||||
placeholder={gettext("Search...")}
|
placeholder={gettext("Search...")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.table
|
<.table
|
||||||
id="members"
|
id="members"
|
||||||
rows={@members}
|
rows={@members}
|
||||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
||||||
dynamic_cols={@dynamic_cols}
|
dynamic_cols={@dynamic_cols}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~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
|
<.input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="select_all"
|
name={member.id}
|
||||||
phx-click="select_all"
|
phx-click="select_member"
|
||||||
checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()}
|
phx-value-id={member.id}
|
||||||
aria-label={gettext("Select all members")}
|
checked={member.id in @selected_members}
|
||||||
|
phx-capture-click
|
||||||
|
phx-stop-propagation
|
||||||
|
aria-label={gettext("Select member")}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
/>
|
/>
|
||||||
"""
|
</:col>
|
||||||
}
|
<:col
|
||||||
>
|
:let={member}
|
||||||
<.input
|
label={
|
||||||
type="checkbox"
|
~H"""
|
||||||
name={member.id}
|
<.live_component
|
||||||
phx-click="select_member"
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
phx-value-id={member.id}
|
id={:sort_first_name}
|
||||||
checked={member.id in @selected_members}
|
field={:first_name}
|
||||||
phx-capture-click
|
label={gettext("First name")}
|
||||||
phx-stop-propagation
|
sort_field={@sort_field}
|
||||||
aria-label={gettext("Select member")}
|
sort_order={@sort_order}
|
||||||
role="checkbox"
|
/>
|
||||||
/>
|
"""
|
||||||
</:col>
|
}
|
||||||
<: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>
|
|
||||||
<: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>
|
|
||||||
<: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>
|
|
||||||
<: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>
|
|
||||||
<: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>
|
|
||||||
<: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>
|
|
||||||
<: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>
|
|
||||||
<: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}
|
|
||||||
</:col>
|
|
||||||
<:action :let={member}>
|
|
||||||
<div class="sr-only">
|
|
||||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
|
|
||||||
</:action>
|
|
||||||
|
|
||||||
<: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")}
|
{member.first_name} {member.last_name}
|
||||||
</.link>
|
</:col>
|
||||||
</:action>
|
<:col
|
||||||
</.table>
|
: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>
|
||||||
|
<: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>
|
||||||
|
<: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>
|
||||||
|
<: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>
|
||||||
|
<: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>
|
||||||
|
<: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>
|
||||||
|
<: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}
|
||||||
|
</:col>
|
||||||
|
<:action :let={member}>
|
||||||
|
<div class="sr-only">
|
||||||
|
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
|
||||||
|
</:action>
|
||||||
|
|
||||||
|
<: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")}
|
||||||
|
</.link>
|
||||||
|
</:action>
|
||||||
|
</.table>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue