Hooray for pager working now.

This commit is contained in:
Yaro Kasear 2025-08-28 14:41:16 -05:00
parent 52bd0d4c91
commit 8100d221a1
5 changed files with 186 additions and 119 deletions

View file

@ -1,8 +1,21 @@
from typing import List from __future__ import annotations
from typing import Iterable, List, Sequence, Set
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Load, joinedload, selectinload from sqlalchemy.orm import Load, joinedload, selectinload, RelationshipProperty
def default_eager_policy(Model, expand: List[str]) -> List[Load]: class EagerConfig:
def __init__(self, strict: bool = False, max_depth: int = 4):
self.strict = strict
self.max_depth = max_depth
def _rel(cls, name: str) -> RelationshipProperty | None:
return inspect(cls).relationships.get(name)
def _is_expandable(rel: RelationshipProperty) -> bool:
# Skip dynamic or viewonly collections; they dont support eagerload
return rel.lazy != "dynamic"
def default_eager_policy(Model, expand: Sequence[str], cfg: EagerConfig | None = None) -> List[Load]:
""" """
Heuristic: Heuristic:
- many-to-one / one-to-one: joinedload - many-to-one / one-to-one: joinedload
@ -12,31 +25,51 @@ def default_eager_policy(Model, expand: List[str]) -> List[Load]:
if not expand: if not expand:
return [] return []
cfg = cfg or EagerConfig()
# normalize, dedupe, and prefer longer paths over their prefixes
raw: Set[str] = {p.strip() for p in expand if p and p.strip()}
# drop prefixes if a longer path exists (author, author.publisher -> keep only author.publisher)
pruned: Set[str] = set(raw)
for p in raw:
parts = p.split(".")
for i in range(1, len(parts)):
pruned.discard(".".join(parts[:i]))
opts: List[Load] = [] opts: List[Load] = []
seen: Set[tuple] = set()
for path in expand: for path in sorted(pruned):
parts = path.split(".") parts = path.split(".")
if len(parts) > cfg.max_depth:
if cfg.strict:
raise ValueError(f"expand path too deep: {path} (max {cfg.max_depth})")
continue
current_model = Model current_model = Model
current_inspect = inspect(current_model) # build the chain incrementally
loader: Load | None = None
ok = True
# first hop for i, name in enumerate(parts):
rel = current_inspect.relationships.get(parts[0]) rel = _rel(current_model, name)
if not rel: if not rel or not _is_expandable(rel):
continue # silently skip bad names ok = False
attr = getattr(current_model, parts[0])
loader: Load = selectinload(attr) if rel.uselist else joinedload(attr)
current_model = rel.mapper.class_
# nested hops, if any
for name in parts[1:]:
current_inspect = inspect(current_model)
rel = current_inspect.relationships.get(name)
if not rel:
break break
attr = getattr(current_model, name) attr = getattr(current_model, name)
if loader is None:
loader = selectinload(attr) if rel.uselist else joinedload(attr)
else:
loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr) loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr)
current_model = rel.mapper.class_ current_model = rel.mapper.class_
if not ok:
if cfg.strict:
raise ValueError(f"unknown or non-expandable relationship in expand path: {path}")
continue
key = (tuple(parts),)
if loader is not None and key not in seen:
opts.append(loader) opts.append(loader)
seen.add(key)
return opts return opts

View file

@ -18,11 +18,13 @@
{%- endfor -%} {%- endfor -%}
{% endmacro %} {% endmacro %}
{% macro rows(items, fields, getp=None) -%} {% macro rows(items, fields, getp=None, model=None) -%}
{%- for obj in items -%} {%- for obj in items -%}
<tr id="row-{{ obj.id }}" style="cursor: pointer;"> <tr id="row-{{ obj.id }}" style="cursor: pointer;">
{%- for f in fields -%} {%- for f in fields -%}
<td data-field="{{ f }}" class="text-nowrap">{{ getp(obj, f) if getp(obj, f) is not none else '-' }}</td> <td data-field="{{ f }}" class="text-nowrap" {% if model %}
onclick="window.location='{{ url_for('main.' + model + '_item', id=obj.id) }}'" {% endif %}>{{ getp(obj, f) if
getp(obj, f) is not none else '-' }}</td>
{%- endfor -%} {%- endfor -%}
</tr> </tr>
{%- else -%} {%- else -%}
@ -32,62 +34,93 @@
{%- endfor -%} {%- endfor -%}
{%- endmacro %} {%- endmacro %}
{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%} {# helper: build hx-get URL with shared params #}
{# {% macro _rows_url(model, page, per_page, sort, filters, fields_csv) -%}
<nav id="pager"> /ui/{{ model }}/frag/rows
{% if page > 1 %} ?page={{ page }}&per_page={{ per_page }}
<button type="button" {%- if sort %}&sort={{ sort }}{% endif -%}
hx-get="/ui/{{ model }}/frag/rows?page=1&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}" {%- if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif -%}
hx-target="#rows" hx-swap="innerHTML">First</button> {%- for k, v in (filters or {}).items() %}&{{ k }}={{ v|urlencode }}{% endfor -%}
<button type="button"
hx-get="/ui/{{ model }}/frag/rows?page={{ page-1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" rel="prev" hx-swap="innerHTML">Prev</button>
{% endif %}
<span>Page {{ page }} / {{ pages }}</span>
{% if page < pages %} <button type="button"
hx-get="/ui/{{ model }}/frag/rows?page={{ page+1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" rel="next" hx-swap="innerHTML">Next</button>
<button type="button"
hx-get="/ui/{{ model }}/frag/rows?page={{ pages }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" hx-swap="innerHTML">Last</button>
{% endif %}
</nav>
#}
{% set p = page|int %}
{% set pg = pages|int %}
{% set prev = [1, p-1]|max %}
{% set nxt = [pg, p+1]|min %}
<nav class="mt-3" aria-label="Pagination">
<ul class="pagination justify-content-center bg-white">
<li class="page-item{{ ' disabled' if p <= 1 }}">
<a class="page-link"
hx-get="/ui/{{ model }}/frag/rows?page={{ prev }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in (filters or {}).items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" hx-swap="innerHTML"
hx-on:click="document.querySelector('#pager-state input[name=page]').value='{{ prev }}'">
Prev
</a>
</li>
<li class="page-item{{ ' disabled' if p >= pg }}">
<a class="page-link"
hx-get="/ui/{{ model }}/frag/rows?page={{ nxt }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in (filters or {}).items() %}&{{k}}={{ v|urlencode }}{% endfor %}"
hx-target="#rows" hx-swap="innerHTML"
hx-on:click="document.querySelector('#pager-state input[name=page]').value='{{ nxt }}'">
Next
</a>
</li>
</ul>
<span>Page {{ p }} / {{ pg }}</span>
</nav>
{%- endmacro %} {%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} {# helper: render one page link or a disabled/current state #}
<form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}" {% macro _page_li(model, label, page, current_page, per_page, sort, filters, fields_csv, attrs='') -%}
hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%- endif {% if page is none %}
-%}> <li class="page-item disabled"><span class="page-link">{{ label }}</span></li>
{% elif page == current_page and label is string and label|int(default=None) is none %}
{# non-numeric current like "…" shouldn't happen, but be safe #}
<li class="page-item active" aria-current="page"><span class="page-link">{{ label }}</span></li>
{% elif page == current_page %}
<li class="page-item active" aria-current="page">
<span class="page-link">{{ label }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" hx-get="{{ _rows_url(model, page, per_page, sort, filters, fields_csv) }}" hx-target="#rows"
hx-swap="innerHTML" data-page="{{ page }}" {{ attrs|safe }}>
{{ label }}
</a>
</li>
{% endif %}
{%- endmacro %}
{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%}
{% set p = page|int %}
{% set pg = pages|int %}
{% set prev = 1 if p <= 1 else p - 1 %} {% set nxt=pg if p>= pg else p + 1 %}
{# window of pages around current #}
{% set win = 2 %}
{% set left = [2, p - win]|max %}
{% set right = [pg - 1, p + win]|min %}
<nav class="mt-3" aria-label="Pagination">
<ul class="pagination justify-content-center bg-white user-select-none" style="cursor: pointer;">
{{ _page_li(model, 'Prev', (None if p <= 1 else prev), p, per_page, sort, filters, fields_csv, 'rel="prev"'
) }} {# always show page 1 #} {{ _page_li(model, 1, 1, p, per_page, sort, filters, fields_csv) }} {#
left ellipsis only if gap> 1 #}
{% if left > 2 %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{# middle window inclusive #}
{% for n in range(left, right + 1) %}
{{ _page_li(model, n, n, p, per_page, sort, filters, fields_csv) }}
{% endfor %}
{# right ellipsis only if gap > 1 #}
{% if right < pg - 1 %} <li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{# last page if more than one total #}
{% if pg > 1 %}
{{ _page_li(model, pg, pg, p, per_page, sort, filters, fields_csv) }}
{% endif %}
{{ _page_li(model, 'Next', (None if p >= pg else nxt), p, per_page, sort, filters, fields_csv,
'rel="next"') }}
</ul>
</nav>
{# keep hidden pager-state in sync with one handler instead of inline click spam #}
<script>
document.currentScript?.previousElementSibling?.addEventListener?.('click', function (e) {
const a = e.target.closest('a.page-link');
if (!a) return;
const targetPage = a.getAttribute('data-page');
if (!targetPage) return;
const inp = document.querySelector('#pager-state input[name=page]');
if (inp) inp.value = targetPage;
}, { capture: true });
</script>
{%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
<form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}"
hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%-
endif -%}>
{%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%} {%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%}
{%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%} {%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%}
<input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}"> <input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}">
@ -101,7 +134,8 @@
maxlength="{{ f.maxlength }}" {% endif %}>{{ f.value or "" }}</textarea> maxlength="{{ f.maxlength }}" {% endif %}>{{ f.value or "" }}</textarea>
{%- elif f.type == "select" -%} {%- elif f.type == "select" -%}
<select id="{{ fid }}" name="{{ f.name }}" {% if f.required %}required{% endif %}> <select id="{{ fid }}" name="{{ f.name }}" {% if f.required %}required{% endif %}>
<option value="">{{ f.placeholder or ("Choose " ~ (f.label or f.name|replace('_',' ')|title)) }}</option> <option value="">{{ f.placeholder or ("Choose " ~ (f.label or f.name|replace('_',' ')|title)) }}
</option>
{% if f.multiple %} {% if f.multiple %}
{% set selected = (f.value or [])|list %} {% set selected = (f.value or [])|list %}
{% for val, lbl in f.choices %} {% for val, lbl in f.choices %}
@ -128,5 +162,5 @@
<div class="actions"> <div class="actions">
<button type="submit">Save</button> <button type="submit">Save</button>
</div> </div>
</form> </form>
{%- endmacro %} {%- endmacro %}

View file

@ -1,2 +1,2 @@
{% import "crudkit/_macros.html" as ui %} {% import "crudkit/_macros.html" as ui %}
{{ ui.rows(items, fields, getp=getp) }} {{ ui.rows(items, fields, getp=getp, model=model) }}

View file

@ -120,7 +120,7 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
s = session(); svc = CrudService(s, default_eager_policy) s = session(); svc = CrudService(s, default_eager_policy)
rows, _ = svc.list(Model, spec) rows, _ = svc.list(Model, spec)
html = render_template("crudkit/rows.html", items=rows, fields=fields, getp=_getp) html = render_template("crudkit/rows.html", items=rows, fields=fields, getp=_getp, model=model)
return html return html

View file

@ -17,7 +17,7 @@ def list_worklog():
return render_template( return render_template(
'table.html', 'table.html',
header=worklog_headers, header=worklog_headers,
model_name='worklog', model_name='work_log',
title="Work Log", title="Work Log",
fields = ['contact.identifier', 'work_item.identifier', 'start_time', 'end_time', 'complete', 'followup', 'analysis'], fields = ['contact.identifier', 'work_item.identifier', 'start_time', 'end_time', 'complete', 'followup', 'analysis'],
entry_route='worklog_item', entry_route='worklog_item',