Add clickable facet pills for mobile-friendly DSL filter composition
All checks were successful
Deploy / deploy (push) Successful in 1m50s

- Create reusable dsl_facets.py component with clickable pills that compose
  DSL filter expressions by appending field:value to the filter input
- Add /api/facets endpoint for dynamic facet count refresh via HTMX
- Fix select dropdown dark mode styling with color-scheme: dark in SelectStyles
- Integrate facet pills into all DSL filter screens: registry, move, and
  all action forms (tag-add, tag-end, attrs, outcome, status-correct)
- Update routes to fetch and pass facet counts, locations, and species
- Add comprehensive unit tests for component and API endpoint
- Add E2E tests for facet pill click behavior and dark mode select visibility

This enables tap-based filter composition on mobile without requiring typing.
Facet counts update dynamically as filters are applied via HTMX.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 22:51:17 +00:00
parent ffef49b931
commit b0fb9726b1
11 changed files with 1055 additions and 27 deletions

View File

@@ -538,6 +538,9 @@ def tag_add_index(request: Request):
roster_hash = ""
animals = []
# Get animal repo for both resolution and facet counts
animal_repo = AnimalRepository(db)
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -546,9 +549,16 @@ def tag_add_index(request: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
# Get facet counts for alive animals
facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
facets = animal_repo.get_facet_counts(facet_filter)
# Get locations and species for facet name lookup
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_all()
return render_page(
request,
tag_add_form(
@@ -558,6 +568,9 @@ def tag_add_index(request: Request):
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
animals=animals,
facets=facets,
locations=locations,
species_list=species_list,
),
title="Add Tag - AnimalTrack",
active_nav=None,
@@ -787,6 +800,9 @@ def tag_end_index(request: Request):
active_tags: list[str] = []
animals = []
# Get animal repo for both resolution and facet counts
animal_repo = AnimalRepository(db)
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -796,9 +812,16 @@ def tag_end_index(request: Request):
roster_hash = compute_roster_hash(resolved_ids, None)
active_tags = _get_active_tags_for_animals(db, resolved_ids)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
# Get facet counts for alive animals
facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
facets = animal_repo.get_facet_counts(facet_filter)
# Get locations and species for facet name lookup
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_all()
return render_page(
request,
tag_end_form(
@@ -809,6 +832,9 @@ def tag_end_index(request: Request):
resolved_count=len(resolved_ids),
active_tags=active_tags,
animals=animals,
facets=facets,
locations=locations,
species_list=species_list,
),
title="End Tag - AnimalTrack",
active_nav=None,
@@ -1012,6 +1038,9 @@ def attrs_index(request: Request):
roster_hash = ""
animals = []
# Get animal repo for both resolution and facet counts
animal_repo = AnimalRepository(db)
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -1020,9 +1049,16 @@ def attrs_index(request: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
# Get facet counts for alive animals
facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
facets = animal_repo.get_facet_counts(facet_filter)
# Get locations and species for facet name lookup
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_all()
return render_page(
request,
attrs_form(
@@ -1032,6 +1068,9 @@ def attrs_index(request: Request):
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
animals=animals,
facets=facets,
locations=locations,
species_list=species_list,
),
title="Update Attributes - AnimalTrack",
active_nav=None,
@@ -1247,6 +1286,9 @@ def outcome_index(request: Request):
roster_hash = ""
animals = []
# Get animal repo for both resolution and facet counts
animal_repo = AnimalRepository(db)
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -1255,13 +1297,20 @@ def outcome_index(request: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
# Get active products for yield items dropdown
product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
# Get facet counts for alive animals
facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
facets = animal_repo.get_facet_counts(facet_filter)
# Get locations and species for facet name lookup
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_all()
return render_page(
request,
outcome_form(
@@ -1272,6 +1321,9 @@ def outcome_index(request: Request):
resolved_count=len(resolved_ids),
products=products,
animals=animals,
facets=facets,
locations=locations,
species_list=species_list,
),
title="Record Outcome - AnimalTrack",
active_nav=None,
@@ -1544,6 +1596,9 @@ async def status_correct_index(req: Request):
resolved_ids: list[str] = []
roster_hash = ""
# Get animal repo for facet counts
animal_repo = AnimalRepository(db)
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
@@ -1552,6 +1607,13 @@ async def status_correct_index(req: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
# Get facet counts (show all statuses for admin correction form)
facets = animal_repo.get_facet_counts(filter_str)
# Get locations and species for facet name lookup
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_all()
return render_page(
req,
status_correct_form(
@@ -1560,6 +1622,9 @@ async def status_correct_index(req: Request):
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
facets=facets,
locations=locations,
species_list=species_list,
),
title="Correct Status - AnimalTrack",
active_nav=None,

View File

@@ -1,17 +1,20 @@
# ABOUTME: API routes for HTMX partial updates.
# ABOUTME: Provides endpoints for selection preview and hash computation.
# ABOUTME: Provides endpoints for selection preview, hash computation, and dynamic facets.
from __future__ import annotations
import time
from fasthtml.common import APIRouter
from fasthtml.common import APIRouter, to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.species import SpeciesRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.web.templates.animal_select import animal_checkbox_list
from animaltrack.web.templates.dsl_facets import dsl_facet_pills
# APIRouter for multi-file route organization
ar = APIRouter()
@@ -97,3 +100,49 @@ def selection_preview(request: Request):
# Render checkbox list for multiple animals
return HTMLResponse(content=to_xml(animal_checkbox_list(animals, selected_ids)))
@ar("/api/facets")
def facets(request: Request):
"""GET /api/facets - Get facet pills HTML for current filter.
Query params:
- filter: DSL filter string (optional)
- include_status: "true" to include status facet (for registry)
Returns HTML partial with facet pills for HTMX outerHTML swap.
The returned HTML has id="dsl-facet-pills" for proper swap targeting.
"""
db = request.app.state.db
filter_str = request.query_params.get("filter", "")
include_status = request.query_params.get("include_status", "").lower() == "true"
# Get facet counts based on current filter
animal_repo = AnimalRepository(db)
if include_status:
# Registry mode: show all statuses, no implicit alive filter
facet_filter = filter_str
else:
# Action form mode: filter to alive animals
if filter_str:
# If filter already includes status, use it as-is
# Otherwise, implicitly filter to alive animals
if "status:" in filter_str:
facet_filter = filter_str
else:
facet_filter = f"status:alive {filter_str}".strip()
else:
facet_filter = "status:alive"
facets = animal_repo.get_facet_counts(facet_filter)
# Get locations and species for name mapping
location_repo = LocationRepository(db)
species_repo = SpeciesRepository(db)
locations = location_repo.list_all()
species_list = species_repo.list_all()
# Render facet pills - filter input ID is "filter" by convention
result = dsl_facet_pills(facets, "filter", locations, species_list, include_status)
return HTMLResponse(content=to_xml(result))

View File

@@ -20,6 +20,7 @@ from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.species import SpeciesRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError
@@ -192,6 +193,9 @@ def move_index(request: Request):
from_location_name = None
animals = []
# Get animal repo for both filter resolution and facet counts
animal_repo = AnimalRepository(db)
if filter_str or not request.query_params:
# If no filter, default to empty (show all alive animals)
filter_ast = parse_filter(filter_str)
@@ -202,9 +206,15 @@ def move_index(request: Request):
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
roster_hash = compute_roster_hash(resolved_ids, from_location_id)
# Fetch animal details for checkbox display
animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolved_ids)
# Get facet counts for alive animals (action forms filter to alive by default)
facet_filter = f"status:alive {filter_str}".strip() if filter_str else "status:alive"
facets = animal_repo.get_facet_counts(facet_filter)
# Get species list for facet name lookup
species_list = SpeciesRepository(db).list_all()
# Get recent events and stats
display_data = _get_move_display_data(db, locations)
@@ -221,6 +231,8 @@ def move_index(request: Request):
from_location_name=from_location_name,
action=animal_move,
animals=animals,
facets=facets,
species_list=species_list,
**display_data,
),
title="Move - AnimalTrack",

View File

@@ -18,8 +18,10 @@ from ulid import ULID
from animaltrack.models.animals import Animal
from animaltrack.models.reference import Location, Species
from animaltrack.repositories.animals import FacetCounts
from animaltrack.selection.validation import SelectionDiff
from animaltrack.web.templates.action_bar import ActionBar
from animaltrack.web.templates.dsl_facets import dsl_facet_pills, dsl_facet_pills_script
# =============================================================================
# Selection Diff Confirmation Panel
@@ -622,7 +624,10 @@ def tag_add_form(
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-add",
animals: list | None = None,
) -> Form:
facets: FacetCounts | None = None,
locations: list[Location] | None = None,
species_list: list[Species] | None = None,
) -> Div:
"""Create the Add Tag form.
Args:
@@ -634,9 +639,12 @@ def tag_add_form(
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
facets: Optional FacetCounts for facet pills display.
locations: Optional list of Locations for facet name lookup.
species_list: Optional list of Species for facet name lookup.
Returns:
Form component for adding tags to animals.
Div component containing facet script and form.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
@@ -686,10 +694,19 @@ def tag_add_form(
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
return Form(
# Build facet pills component if facets provided
facet_pills_component = None
facet_script = None
if facets:
facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
facet_script = dsl_facet_pills_script("filter")
form = Form(
H2("Add Tag", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Facet pills for easy filter composition (tap to add filter terms)
facet_pills_component,
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
@@ -735,6 +752,8 @@ def tag_add_form(
cls="space-y-4",
)
return Div(facet_script, form)
def tag_add_diff_panel(
diff: SelectionDiff,
@@ -788,7 +807,10 @@ def tag_end_form(
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-end",
animals: list | None = None,
) -> Form:
facets: FacetCounts | None = None,
locations: list[Location] | None = None,
species_list: list[Species] | None = None,
) -> Div:
"""Create the End Tag form.
Args:
@@ -801,9 +823,12 @@ def tag_end_form(
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
facets: Optional FacetCounts for facet pills display.
locations: Optional list of Locations for facet name lookup.
species_list: Optional list of Species for facet name lookup.
Returns:
Form component for ending tags on animals.
Div component containing facet script and form.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
@@ -860,10 +885,19 @@ def tag_end_form(
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
return Form(
# Build facet pills component if facets provided
facet_pills_component = None
facet_script = None
if facets:
facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
facet_script = dsl_facet_pills_script("filter")
form = Form(
H2("End Tag", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Facet pills for easy filter composition (tap to add filter terms)
facet_pills_component,
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
@@ -919,6 +953,8 @@ def tag_end_form(
cls="space-y-4",
)
return Div(facet_script, form)
def tag_end_diff_panel(
diff: SelectionDiff,
@@ -971,7 +1007,10 @@ def attrs_form(
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-attrs",
animals: list | None = None,
) -> Form:
facets: FacetCounts | None = None,
locations: list[Location] | None = None,
species_list: list[Species] | None = None,
) -> Div:
"""Create the Update Attributes form.
Args:
@@ -983,9 +1022,12 @@ def attrs_form(
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
facets: Optional FacetCounts for facet pills display.
locations: Optional list of Locations for facet name lookup.
species_list: Optional list of Species for facet name lookup.
Returns:
Form component for updating animal attributes.
Div component containing facet script and form.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
@@ -1063,10 +1105,19 @@ def attrs_form(
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
return Form(
# Build facet pills component if facets provided
facet_pills_component = None
facet_script = None
if facets:
facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
facet_script = dsl_facet_pills_script("filter")
form = Form(
H2("Update Attributes", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Facet pills for easy filter composition (tap to add filter terms)
facet_pills_component,
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
@@ -1121,6 +1172,8 @@ def attrs_form(
cls="space-y-4",
)
return Div(facet_script, form)
def attrs_diff_panel(
diff: SelectionDiff,
@@ -1182,7 +1235,10 @@ def outcome_form(
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-outcome",
animals: list | None = None,
) -> Form:
facets: FacetCounts | None = None,
locations: list[Location] | None = None,
species_list: list[Species] | None = None,
) -> Div:
"""Create the Record Outcome form.
Args:
@@ -1195,9 +1251,12 @@ def outcome_form(
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
facets: Optional FacetCounts for facet pills display.
locations: Optional list of Locations for facet name lookup.
species_list: Optional list of Species for facet name lookup.
Returns:
Form component for recording animal outcomes.
Div component containing facet script and form.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
@@ -1320,9 +1379,18 @@ def outcome_form(
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md space-y-3",
)
return Form(
# Build facet pills component if facets provided
facet_pills_component = None
facet_script = None
if facets:
facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
facet_script = dsl_facet_pills_script("filter")
form = Form(
H2("Record Outcome", cls="text-xl font-bold mb-4"),
error_component,
# Facet pills for easy filter composition (tap to add filter terms)
facet_pills_component,
# Filter field with HTMX to fetch selection preview
LabelInput(
label="Filter (DSL)",
@@ -1379,6 +1447,8 @@ def outcome_form(
cls="space-y-4",
)
return Div(facet_script, form)
def outcome_diff_panel(
diff: SelectionDiff,
@@ -1448,7 +1518,10 @@ def status_correct_form(
resolved_count: int = 0,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-status-correct",
) -> Form:
facets: FacetCounts | None = None,
locations: list[Location] | None = None,
species_list: list[Species] | None = None,
) -> Div:
"""Create the Correct Status form (admin-only).
Args:
@@ -1459,9 +1532,12 @@ def status_correct_form(
resolved_count: Number of resolved animals.
error: Optional error message to display.
action: Route function or URL string for form submission.
facets: Optional FacetCounts for facet pills display.
locations: Optional list of Locations for facet name lookup.
species_list: Optional list of Species for facet name lookup.
Returns:
Form component for correcting animal status.
Div component containing facet script and form.
"""
if resolved_ids is None:
resolved_ids = []
@@ -1508,11 +1584,19 @@ def status_correct_form(
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
return Form(
# Build facet pills component if facets provided
facet_pills_component = None
facet_script = None
if facets:
facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
facet_script = dsl_facet_pills_script("filter")
form = Form(
H2("Correct Animal Status", cls="text-xl font-bold mb-4"),
admin_warning,
error_component,
selection_preview,
# Facet pills for easy filter composition (tap to add filter terms)
facet_pills_component,
# Filter field
LabelInput(
label="Filter (DSL)",
@@ -1521,6 +1605,7 @@ def status_correct_form(
value=filter_str,
placeholder="e.g., species:duck location:Coop1",
),
selection_preview,
# New status selection - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("New Status", _for="new_status"),
@@ -1564,6 +1649,8 @@ def status_correct_form(
cls="space-y-4",
)
return Div(facet_script, form)
def status_correct_diff_panel(
diff: SelectionDiff,

View File

@@ -39,6 +39,12 @@ def SelectStyles(): # noqa: N802
color: #e5e5e5 !important;
-webkit-text-fill-color: #e5e5e5 !important;
}
/* Tell browser to use native dark mode for select dropdown options.
This makes <option> elements readable with light text on dark background.
CSS styling of <option> is limited by browsers, so color-scheme is the fix. */
select, .uk-select {
color-scheme: dark;
}
/* Placeholder text styling */
input::placeholder, textarea::placeholder,
.uk-input::placeholder, .uk-textarea::placeholder {
@@ -46,7 +52,7 @@ def SelectStyles(): # noqa: N802
-webkit-text-fill-color: #737373 !important;
opacity: 1;
}
/* Select dropdown options */
/* Select dropdown options - fallback for browsers that support it */
select option, .uk-select option {
background-color: #1c1c1c;
color: #e5e5e5;

View File

@@ -0,0 +1,169 @@
# ABOUTME: Reusable DSL facet pills component for filter composition.
# ABOUTME: Provides clickable pills that compose DSL filter expressions via JavaScript and HTMX.
from typing import Any
from fasthtml.common import Div, P, Script, Span
from animaltrack.models.reference import Location, Species
from animaltrack.repositories.animals import FacetCounts
def dsl_facet_pills(
facets: FacetCounts,
filter_input_id: str,
locations: list[Location] | None,
species_list: list[Species] | None,
include_status: bool = False,
) -> Div:
"""Render clickable facet pills that compose DSL filter expressions.
This component displays pills for species, sex, life_stage, and location facets.
Clicking a pill appends the corresponding field:value to the filter input and
triggers HTMX updates for both the selection preview and the facet counts.
Args:
facets: FacetCounts with by_species, by_sex, by_life_stage, by_location dicts.
filter_input_id: ID of the filter input element (e.g., "filter").
locations: List of Location objects for name lookup.
species_list: List of Species objects for name lookup.
include_status: If True, include status facet section (for registry).
Defaults to False (action forms filter to alive by default).
Returns:
Div component containing facet pill sections with HTMX attributes.
"""
location_map = {loc.id: loc.name for loc in (locations or [])}
species_map = {s.code: s.name for s in (species_list or [])}
# Build facet sections
sections = []
# Status facet (optional - registry shows all statuses, action forms skip)
if include_status:
sections.append(facet_pill_section("Status", facets.by_status, filter_input_id, "status"))
sections.extend(
[
facet_pill_section(
"Species", facets.by_species, filter_input_id, "species", species_map
),
facet_pill_section("Sex", facets.by_sex, filter_input_id, "sex"),
facet_pill_section("Life Stage", facets.by_life_stage, filter_input_id, "life_stage"),
facet_pill_section(
"Location", facets.by_location, filter_input_id, "location", location_map
),
]
)
# Filter out None sections (empty facets)
sections = [s for s in sections if s is not None]
# Build HTMX URL with include_status param if needed
htmx_url = "/api/facets"
if include_status:
htmx_url = "/api/facets?include_status=true"
return Div(
*sections,
id="dsl-facet-pills",
# HTMX: Refresh facets when filter input changes (600ms after change)
hx_get=htmx_url,
hx_trigger=f"change from:#{filter_input_id} delay:600ms",
hx_include=f"#{filter_input_id}",
hx_swap="outerHTML",
cls="space-y-3 mb-4",
)
def facet_pill_section(
title: str,
counts: dict[str, int],
filter_input_id: str,
field: str,
label_map: dict[str, str] | None = None,
) -> Any:
"""Single facet section with clickable pills.
Args:
title: Section title (e.g., "Species", "Sex").
counts: Dictionary of value -> count.
filter_input_id: ID of the filter input element.
field: Field name for DSL filter (e.g., "species", "sex").
label_map: Optional mapping from value to display label.
Returns:
Div component with facet pills, or None if counts is empty.
"""
if not counts:
return None
# Build inline pill items, sorted by count descending
items = []
for value, count in sorted(counts.items(), key=lambda x: -x[1]):
# Get display label
label = label_map.get(value, value) if label_map else value.replace("_", " ").title()
# Build pill with data attributes and onclick handler
items.append(
Span(
Span(label, cls="text-xs"),
Span(str(count), cls="text-xs text-stone-500 ml-1"),
data_facet_field=field,
data_facet_value=value,
onclick=f"addFacetToFilter('{filter_input_id}', '{field}', '{value}')",
cls="inline-flex items-center px-2 py-1 rounded bg-stone-800 "
"hover:bg-stone-700 cursor-pointer mr-1 mb-1",
)
)
return Div(
P(title, cls="font-semibold text-xs text-stone-400 mb-2"),
Div(
*items,
cls="flex flex-wrap",
),
)
def dsl_facet_pills_script(filter_input_id: str) -> Script:
"""JavaScript for facet pill click handling.
Provides the addFacetToFilter function that:
1. Appends field:value to the filter input
2. Triggers a change event to refresh selection preview and facet counts
Args:
filter_input_id: ID of the filter input element.
Returns:
Script element with the facet interaction JavaScript.
"""
return Script("""
// Add a facet filter term to the filter input
function addFacetToFilter(inputId, field, value) {
var input = document.getElementById(inputId);
if (!input) return;
var currentFilter = input.value.trim();
var newTerm = field + ':' + value;
// Check if value contains spaces and needs quoting
if (value.indexOf(' ') !== -1) {
newTerm = field + ':"' + value + '"';
}
// Append to filter (space-separated)
if (currentFilter) {
input.value = currentFilter + ' ' + newTerm;
} else {
input.value = newTerm;
}
// Trigger change event for HTMX updates
input.dispatchEvent(new Event('change', { bubbles: true }));
// Also trigger input event for any other listeners
input.dispatchEvent(new Event('input', { bubbles: true }));
}
""")

View File

@@ -9,10 +9,12 @@ from monsterui.all import Alert, AlertT, Button, ButtonT, FormLabel, LabelInput,
from ulid import ULID
from animaltrack.models.events import Event
from animaltrack.models.reference import Location
from animaltrack.models.reference import Location, Species
from animaltrack.repositories.animals import FacetCounts
from animaltrack.selection.validation import SelectionDiff
from animaltrack.web.templates.action_bar import ActionBar
from animaltrack.web.templates.actions import event_datetime_field
from animaltrack.web.templates.dsl_facets import dsl_facet_pills, dsl_facet_pills_script
from animaltrack.web.templates.recent_events import recent_events_section
@@ -31,6 +33,8 @@ def move_form(
recent_events: list[tuple[Event, bool]] | None = None,
days_since_last_move: int | None = None,
location_names: dict[str, str] | None = None,
facets: FacetCounts | None = None,
species_list: list[Species] | None = None,
) -> Div:
"""Create the Move Animals form.
@@ -49,6 +53,8 @@ def move_form(
recent_events: Recent (Event, is_deleted) tuples, most recent first.
days_since_last_move: Number of days since the last move event.
location_names: Dict mapping location_id to location name.
facets: Optional FacetCounts for facet pills display.
species_list: Optional list of Species for facet name lookup.
Returns:
Div containing form and recent events section.
@@ -134,10 +140,19 @@ def move_form(
else:
stat_text = f"Last move: {days_since_last_move} days ago"
# Build facet pills component if facets provided
facet_pills_component = None
facet_script = None
if facets:
facet_pills_component = dsl_facet_pills(facets, "filter", locations, species_list)
facet_script = dsl_facet_pills_script("filter")
form = Form(
H2("Move Animals", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Facet pills for easy filter composition (tap to add filter terms)
facet_pills_component,
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
@@ -185,6 +200,8 @@ def move_form(
)
return Div(
# JavaScript for facet pill interactions
facet_script,
form,
recent_events_section(
title="Recent Moves",

View File

@@ -28,6 +28,7 @@ from monsterui.all import Button, ButtonT, FormLabel, Grid
from animaltrack.id_gen import format_animal_id
from animaltrack.models.reference import Location, Species
from animaltrack.repositories.animals import AnimalListItem, FacetCounts
from animaltrack.web.templates.dsl_facets import dsl_facet_pills, dsl_facet_pills_script
def registry_page(
@@ -54,12 +55,14 @@ def registry_page(
Div component with header, sidebar, and main content.
"""
return Div(
# JavaScript for facet pill interactions
dsl_facet_pills_script("filter"),
# Filter at top - full width
registry_header(filter_str, total_count),
# Grid with sidebar and table
Grid(
# Sidebar with facets
facet_sidebar(facets, filter_str, locations, species_list),
# Sidebar with clickable facet pills (include status for registry)
dsl_facet_pills(facets, "filter", locations, species_list, include_status=True),
# Main content - selection toolbar + table
Div(
selection_toolbar(),

View File

@@ -0,0 +1,192 @@
# ABOUTME: E2E tests for DSL facet pills component.
# ABOUTME: Tests click-to-filter, dynamic count updates, and dark mode visibility.
import pytest
from playwright.sync_api import Page, expect
pytestmark = pytest.mark.e2e
class TestFacetPillsOnMoveForm:
"""Test facet pills functionality on the move form."""
def test_facet_pills_visible_on_move_page(self, page: Page, live_server):
"""Verify facet pills section is visible on move page."""
page.goto(f"{live_server.url}/move")
expect(page.locator("body")).to_be_visible()
# Should see facet pills container
facet_container = page.locator("#dsl-facet-pills")
expect(facet_container).to_be_visible()
def test_click_species_facet_updates_filter(self, page: Page, live_server):
"""Clicking a species facet pill updates the filter input."""
page.goto(f"{live_server.url}/move")
expect(page.locator("body")).to_be_visible()
# Click on a species facet pill (e.g., duck)
duck_pill = page.locator('[data-facet-field="species"][data-facet-value="duck"]')
expect(duck_pill).to_be_visible()
duck_pill.click()
# Filter input should now contain species:duck
filter_input = page.locator("#filter")
expect(filter_input).to_have_value("species:duck")
def test_click_multiple_facets_composes_filter(self, page: Page, live_server):
"""Clicking multiple facet pills composes the filter."""
page.goto(f"{live_server.url}/move")
expect(page.locator("body")).to_be_visible()
# Click species facet
duck_pill = page.locator('[data-facet-field="species"][data-facet-value="duck"]')
expect(duck_pill).to_be_visible()
duck_pill.click()
# Click sex facet
female_pill = page.locator('[data-facet-field="sex"][data-facet-value="female"]')
expect(female_pill).to_be_visible()
female_pill.click()
# Filter should contain both
filter_input = page.locator("#filter")
filter_value = filter_input.input_value()
assert "species:duck" in filter_value
assert "sex:female" in filter_value
def test_facet_counts_update_after_filter(self, page: Page, live_server):
"""Facet counts update dynamically when filter changes."""
page.goto(f"{live_server.url}/move")
expect(page.locator("body")).to_be_visible()
# Get initial species counts
facet_container = page.locator("#dsl-facet-pills")
expect(facet_container).to_be_visible()
# Click species:duck to filter
duck_pill = page.locator('[data-facet-field="species"][data-facet-value="duck"]')
duck_pill.click()
# Wait for HTMX updates
page.wait_for_timeout(1000)
# Facet counts should have updated - only alive duck-related counts shown
# The sex facet should now show counts for ducks only
sex_section = page.locator("#dsl-facet-pills").locator("text=Sex").locator("..")
expect(sex_section).to_be_visible()
def test_selection_preview_updates_after_facet_click(self, page: Page, live_server):
"""Selection preview updates after clicking a facet pill."""
page.goto(f"{live_server.url}/move")
expect(page.locator("body")).to_be_visible()
# Click species facet
duck_pill = page.locator('[data-facet-field="species"][data-facet-value="duck"]')
expect(duck_pill).to_be_visible()
duck_pill.click()
# Wait for HTMX updates to selection container
page.wait_for_timeout(1000)
# Selection container should show filtered animals
selection_container = page.locator("#selection-container")
expect(selection_container).to_be_visible()
class TestFacetPillsOnOutcomeForm:
"""Test facet pills functionality on the outcome form."""
def test_facet_pills_visible_on_outcome_page(self, page: Page, live_server):
"""Verify facet pills section is visible on outcome page."""
page.goto(f"{live_server.url}/actions/outcome")
expect(page.locator("body")).to_be_visible()
# Should see facet pills container
facet_container = page.locator("#dsl-facet-pills")
expect(facet_container).to_be_visible()
def test_click_facet_on_outcome_form(self, page: Page, live_server):
"""Clicking a facet pill on outcome form updates filter."""
page.goto(f"{live_server.url}/actions/outcome")
expect(page.locator("body")).to_be_visible()
# Click on a species facet pill
duck_pill = page.locator('[data-facet-field="species"][data-facet-value="duck"]')
expect(duck_pill).to_be_visible()
duck_pill.click()
# Filter input should now contain species:duck
filter_input = page.locator("#filter")
expect(filter_input).to_have_value("species:duck")
class TestFacetPillsOnTagAddForm:
"""Test facet pills functionality on the tag add form."""
def test_facet_pills_visible_on_tag_add_page(self, page: Page, live_server):
"""Verify facet pills section is visible on tag add page."""
page.goto(f"{live_server.url}/actions/tag-add")
expect(page.locator("body")).to_be_visible()
# Should see facet pills container
facet_container = page.locator("#dsl-facet-pills")
expect(facet_container).to_be_visible()
class TestFacetPillsOnRegistry:
"""Test facet pills on registry replace existing facets."""
def test_registry_facet_pills_visible(self, page: Page, live_server):
"""Verify facet pills appear in registry sidebar."""
page.goto(f"{live_server.url}/registry")
expect(page.locator("body")).to_be_visible()
# Should see facet pills in sidebar
facet_container = page.locator("#dsl-facet-pills")
expect(facet_container).to_be_visible()
def test_registry_facet_click_updates_filter(self, page: Page, live_server):
"""Clicking a facet in registry updates the filter."""
page.goto(f"{live_server.url}/registry")
expect(page.locator("body")).to_be_visible()
# Click on species facet
duck_pill = page.locator('[data-facet-field="species"][data-facet-value="duck"]')
expect(duck_pill).to_be_visible()
duck_pill.click()
# Filter input should be updated
filter_input = page.locator("#filter")
expect(filter_input).to_have_value("species:duck")
class TestSelectDarkMode:
"""Test select dropdown visibility in dark mode."""
def test_select_options_visible_on_move_form(self, page: Page, live_server):
"""Verify select dropdown options are readable in dark mode."""
page.goto(f"{live_server.url}/move")
expect(page.locator("body")).to_be_visible()
# Click to open destination dropdown
select = page.locator("#to_location_id")
expect(select).to_be_visible()
# Check the select has proper dark mode styling
# Note: We check computed styles to verify color-scheme is set
color_scheme = select.evaluate("el => window.getComputedStyle(el).colorScheme")
# Should have dark color scheme for native dark mode option styling
assert "dark" in color_scheme.lower() or color_scheme == "auto"
def test_outcome_select_options_visible(self, page: Page, live_server):
"""Verify outcome dropdown options are readable."""
page.goto(f"{live_server.url}/actions/outcome")
expect(page.locator("body")).to_be_visible()
# Check outcome dropdown has proper styling
select = page.locator("#outcome")
expect(select).to_be_visible()
# Verify the select can be interacted with
select.click()
expect(select).to_be_focused()

195
tests/test_api_facets.py Normal file
View File

@@ -0,0 +1,195 @@
# ABOUTME: Unit tests for /api/facets endpoint.
# ABOUTME: Tests dynamic facet count retrieval based on filter.
import os
import time
import pytest
from starlette.testclient import TestClient
from animaltrack.events.payloads import AnimalCohortCreatedPayload
from animaltrack.events.store import EventStore
from animaltrack.projections import ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.services.animal import AnimalService
def make_test_settings(
csrf_secret: str = "test-secret",
trusted_proxy_ips: str = "127.0.0.1",
dev_mode: bool = True,
):
"""Create Settings for testing by setting env vars temporarily."""
from animaltrack.config import Settings
old_env = os.environ.copy()
try:
os.environ["CSRF_SECRET"] = csrf_secret
os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips
os.environ["DEV_MODE"] = str(dev_mode).lower()
return Settings()
finally:
os.environ.clear()
os.environ.update(old_env)
@pytest.fixture
def client(seeded_db):
"""Create a test client for the app."""
from animaltrack.web.app import create_app
settings = make_test_settings(trusted_proxy_ips="testclient")
app, rt = create_app(settings=settings, db=seeded_db)
return TestClient(app, raise_server_exceptions=True)
@pytest.fixture
def projection_registry(seeded_db):
"""Create a ProjectionRegistry with animal projections registered."""
registry = ProjectionRegistry()
registry.register(AnimalRegistryProjection(seeded_db))
registry.register(EventAnimalsProjection(seeded_db))
registry.register(IntervalProjection(seeded_db))
return registry
@pytest.fixture
def animal_service(seeded_db, projection_registry):
"""Create an AnimalService for testing."""
event_store = EventStore(seeded_db)
return AnimalService(seeded_db, event_store, projection_registry)
@pytest.fixture
def location_strip1_id(seeded_db):
"""Get Strip 1 location ID from seeded data."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
return row[0]
@pytest.fixture
def location_strip2_id(seeded_db):
"""Get Strip 2 location ID from seeded data."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()
return row[0]
@pytest.fixture
def ducks_at_strip1(seeded_db, animal_service, location_strip1_id):
"""Create 5 female ducks at Strip 1."""
payload = AnimalCohortCreatedPayload(
species="duck",
count=5,
life_stage="adult",
sex="female",
location_id=location_strip1_id,
origin="purchased",
)
ts_utc = int(time.time() * 1000)
event = animal_service.create_cohort(payload, ts_utc, "test_user")
return event.entity_refs["animal_ids"]
@pytest.fixture
def geese_at_strip2(seeded_db, animal_service, location_strip2_id):
"""Create 3 male geese at Strip 2."""
payload = AnimalCohortCreatedPayload(
species="goose",
count=3,
life_stage="adult",
sex="male",
location_id=location_strip2_id,
origin="purchased",
)
ts_utc = int(time.time() * 1000)
event = animal_service.create_cohort(payload, ts_utc, "test_user")
return event.entity_refs["animal_ids"]
class TestApiFacetsEndpoint:
"""Test GET /api/facets endpoint."""
def test_facets_endpoint_exists(self, client, ducks_at_strip1):
"""Verify the facets endpoint responds."""
response = client.get("/api/facets")
assert response.status_code == 200
def test_facets_returns_html_partial(self, client, ducks_at_strip1):
"""Facets endpoint returns HTML partial for HTMX swap."""
response = client.get("/api/facets")
assert response.status_code == 200
content = response.text
# Should be HTML with facet pills structure
assert 'id="dsl-facet-pills"' in content
assert "Species" in content
def test_facets_respects_filter(self, client, ducks_at_strip1, geese_at_strip2):
"""Facets endpoint applies filter and shows filtered counts."""
# Get facets filtered to ducks only
response = client.get("/api/facets?filter=species:duck")
assert response.status_code == 200
content = response.text
# Should show sex facets for ducks (5 female)
assert "female" in content.lower()
# Should not show goose sex (male) since we filtered to ducks
# (actually it might show male=0 or not at all)
def test_facets_shows_count_for_alive_animals(self, client, ducks_at_strip1):
"""Facets show counts for alive animals by default."""
response = client.get("/api/facets")
assert response.status_code == 200
content = response.text
# Should show species with counts
assert "duck" in content.lower() or "Duck" in content
# Count 5 should appear
assert "5" in content
def test_facets_with_empty_filter(self, client, ducks_at_strip1, geese_at_strip2):
"""Empty filter returns all alive animals' facets."""
response = client.get("/api/facets?filter=")
assert response.status_code == 200
content = response.text
# Should have facet pills
assert 'id="dsl-facet-pills"' in content
def test_facets_with_location_filter(self, client, ducks_at_strip1, geese_at_strip2):
"""Location filter shows facets for that location only."""
response = client.get('/api/facets?filter=location:"Strip 1"')
assert response.status_code == 200
content = response.text
# Should show ducks (at Strip 1)
assert "duck" in content.lower() or "Duck" in content
def test_facets_includes_htmx_swap_attributes(self, client, ducks_at_strip1):
"""Returned HTML has proper ID for HTMX swap targeting."""
response = client.get("/api/facets")
assert response.status_code == 200
content = response.text
# Must have same ID for outerHTML swap to work
assert 'id="dsl-facet-pills"' in content
class TestApiFacetsWithSelectionPreview:
"""Test facets endpoint integrates with selection preview workflow."""
def test_facets_and_preview_use_same_filter(self, client, ducks_at_strip1, geese_at_strip2):
"""Both endpoints interpret the same filter consistently."""
filter_str = "species:duck"
# Get facets
facets_resp = client.get(f"/api/facets?filter={filter_str}")
assert facets_resp.status_code == 200
# Get selection preview
preview_resp = client.get(f"/api/selection-preview?filter={filter_str}")
assert preview_resp.status_code == 200
# Both should work with the same filter

233
tests/test_dsl_facets.py Normal file
View File

@@ -0,0 +1,233 @@
# ABOUTME: Unit tests for DSL facet pills template component.
# ABOUTME: Tests HTML generation for facet pill structure and HTMX attributes.
from fasthtml.common import to_xml
from animaltrack.repositories.animals import FacetCounts
class TestDslFacetPills:
"""Test the dsl_facet_pills component."""
def test_facet_pills_renders_with_counts(self):
"""Facet pills component renders species counts as pills."""
from animaltrack.web.templates.dsl_facets import dsl_facet_pills
facets = FacetCounts(
by_species={"duck": 5, "goose": 3},
by_sex={"female": 4, "male": 3, "unknown": 1},
by_life_stage={"adult": 6, "juvenile": 2},
by_location={"loc1": 5, "loc2": 3},
)
locations = []
species_list = []
result = dsl_facet_pills(facets, "filter", locations, species_list)
html = to_xml(result)
# Should have container with proper ID
assert 'id="dsl-facet-pills"' in html
# Should have data attributes for JavaScript
assert 'data-facet-field="species"' in html
assert 'data-facet-value="duck"' in html
assert 'data-facet-value="goose"' in html
def test_facet_pills_has_htmx_attributes_for_refresh(self):
"""Facet pills container has HTMX attributes for dynamic refresh."""
from animaltrack.web.templates.dsl_facets import dsl_facet_pills
facets = FacetCounts(
by_species={"duck": 5},
by_sex={},
by_life_stage={},
by_location={},
)
result = dsl_facet_pills(facets, "filter", [], [])
html = to_xml(result)
# Should have HTMX attributes for updating facets
assert "hx-get" in html
assert "/api/facets" in html
assert "hx-trigger" in html
assert "#filter" in html # References the filter input
def test_facet_pills_renders_all_facet_sections(self):
"""Facet pills renders species, sex, life_stage, and location sections."""
from animaltrack.web.templates.dsl_facets import dsl_facet_pills
facets = FacetCounts(
by_species={"duck": 5},
by_sex={"female": 3},
by_life_stage={"adult": 4},
by_location={"loc1": 5},
)
result = dsl_facet_pills(facets, "filter", [], [])
html = to_xml(result)
# Should have all section headers
assert "Species" in html
assert "Sex" in html
assert "Life Stage" in html
assert "Location" in html
def test_facet_pills_includes_counts_in_pills(self):
"""Each pill shows the count alongside the label."""
from animaltrack.web.templates.dsl_facets import dsl_facet_pills
facets = FacetCounts(
by_species={"duck": 12},
by_sex={},
by_life_stage={},
by_location={},
)
result = dsl_facet_pills(facets, "filter", [], [])
html = to_xml(result)
# Should show count 12
assert ">12<" in html or ">12 " in html or " 12<" in html
def test_facet_pills_uses_location_names(self):
"""Location facets use human-readable names from location list."""
from animaltrack.models.reference import Location
from animaltrack.web.templates.dsl_facets import dsl_facet_pills
facets = FacetCounts(
by_species={},
by_sex={},
by_life_stage={},
by_location={"01ARZ3NDEKTSV4RRFFQ69G5FAV": 5},
)
locations = [
Location(
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
name="Strip 1",
active=True,
created_at_utc=0,
updated_at_utc=0,
)
]
result = dsl_facet_pills(facets, "filter", locations, [])
html = to_xml(result)
# Should display location name
assert "Strip 1" in html
def test_facet_pills_uses_species_names(self):
"""Species facets use human-readable names from species list."""
from animaltrack.models.reference import Species
from animaltrack.web.templates.dsl_facets import dsl_facet_pills
facets = FacetCounts(
by_species={"duck": 5},
by_sex={},
by_life_stage={},
by_location={},
)
species_list = [
Species(
code="duck",
name="Duck",
active=True,
created_at_utc=0,
updated_at_utc=0,
)
]
result = dsl_facet_pills(facets, "filter", [], species_list)
html = to_xml(result)
# Should display species name
assert "Duck" in html
def test_facet_pills_empty_facets_not_shown(self):
"""Empty facet sections are not rendered."""
from animaltrack.web.templates.dsl_facets import dsl_facet_pills
facets = FacetCounts(
by_species={"duck": 5},
by_sex={}, # Empty
by_life_stage={}, # Empty
by_location={}, # Empty
)
result = dsl_facet_pills(facets, "filter", [], [])
html = to_xml(result)
# Should show Species but not empty sections
assert "Species" in html
# Sex section header should not appear since no sex facets
# (we count section headers, not raw word occurrences)
def test_facet_pills_onclick_calls_javascript(self):
"""Pill click handler uses JavaScript to update filter."""
from animaltrack.web.templates.dsl_facets import dsl_facet_pills
facets = FacetCounts(
by_species={"duck": 5},
by_sex={},
by_life_stage={},
by_location={},
)
result = dsl_facet_pills(facets, "filter", [], [])
html = to_xml(result)
# Should have onclick or similar handler
assert "onclick" in html or "hx-on:click" in html
class TestFacetPillsSection:
"""Test the facet_pill_section helper function."""
def test_section_sorts_by_count_descending(self):
"""Pills are sorted by count in descending order."""
from animaltrack.web.templates.dsl_facets import facet_pill_section
counts = {"a": 1, "b": 5, "c": 3}
result = facet_pill_section("Test", counts, "filter", "field")
html = to_xml(result)
# "b" (count 5) should appear before "c" (count 3) which appears before "a" (count 1)
pos_b = html.find('data-facet-value="b"')
pos_c = html.find('data-facet-value="c"')
pos_a = html.find('data-facet-value="a"')
assert pos_b < pos_c < pos_a, "Pills should be sorted by count descending"
def test_section_returns_none_for_empty_counts(self):
"""Empty counts returns None (no section rendered)."""
from animaltrack.web.templates.dsl_facets import facet_pill_section
result = facet_pill_section("Test", {}, "filter", "field")
assert result is None
def test_section_applies_label_map(self):
"""Label map transforms values to display labels."""
from animaltrack.web.templates.dsl_facets import facet_pill_section
counts = {"val1": 5}
label_map = {"val1": "Display Label"}
result = facet_pill_section("Test", counts, "filter", "field", label_map)
html = to_xml(result)
assert "Display Label" in html
class TestDslFacetPillsScript:
"""Test the JavaScript for facet pills interaction."""
def test_script_included_in_component(self):
"""Facet pills component includes the JavaScript for interaction."""
from animaltrack.web.templates.dsl_facets import dsl_facet_pills_script
result = dsl_facet_pills_script("filter")
html = to_xml(result)
# Should be a script element
assert "<script" in html.lower()
# Should have function to handle pill clicks
assert "appendFacetToFilter" in html or "addFacetToFilter" in html