{# ========================================================================= Shared sortable table-header macro. Renders a clickable that toggles a server-side sort via URL params ?sort=&dir=. Same URL contract across every onboarded app (Grind, Beany, Auth, Digi). Backend is responsible for parsing the params against an allowlist and applying ORDER BY safely. Two rendering modes via the optional `target=` parameter: * target omitted (default) -> plain only. Full-page reload. Use in Beany/Auth/Digi where pages are not HTMX-driven. * target="#some-id" -> renders BOTH href and hx-get/hx-target/ hx-push-url. HTMX intercepts the click and swaps the partial; the href stays so non-HTMX clients (and right-click "open in new tab") still work. Progressive enhancement. ARIA: emits aria-sort="ascending"|"descending"|"none" so screen readers announce the active column. Visual indicator is a small triangle (\u25b2/ \u25bc) appended to the label. Parameters ---------- label : str — visible header text. col : str — sort key (must be in the route's allowlist). current_sort : str | None — the active sort key for this request. current_dir : str | None — 'asc' | 'desc' (the active direction). base_qs : str — already-stripped query string from the caller; sortable_th appends '&sort=&dir=' to it. The caller MUST strip any pre-existing sort= and dir= params from request.url.query to avoid duplicates. target : str | None — HTMX target selector ('#report-content', etc.) Omit for plain-link mode. indicator : str | None — optional hx-indicator selector ('#report-spinner', '.htmx-indicator', etc.) Only emitted in HTMX mode (target= must also be set). align : 'left'|'center'|'right' — cell text-align (default 'left'). extra_class : str — additional CSS classes on the . ========================================================================= #} {% macro sortable_th(label, col, current_sort, current_dir, base_qs, target=None, indicator=None, align='left', extra_class='') %} {# Normalize current_dir: anything other than 'asc' / 'desc' (None, '', garbage from a tampered URL) is treated as "no direction supplied", which means the column is NOT considered active even if current_sort == col. This avoids rendering aria-sort="ascending" + an up-arrow when the caller passed (col, None) — a common shape on the very first request. #} {%- set active_dir = current_dir if current_dir in ('asc', 'desc') else none -%} {%- set is_active = (current_sort == col and active_dir is not none) -%} {%- set next_dir = 'asc' if (is_active and active_dir == 'desc') else 'desc' -%} {%- set arrow = '\u25bc' if (is_active and active_dir == 'desc') else ('\u25b2' if (is_active and active_dir == 'asc') else '') -%} {%- set aria = 'descending' if (is_active and active_dir == 'desc') else ('ascending' if (is_active and active_dir == 'asc') else 'none') -%} {%- set sep = '&' if base_qs else '' -%} {%- set href = '?' ~ base_qs ~ sep ~ 'sort=' ~ col ~ '&dir=' ~ next_dir -%} {# Tailwind's static class scanner cannot detect dynamically-composed classes like `text-{{ align }}`; if any of text-left/center/right is otherwise unused in the consumer's templates, it would be purged from the build. Materialize the literal class name in a static lookup so all 3 variants are statically present in the rendered template source. #} {%- set align_class = { 'left': 'text-left', 'center': 'text-center', 'right': 'text-right', }.get(align, 'text-left') -%} {{ label }}{% if arrow %} {{ arrow }}{% endif %} {% endmacro %} {# ------------------------------------------------------------------------- strip_sort_params Helper: take request.url.query and return it with sort/dir params removed. Use the result as base_qs= for sortable_th / pivot_sort_th. Keeps every other param (date range, filters, zoom, group_by, ...) intact across sort clicks. By default strips 'sort' and 'dir'. For pages with multiple independent sortable tables (e.g. dayparts with Product x Daypart + Daypart x Category), pass custom param names to strip only one table's params and leave the other table's state intact. Main table uses sort/dir; secondary uses sort2/dir2. For the main table's headers, base_qs preserves sort2/dir2 but drops sort/dir; for the secondary table, the inverse. Usage (single-table default, as a Jinja snippet in a template): set base_qs = strip_sort_params(request.url.query) sortable_th("Revenue", "revenue", sort, dir, base_qs, target="#x", align="right") Usage (two-pivot page): set base_qs = strip_sort_params(request.url.query) set base_qs_2 = strip_sort_params(request.url.query, sort_param='sort2', dir_param='dir2') ------------------------------------------------------------------------- #} {% macro strip_sort_params(qs, sort_param='sort', dir_param='dir') -%} {%- set parts = [] -%} {%- set to_strip = (sort_param, dir_param) -%} {%- for chunk in qs.split('&') -%} {# Match the *key* not the prefix: "sortx=1" must survive, and a bare "sort" / "dir" (no '=') must be rejected. Split on first '=' to extract the key, then compare. #} {%- set key = chunk.split('=', 1)[0] -%} {%- if chunk and key not in to_strip -%} {%- set _ = parts.append(chunk) -%} {%- endif -%} {%- endfor -%} {{ parts | join('&') }} {%- endmacro %} {# ========================================================================= pivot_sort_th Variant of sortable_th for pivot/heatmap tables whose column set is derived at runtime from the data (e.g. hour buckets, payment-type IDs, branch names, daypart slugs). The sort key cannot be checked against a static allowlist the way sortable_th's caller does — the route must validate the key inline against the current column set instead. Same URL contract as sortable_th (?sort=&dir=), same toggle behaviour, same ARIA, same progressive enhancement via target=. Two additional parameters (sort_param, dir_param) let multiple pivot tables share one page without param collision. A page with two pivots uses sort_param='sort' / dir_param='dir' for the primary table and sort_param='sort2' / dir_param='dir2' for the secondary. Parameters ---------- label, col, current_sort, current_dir, base_qs, target, indicator, align, extra_class: same as sortable_th. sort_param : str = 'sort' — URL param name this th writes. dir_param : str = 'dir' — direction param name. IMPORTANT: the caller's base_qs MUST already have *this table's* sort/dir stripped. If the page has two pivot tables, pass distinct base_qs values (see strip_sort_params's multi-param form) so switching one table's sort preserves the other's state. ========================================================================= #} {% macro pivot_sort_th(label, col, current_sort, current_dir, base_qs, target=None, indicator=None, align='center', extra_class='', sort_param='sort', dir_param='dir') %} {%- set active_dir = current_dir if current_dir in ('asc', 'desc') else none -%} {%- set is_active = (current_sort == col and active_dir is not none) -%} {%- set next_dir = 'asc' if (is_active and active_dir == 'desc') else 'desc' -%} {%- set arrow = '\u25bc' if (is_active and active_dir == 'desc') else ('\u25b2' if (is_active and active_dir == 'asc') else '') -%} {%- set aria = 'descending' if (is_active and active_dir == 'desc') else ('ascending' if (is_active and active_dir == 'asc') else 'none') -%} {%- set sep = '&' if base_qs else '' -%} {%- set href = '?' ~ base_qs ~ sep ~ sort_param ~ '=' ~ col ~ '&' ~ dir_param ~ '=' ~ next_dir -%} {%- set align_class = { 'left': 'text-left', 'center': 'text-center', 'right': 'text-right', }.get(align, 'text-center') -%} {{ label }}{% if arrow %} {{ arrow }}{% endif %} {% endmacro %} {# ========================================================================= sort_select Dropdown-based sort control for card-grid pages (e.g. /reports/branches in Grind) that have no column headers to click. Renders two {% for key, opt_label in options %} {% else %} {% endfor %} {% endmacro %}