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

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