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)
loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr) 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)
current_model = rel.mapper.class_ current_model = rel.mapper.class_
opts.append(loader) 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)
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,101 +34,133 @@
{%- endfor -%} {%- endfor -%}
{%- endmacro %} {%- endmacro %}
{# helper: build hx-get URL with shared params #}
{% macro _rows_url(model, page, per_page, sort, filters, fields_csv) -%}
/ui/{{ model }}/frag/rows
?page={{ page }}&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 -%}
{%- endmacro %}
{# helper: render one page link or a disabled/current state #}
{% macro _page_li(model, label, page, current_page, per_page, sort, filters, fields_csv, attrs='') -%}
{% 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) -%} {% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%}
{#
<nav id="pager">
{% if page > 1 %}
<button type="button"
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 %}"
hx-target="#rows" hx-swap="innerHTML">First</button>
<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 p = page|int %}
{% set pg = pages|int %} {% set pg = pages|int %}
{% set prev = [1, p-1]|max %} {% set prev = 1 if p <= 1 else p - 1 %} {% set nxt=pg if p>= pg else p + 1 %}
{% set nxt = [pg, p+1]|min %}
<nav class="mt-3" aria-label="Pagination"> {# window of pages around current #}
<ul class="pagination justify-content-center bg-white"> {% set win = 2 %}
<li class="page-item{{ ' disabled' if p <= 1 }}"> {% set left = [2, p - win]|max %}
<a class="page-link" {% set right = [pg - 1, p + win]|min %}
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 }}"> <nav class="mt-3" aria-label="Pagination">
<a class="page-link" <ul class="pagination justify-content-center bg-white user-select-none" style="cursor: pointer;">
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 %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} {{ _page_li(model, 'Prev', (None if p <= 1 else prev), p, per_page, sort, filters, fields_csv, 'rel="prev"'
<form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}" ) }} {# always show page 1 #} {{ _page_li(model, 1, 1, p, per_page, sort, filters, fields_csv) }} {#
hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%- endif left ellipsis only if gap> 1 #}
-%}> {% if left > 2 %}
{%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%} <li class="page-item disabled"><span class="page-link"></span></li>
{%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%} {% endif %}
<input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}">
{%- for f in schema -%} {# middle window inclusive #}
<div class="field" data-name="{{ f.name }}"> {% for n in range(left, right + 1) %}
{% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %} {{ _page_li(model, n, n, p, per_page, sort, filters, fields_csv) }}
<label for="{{ fid }}">{{ f.label or f.name|replace('_',' ')|title }}</label> {% endfor %}
{%- if f.type == "textarea" -%}
<textarea id="{{ fid }}" name="{{ f.name }}" {%- if f.required %} required{% endif %}{% if f.maxlength %}
maxlength="{{ f.maxlength }}" {% endif %}>{{ f.value or "" }}</textarea>
{%- elif f.type == "select" -%}
<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>
{% if f.multiple %}
{% set selected = (f.value or [])|list %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if val in selected else '' }}>{{ lbl }}</option>
{% endfor %}
{% else %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if (f.value|string)==(val|string) else '' }}>{{ lbl }}</option>
{% endfor %}
{% endif %}
</select>
{%- elif f.type == "checkbox" -%}
<input type="hidden" name="{{ f.name }}" value="0">
<input id="{{ fid }}" type="checkbox" name="{{ f.name }}" value="1" {{ "checked" if f.value else "" }}>
{%- else -%}
<input id="{{ fid }}" type="{{ f.type }}" name="{{ f.name }}"
value="{{ f.value if f.value is not none else '' }}" {%- if f.required %} required{% endif %} {%- if
f.maxlength %} maxlength="{{ f.maxlength }}" {% endif %}>
{%- endif -%}
{%- if f.help %}<div class="help">{{ f.help }}</div>{% endif -%}
</div>
{%- endfor -%}
<div class="actions"> {# right ellipsis only if gap > 1 #}
<button type="submit">Save</button> {% if right < pg - 1 %} <li class="page-item disabled"><span class="page-link"></span></li>
</div> {% endif %}
</form>
{%- endmacro %} {# 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 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') }}">
{%- for f in schema -%}
<div class="field" data-name="{{ f.name }}">
{% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %}
<label for="{{ fid }}">{{ f.label or f.name|replace('_',' ')|title }}</label>
{%- if f.type == "textarea" -%}
<textarea id="{{ fid }}" name="{{ f.name }}" {%- if f.required %} required{% endif %}{% if f.maxlength %}
maxlength="{{ f.maxlength }}" {% endif %}>{{ f.value or "" }}</textarea>
{%- elif f.type == "select" -%}
<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>
{% if f.multiple %}
{% set selected = (f.value or [])|list %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if val in selected else '' }}>{{ lbl }}</option>
{% endfor %}
{% else %}
{% for val, lbl in f.choices %}
<option value="{{ val }}" {{ 'selected' if (f.value|string)==(val|string) else '' }}>{{ lbl }}</option>
{% endfor %}
{% endif %}
</select>
{%- elif f.type == "checkbox" -%}
<input type="hidden" name="{{ f.name }}" value="0">
<input id="{{ fid }}" type="checkbox" name="{{ f.name }}" value="1" {{ "checked" if f.value else "" }}>
{%- else -%}
<input id="{{ fid }}" type="{{ f.type }}" name="{{ f.name }}"
value="{{ f.value if f.value is not none else '' }}" {%- if f.required %} required{% endif %} {%- if
f.maxlength %} maxlength="{{ f.maxlength }}" {% endif %}>
{%- endif -%}
{%- if f.help %}<div class="help">{{ f.help }}</div>{% endif -%}
</div>
{%- endfor -%}
<div class="actions">
<button type="submit">Save</button>
</div>
</form>
{%- 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',