fix(member-live): guard the composite sort event and apply review cleanup
This commit is contained in:
parent
f2312c24a1
commit
2c8a403cc0
6 changed files with 66 additions and 48 deletions
|
|
@ -923,8 +923,7 @@
|
||||||
* pure CSS anchor positioning (the "⋮" trigger carries anchor-name, this popover
|
* pure CSS anchor positioning (the "⋮" trigger carries anchor-name, this popover
|
||||||
* carries position-anchor). `position-area` drops it just below the trigger,
|
* carries position-anchor). `position-area` drops it just below the trigger,
|
||||||
* right-aligned, and `position-try-fallbacks` flips it above when there is no
|
* right-aligned, and `position-try-fallbacks` flips it above when there is no
|
||||||
* room below. The SubfieldMenu hook only toggles it — no coordinate math. This
|
* room below. The SubfieldMenu hook only toggles it — no coordinate math.
|
||||||
* mirrors the payment-badge popover approach (§548) for a consistent look/feel.
|
|
||||||
*/
|
*/
|
||||||
.popover-menu {
|
.popover-menu {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,20 @@ Hooks.SortableList = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guarded native Popover API toggles: showPopover()/hidePopover() throw on a
|
||||||
|
// detached element or a redundant open/close, so the popover hooks below share
|
||||||
|
// these wrappers instead of repeating the try/catch + :popover-open check.
|
||||||
|
function openPopover(el) {
|
||||||
|
try {
|
||||||
|
if (el && !el.matches(":popover-open")) el.showPopover()
|
||||||
|
} catch (_e) {}
|
||||||
|
}
|
||||||
|
function closePopover(el) {
|
||||||
|
try {
|
||||||
|
if (el && el.matches(":popover-open")) el.hidePopover()
|
||||||
|
} catch (_e) {}
|
||||||
|
}
|
||||||
|
|
||||||
// PopoverTooltip hook: shows a hint (referenced by data-tooltip-id) in the
|
// PopoverTooltip hook: shows a hint (referenced by data-tooltip-id) in the
|
||||||
// browser TOP LAYER via the native Popover API, so it escapes the members
|
// browser TOP LAYER via the native Popover API, so it escapes the members
|
||||||
// table's overflow clipping (overflow-x:auto forces overflow-y:auto, which clips
|
// table's overflow clipping (overflow-x:auto forces overflow-y:auto, which clips
|
||||||
|
|
@ -386,19 +400,15 @@ Hooks.PopoverTooltip = {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.show = () => {
|
this.show = () => {
|
||||||
|
openPopover(this.tip)
|
||||||
|
// Popover is measurable once open; positioning in the same tick avoids a
|
||||||
|
// visible flash (JS runs before the browser paints).
|
||||||
try {
|
try {
|
||||||
if (!this.tip.matches(":popover-open")) this.tip.showPopover()
|
|
||||||
// showPopover() makes it measurable; positioning in the same tick avoids
|
|
||||||
// a visible flash (JS runs before the browser paints).
|
|
||||||
this.position()
|
this.position()
|
||||||
} catch (_e) {}
|
} catch (_e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hide = () => {
|
this.hide = () => closePopover(this.tip)
|
||||||
try {
|
|
||||||
if (this.tip.matches(":popover-open")) this.tip.hidePopover()
|
|
||||||
} catch (_e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onKey = (e) => { if (e.key === "Escape") this.hide() }
|
this.onKey = (e) => { if (e.key === "Escape") this.hide() }
|
||||||
|
|
||||||
|
|
@ -431,8 +441,9 @@ Hooks.PopoverTooltip = {
|
||||||
// sort sub-field. Attached to the "⋮" trigger; data-menu-id names the <ul
|
// sort sub-field. Attached to the "⋮" trigger; data-menu-id names the <ul
|
||||||
// popover> menu. Handles click / keyboard open, roving arrow-key focus,
|
// popover> menu. Handles click / keyboard open, roving arrow-key focus,
|
||||||
// Escape / click-away close, and closes after a selection (the LiveView
|
// Escape / click-away close, and closes after a selection (the LiveView
|
||||||
// re-renders on sort_composite anyway). Positioned with getBoundingClientRect
|
// re-renders on sort_composite anyway). The menu and its hint are placed via CSS
|
||||||
// (portable; no CSS anchor-positioning needed).
|
// anchor positioning (see .popover-menu / .popover-tooltip in app.css), so this
|
||||||
|
// hook does no coordinate math — it only toggles visibility, focus and keyboard.
|
||||||
Hooks.SubfieldMenu = {
|
Hooks.SubfieldMenu = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.menu = document.getElementById(this.el.dataset.menuId)
|
this.menu = document.getElementById(this.el.dataset.menuId)
|
||||||
|
|
@ -442,17 +453,11 @@ Hooks.SubfieldMenu = {
|
||||||
// .popover-tooltip look as the sort-header tooltips; placement is CSS anchor
|
// .popover-tooltip look as the sort-header tooltips; placement is CSS anchor
|
||||||
// positioning (no coordinate math).
|
// positioning (no coordinate math).
|
||||||
this.tip = document.getElementById(this.el.dataset.tooltipId)
|
this.tip = document.getElementById(this.el.dataset.tooltipId)
|
||||||
|
// Only hint while the menu is closed.
|
||||||
this.showTip = () => {
|
this.showTip = () => {
|
||||||
if (!this.tip || this.menu.matches(":popover-open")) return
|
if (!this.menu.matches(":popover-open")) openPopover(this.tip)
|
||||||
try {
|
|
||||||
if (!this.tip.matches(":popover-open")) this.tip.showPopover()
|
|
||||||
} catch (_e) {}
|
|
||||||
}
|
|
||||||
this.hideTip = () => {
|
|
||||||
try {
|
|
||||||
if (this.tip && this.tip.matches(":popover-open")) this.tip.hidePopover()
|
|
||||||
} catch (_e) {}
|
|
||||||
}
|
}
|
||||||
|
this.hideTip = () => closePopover(this.tip)
|
||||||
|
|
||||||
this.items = () => [...this.menu.querySelectorAll('[role="menuitemradio"]')]
|
this.items = () => [...this.menu.querySelectorAll('[role="menuitemradio"]')]
|
||||||
|
|
||||||
|
|
@ -591,10 +596,11 @@ Hooks.SidebarState = {
|
||||||
// approaches the viewport, rather than only once it is fully scrolled into view.
|
// approaches the viewport, rather than only once it is fully scrolled into view.
|
||||||
// An IntersectionObserver rooted at the scroll container with a positive bottom
|
// An IntersectionObserver rooted at the scroll container with a positive bottom
|
||||||
// rootMargin treats the sentinel as visible while it is still that many pixels
|
// rootMargin treats the sentinel as visible while it is still that many pixels
|
||||||
// below the fold, so the next page loads ahead of time. It complements the
|
// below the fold, so the next page loads ahead of time. This is the SOLE load
|
||||||
// built-in phx-viewport-bottom binding (which stays as a reliable backstop);
|
// trigger: the table deliberately carries no phx-viewport-bottom binding, whose
|
||||||
// duplicate loads are harmless because the server guards on "more?" and the
|
// built-in InfiniteScroll would scroll the sentinel row back into view after each
|
||||||
// stream de-duplicates rows by id.
|
// load and cascade into loading every page. Duplicate loads are still harmless —
|
||||||
|
// the server guards on "more?" and the stream de-duplicates rows by id.
|
||||||
Hooks.LoadMorePrefetch = {
|
Hooks.LoadMorePrefetch = {
|
||||||
mounted() {
|
mounted() {
|
||||||
const container = document.getElementById(this.el.dataset.scrollContainer)
|
const container = document.getElementById(this.el.dataset.scrollContainer)
|
||||||
|
|
|
||||||
|
|
@ -1042,7 +1042,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
slot :footer,
|
slot :footer,
|
||||||
doc:
|
doc:
|
||||||
"optional after-rows content rendered as a full-width row in a separate, non-streamed tbody. Combined with viewport_bottom it acts as the infinite-scroll sentinel (e.g. a loading indicator) that fires the load event and disappears once no more pages remain."
|
"optional after-rows content rendered as a full-width row in a separate, non-streamed tbody. Combined with infinite_scroll it holds the infinite-scroll sentinel (a hook in the slot fires the load event, plus e.g. a loading indicator) and disappears once no more pages remain."
|
||||||
|
|
||||||
def table(assigns) do
|
def table(assigns) do
|
||||||
assigns =
|
assigns =
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ defmodule MvWeb.Components.MemberNameSortHeader do
|
||||||
aria-label={gettext("Choose sort field")}
|
aria-label={gettext("Choose sort field")}
|
||||||
aria-describedby="member-subfield-tooltip"
|
aria-describedby="member-subfield-tooltip"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
|
aria-controls="member-subfield-menu"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
phx-hook="SubfieldMenu"
|
phx-hook="SubfieldMenu"
|
||||||
data-menu-id="member-subfield-menu"
|
data-menu-id="member-subfield-menu"
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
# Number of members fetched per keyset page (matches the :overview action's
|
# Number of members fetched per keyset page (matches the :overview action's
|
||||||
# default_limit). Mount loads one page; further pages arrive via infinite
|
# default_limit). Mount loads one page; further pages arrive via infinite
|
||||||
# scroll (phx-viewport-bottom) so the socket never holds the full table.
|
# scroll (an IntersectionObserver sentinel) so the socket never holds the full table.
|
||||||
@page_limit 50
|
@page_limit 50
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
@ -384,12 +384,17 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
# The composite "Member" column's sub-field menu (first name / last name) always
|
# The composite "Member" column's sub-field menu (first name / last name) always
|
||||||
# sorts ascending by the chosen field: it switches the sort key rather than
|
# sorts ascending by the chosen field: it switches the sort key rather than
|
||||||
# toggling direction (a click on the header itself toggles direction).
|
# toggling direction (a click on the header itself toggles direction). The field
|
||||||
def handle_event("sort_composite", %{"field" => field_str}, socket) do
|
# is allow-listed to the two real sub-fields, so a crafted event payload cannot
|
||||||
field = String.to_existing_atom(field_str)
|
# reach String.to_existing_atom with an unknown atom (which would crash the LV).
|
||||||
{:noreply, apply_sort_change(socket, field, :asc)}
|
@impl true
|
||||||
|
def handle_event("sort_composite", %{"field" => field}, socket)
|
||||||
|
when field in ["first_name", "last_name"] do
|
||||||
|
{:noreply, apply_sort_change(socket, String.to_existing_atom(field), :asc)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("sort_composite", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
# Applies a resolved (field, order) sort: updates assigns and the sort-header
|
# Applies a resolved (field, order) sort: updates assigns and the sort-header
|
||||||
# components, reloads the keyset stream from page 1, scrolls to top, and syncs
|
# components, reloads the keyset stream from page 1, scrolls to top, and syncs
|
||||||
# the URL. Shared by the header-click ("sort") and sub-field menu
|
# the URL. Shared by the header-click ("sort") and sub-field menu
|
||||||
|
|
@ -1268,7 +1273,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Fetches the next keyset page (if any) and appends it to the stream and the
|
# Fetches the next keyset page (if any) and appends it to the stream and the
|
||||||
# loaded window. Triggered by phx-viewport-bottom; a no-op once the last page
|
# loaded window. Triggered by the IntersectionObserver sentinel; a no-op once the last page
|
||||||
# has been reached so the bottom sentinel stops fetching.
|
# has been reached so the bottom sentinel stops fetching.
|
||||||
defp load_more(%{assigns: %{more?: false}} = socket), do: socket
|
defp load_more(%{assigns: %{more?: false}} = socket), do: socket
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,17 +100,25 @@ Enum.each(1..count, fn i ->
|
||||||
)
|
)
|
||||||
|
|
||||||
# Give the member fee cycles + a round-robin status so the fee-status column is
|
# Give the member fee cycles + a round-robin status so the fee-status column is
|
||||||
# populated at scale. Only generate when none exist yet (idempotent re-runs).
|
# populated at scale. Cycles are only generated when none exist yet; the status
|
||||||
|
# is (re-)applied on every run so a run interrupted between generation and the
|
||||||
|
# status update still converges (idempotent — the update is a no-op once set).
|
||||||
if not is_nil(member.membership_fee_type_id) do
|
if not is_nil(member.membership_fee_type_id) do
|
||||||
member = Ash.load!(member, :membership_fee_cycles, actor: admin_user_with_role)
|
member = Ash.load!(member, :membership_fee_cycles, actor: admin_user_with_role)
|
||||||
|
|
||||||
|
cycles =
|
||||||
if Enum.empty?(member.membership_fee_cycles) do
|
if Enum.empty?(member.membership_fee_cycles) do
|
||||||
{:ok, cycles, _} =
|
{:ok, generated, _} =
|
||||||
CycleGenerator.generate_cycles_for_member(member.id,
|
CycleGenerator.generate_cycles_for_member(member.id,
|
||||||
skip_lock?: true,
|
skip_lock?: true,
|
||||||
actor: admin_user_with_role
|
actor: admin_user_with_role
|
||||||
)
|
)
|
||||||
|
|
||||||
|
generated
|
||||||
|
else
|
||||||
|
member.membership_fee_cycles
|
||||||
|
end
|
||||||
|
|
||||||
status = Enum.at(statuses, rem(i, length(statuses)))
|
status = Enum.at(statuses, rem(i, length(statuses)))
|
||||||
|
|
||||||
Enum.each(cycles, fn cycle ->
|
Enum.each(cycles, fn cycle ->
|
||||||
|
|
@ -121,7 +129,6 @@ Enum.each(1..count, fn i ->
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
if rem(i, 50) == 0, do: IO.puts(" … #{i}/#{count}")
|
if rem(i, 50) == 0, do: IO.puts(" … #{i}/#{count}")
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue