Add filtering for dropdowns.
This commit is contained in:
parent
5234cbdd61
commit
51520da5af
4 changed files with 87 additions and 8 deletions
|
|
@ -415,6 +415,8 @@ class CRUDService(Generic[T]):
|
||||||
opt = opt.load_only(*cols)
|
opt = opt.load_only(*cols)
|
||||||
query = query.options(opt)
|
query = query.options(opt)
|
||||||
|
|
||||||
|
# inside CRUDService._apply_firsthop_strategies
|
||||||
|
# ...
|
||||||
# NEW: if a first-hop to-one relationship’s target table is present in filter expressions,
|
# NEW: if a first-hop to-one relationship’s target table is present in filter expressions,
|
||||||
# make sure we actually JOIN it (outer) so filters don’t create a cartesian product.
|
# make sure we actually JOIN it (outer) so filters don’t create a cartesian product.
|
||||||
if plan.filter_tables:
|
if plan.filter_tables:
|
||||||
|
|
@ -422,14 +424,19 @@ class CRUDService(Generic[T]):
|
||||||
for rel in mapper.relationships:
|
for rel in mapper.relationships:
|
||||||
if rel.uselist:
|
if rel.uselist:
|
||||||
continue # only first-hop to-one here
|
continue # only first-hop to-one here
|
||||||
target_tbl = getattr(rel.mapper.class_, "__table__", None)
|
target_cls = rel.mapper.class_
|
||||||
|
target_tbl = getattr(target_cls, "__table__", None)
|
||||||
if target_tbl is None:
|
if target_tbl is None:
|
||||||
continue
|
continue
|
||||||
if target_tbl in plan.filter_tables:
|
if target_tbl in plan.filter_tables:
|
||||||
if rel.key in joined_rel_keys:
|
if rel.key in joined_rel_keys:
|
||||||
continue # already joined via join_paths
|
continue # already joined via join_paths
|
||||||
query = query.join(getattr(root_alias, rel.key), isouter=True)
|
|
||||||
|
# alias when joining same-entity relationships (User->User supervisor)
|
||||||
|
ta = aliased(target_cls) if target_cls is self.model else target_cls
|
||||||
|
query = query.join(getattr(root_alias, rel.key).of_type(ta), isouter=True)
|
||||||
joined_rel_keys.add(rel.key)
|
joined_rel_keys.add(rel.key)
|
||||||
|
|
||||||
if log.isEnabledFor(logging.DEBUG):
|
if log.isEnabledFor(logging.DEBUG):
|
||||||
info = []
|
info = []
|
||||||
for base_alias, rel_attr, target_alias in plan.join_paths:
|
for base_alias, rel_attr, target_alias in plan.join_paths:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ from sqlalchemy.orm.base import NO_VALUE
|
||||||
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
|
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import crudkit
|
||||||
|
|
||||||
_ALLOWED_ATTRS = {
|
_ALLOWED_ATTRS = {
|
||||||
"class", "placeholder", "autocomplete", "inputmode", "pattern",
|
"class", "placeholder", "autocomplete", "inputmode", "pattern",
|
||||||
"min", "max", "step", "maxlength", "minlength",
|
"min", "max", "step", "maxlength", "minlength",
|
||||||
|
|
@ -107,6 +109,53 @@ def register_template_globals(app=None):
|
||||||
app.add_template_global(fn, name)
|
app.add_template_global(fn, name)
|
||||||
installed.add(name)
|
installed.add(name)
|
||||||
|
|
||||||
|
def _fields_for_label_params(label_spec, related_model):
|
||||||
|
"""
|
||||||
|
Build a 'fields' list suitable for CRUDService.list() so labels render
|
||||||
|
without triggering lazy loads. Always includes 'id'.
|
||||||
|
"""
|
||||||
|
simple_cols, rel_paths = _extract_label_requirements(label_spec, related_model)
|
||||||
|
fields = set(["id"])
|
||||||
|
for c in simple_cols:
|
||||||
|
fields.add(c)
|
||||||
|
for rel_name, col_name in rel_paths:
|
||||||
|
if col_name == "__all__":
|
||||||
|
# just ensure relationship object is present; ask for rel.id
|
||||||
|
fields.add(f"{rel_name}.id")
|
||||||
|
else:
|
||||||
|
fields.add(f"{rel_name}.{col_name}")
|
||||||
|
return list(fields)
|
||||||
|
|
||||||
|
def _fk_options_via_service(related_model, label_spec, *, options_params: dict | None = None):
|
||||||
|
svc = crudkit.crud.get_service(related_model)
|
||||||
|
|
||||||
|
# default to unlimited results for dropdowns
|
||||||
|
params = {"limit": 0}
|
||||||
|
if options_params:
|
||||||
|
params.update(options_params) # caller can override limit if needed
|
||||||
|
|
||||||
|
# ensure fields needed to render the label are present (avoid lazy loads)
|
||||||
|
fields = _fields_for_label_params(label_spec, related_model)
|
||||||
|
if fields:
|
||||||
|
existing = params.get("fields")
|
||||||
|
if isinstance(existing, str):
|
||||||
|
existing = [s.strip() for s in existing.split(",") if s.strip()]
|
||||||
|
if isinstance(existing, (list, tuple)):
|
||||||
|
params["fields"] = list(dict.fromkeys(list(existing) + fields))
|
||||||
|
else:
|
||||||
|
params["fields"] = fields
|
||||||
|
|
||||||
|
# only set a default sort if caller didn’t supply one
|
||||||
|
if "sort" not in params:
|
||||||
|
simple_cols, _ = _extract_label_requirements(label_spec, related_model)
|
||||||
|
params["sort"] = (simple_cols[0] if simple_cols else "id")
|
||||||
|
|
||||||
|
rows = svc.list(params)
|
||||||
|
return [
|
||||||
|
{"value": str(r.id), "label": _label_from_obj(r, label_spec)}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
def expand_projection(model_cls, fields):
|
def expand_projection(model_cls, fields):
|
||||||
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
|
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
|
||||||
out = set(fields)
|
out = set(fields)
|
||||||
|
|
@ -647,10 +696,12 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
|
||||||
if "label_deps" in spec:
|
if "label_deps" in spec:
|
||||||
field["label_deps"] = spec["label_deps"]
|
field["label_deps"] = spec["label_deps"]
|
||||||
|
|
||||||
|
opts_params = spec.get("options_params") or spec.get("options_filter") or spec.get("options_where")
|
||||||
|
|
||||||
if rel_prop:
|
if rel_prop:
|
||||||
if field["type"] is None:
|
if field["type"] is None:
|
||||||
field["type"] = "select"
|
field["type"] = "select"
|
||||||
if field["type"] == "select" and field.get("options") is None and session is not None:
|
if field["type"] == "select" and field.get("options") is None:
|
||||||
related_model = rel_prop.mapper.class_
|
related_model = rel_prop.mapper.class_
|
||||||
label_spec = (
|
label_spec = (
|
||||||
spec.get("label_spec")
|
spec.get("label_spec")
|
||||||
|
|
@ -658,7 +709,11 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
|
||||||
or getattr(related_model, "__crud_label__", None)
|
or getattr(related_model, "__crud_label__", None)
|
||||||
or "id"
|
or "id"
|
||||||
)
|
)
|
||||||
field["options"] = _fk_options(session, related_model, label_spec)
|
field["options"] = _fk_options_via_service(
|
||||||
|
related_model,
|
||||||
|
label_spec,
|
||||||
|
options_params=opts_params
|
||||||
|
)
|
||||||
return field
|
return field
|
||||||
|
|
||||||
col = mapper.columns.get(name)
|
col = mapper.columns.get(name)
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,8 @@ def _fields_for_model(model: str):
|
||||||
"row": "name", "wrap": {"class": "col-3"}},
|
"row": "name", "wrap": {"class": "col-3"}},
|
||||||
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"},
|
{"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"},
|
||||||
"label_spec": "{label}", "row": "details", "wrap": {"class": "col-3"},
|
"label_spec": "{label}", "row": "details", "wrap": {"class": "col-3"},
|
||||||
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}},
|
"attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}},
|
||||||
|
"options_params": {"active__eq": True, "staff__eq": True}},
|
||||||
{"name": "location", "label": "Room", "label_attrs": {"class": "form-label"},
|
{"name": "location", "label": "Room", "label_attrs": {"class": "form-label"},
|
||||||
"label_spec": "{name} - {room_function.description}",
|
"label_spec": "{name} - {room_function.description}",
|
||||||
"row": "details", "wrap": {"class": "col-3"}, "attrs": {"class": "form-control"}},
|
"row": "details", "wrap": {"class": "col-3"}, "attrs": {"class": "form-control"}},
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,21 @@
|
||||||
#}
|
#}
|
||||||
{% if options %}
|
{% if options %}
|
||||||
{% if value %}
|
{% if value %}
|
||||||
{% set sel_label = (options | selectattr('value', 'equalto', value) | first)['label'] %}
|
{% set opts = options or [] %}
|
||||||
|
{% set selected = opts | selectattr('value', 'equalto', value) | list %}
|
||||||
|
{% if not selected %}
|
||||||
|
{# try again with string coercion to handle int vs str mismatch #}
|
||||||
|
{% set selected = opts | selectattr('value', 'equalto', value|string) | list %}
|
||||||
|
{% endif %}
|
||||||
|
{% set sel = selected[0] if selected else none %}
|
||||||
|
|
||||||
|
{% if sel %}
|
||||||
|
{% set sel_label = sel['label'] %}
|
||||||
|
{% elif value_label %}
|
||||||
|
{% set sel_label = value_label %}
|
||||||
|
{% else %}
|
||||||
|
{% set sel_label = "-- Select --" %}
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set sel_label = "-- Select --" %}
|
{% set sel_label = "-- Select --" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -41,11 +55,13 @@
|
||||||
id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')">
|
id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')">
|
||||||
{% for opt in options %}
|
{% for opt in options %}
|
||||||
<li><a class="dropdown-item{% if opt.value|string == value|string %} active{% endif %}"
|
<li><a class="dropdown-item{% if opt.value|string == value|string %} active{% endif %}"
|
||||||
data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')" id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }}</a></li>
|
data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')"
|
||||||
|
id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No selection available --</button>
|
<button class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle disabled inventory-dropdown" disabled>-- No
|
||||||
|
selection available --</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value }}">
|
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}" value="{{ value }}">
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue