feat: add HTMX trigger to filter inputs for dynamic checkbox selection

When users type a filter, HTMX now fetches the matching animals and
displays a checkbox list for subset selection. Changes:

- Add hx_get="/api/selection-preview" to filter inputs in all forms
- Wrap selection component in #selection-container for HTMX targeting
- Add subset_mode hidden field to checkbox list component
- Handle single-animal case with simple count display (no checkboxes)

Forms updated: outcome, tag-add, tag-end, attrs, move

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 19:27:17 +00:00
parent a35d4a3c0d
commit 1c836c6f7d
4 changed files with 125 additions and 52 deletions

View File

@@ -74,11 +74,26 @@ def selection_preview(request: Request):
animal_repo = AnimalRepository(db) animal_repo = AnimalRepository(db)
animals = animal_repo.get_by_ids(resolution.animal_ids) animals = animal_repo.get_by_ids(resolution.animal_ids)
from fasthtml.common import Div, P, Span, to_xml
# For single animal, show simple count display (no checkboxes needed)
if len(animals) == 1:
return HTMLResponse(
content=to_xml(
Div(
P(
Span("1", cls="font-bold text-lg"),
" animal selected",
cls="text-sm",
),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md",
)
)
)
# If no pre-selection, default to all selected # If no pre-selection, default to all selected
if selected_ids is None: if selected_ids is None:
selected_ids = [a.animal_id for a in animals] selected_ids = [a.animal_id for a in animals]
# Render checkbox list # Render checkbox list for multiple animals
from fasthtml.common import to_xml
return HTMLResponse(content=to_xml(animal_checkbox_list(animals, selected_ids))) return HTMLResponse(content=to_xml(animal_checkbox_list(animals, selected_ids)))

View File

@@ -566,31 +566,38 @@ def tag_add_form(
error_component = Alert(error, cls=AlertT.warning) error_component = Alert(error, cls=AlertT.warning)
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
selection_component = None # Wrapped in a container with ID for HTMX targeting
selection_content = None
subset_mode = False subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
selection_component = Div( selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
) )
subset_mode = True subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
selection_component = Div( selection_content = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected", " animals selected",
cls="text-sm", cls="text-sm",
), ),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md",
) )
elif filter_str: elif filter_str:
selection_component = Div( selection_content = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md",
) )
# Container for HTMX updates - always present so it can be targeted
selection_container = Div(
selection_content,
id="selection-container",
cls="mb-4" if selection_content else "",
)
# Hidden fields for resolved_ids (as multiple values) # Hidden fields for resolved_ids (as multiple values)
resolved_id_fields = [ resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
@@ -600,16 +607,20 @@ def tag_add_form(
H2("Add Tag", cls="text-xl font-bold mb-4"), H2("Add Tag", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Filter input # Filter input with HTMX to fetch selection preview
LabelInput( LabelInput(
"Filter", "Filter",
id="filter", id="filter",
name="filter", name="filter",
value=filter_str, value=filter_str,
placeholder='e.g., location:"Strip 1" species:duck', placeholder='e.g., location:"Strip 1" species:duck',
hx_get="/api/selection-preview",
hx_trigger="change, keyup delay:500ms changed",
hx_target="#selection-container",
hx_swap="innerHTML",
), ),
# Selection component (checkboxes or simple count) # Selection container - updated via HTMX when filter changes
selection_component, selection_container,
# Tag input # Tag input
LabelInput( LabelInput(
"Tag", "Tag",
@@ -768,31 +779,38 @@ def tag_end_form(
error_component = Alert(error, cls=AlertT.warning) error_component = Alert(error, cls=AlertT.warning)
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
selection_component = None # Wrapped in a container with ID for HTMX targeting
selection_content = None
subset_mode = False subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
selection_component = Div( selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
) )
subset_mode = True subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
selection_component = Div( selection_content = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected", " animals selected",
cls="text-sm", cls="text-sm",
), ),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md",
) )
elif filter_str: elif filter_str:
selection_component = Div( selection_content = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md",
) )
# Container for HTMX updates - always present so it can be targeted
selection_container = Div(
selection_content,
id="selection-container",
cls="mb-4" if selection_content else "",
)
# Build tag options from active_tags # Build tag options from active_tags
tag_options = [Option("Select tag to end...", value="", disabled=True, selected=True)] tag_options = [Option("Select tag to end...", value="", disabled=True, selected=True)]
for tag in active_tags: for tag in active_tags:
@@ -807,16 +825,20 @@ def tag_end_form(
H2("End Tag", cls="text-xl font-bold mb-4"), H2("End Tag", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Filter input # Filter input with HTMX to fetch selection preview
LabelInput( LabelInput(
"Filter", "Filter",
id="filter", id="filter",
name="filter", name="filter",
value=filter_str, value=filter_str,
placeholder="e.g., tag:layer-birds species:duck", placeholder="e.g., tag:layer-birds species:duck",
hx_get="/api/selection-preview",
hx_trigger="change, keyup delay:500ms changed",
hx_target="#selection-container",
hx_swap="innerHTML",
), ),
# Selection component (checkboxes or simple count) # Selection container - updated via HTMX when filter changes
selection_component, selection_container,
# Tag dropdown # Tag dropdown
LabelSelect( LabelSelect(
*tag_options, *tag_options,
@@ -976,31 +998,38 @@ def attrs_form(
error_component = Alert(error, cls=AlertT.warning) error_component = Alert(error, cls=AlertT.warning)
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
selection_component = None # Wrapped in a container with ID for HTMX targeting
selection_content = None
subset_mode = False subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
selection_component = Div( selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
) )
subset_mode = True subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
selection_component = Div( selection_content = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected", " animals selected",
cls="text-sm", cls="text-sm",
), ),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md",
) )
elif filter_str: elif filter_str:
selection_component = Div( selection_content = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md",
) )
# Container for HTMX updates - always present so it can be targeted
selection_container = Div(
selection_content,
id="selection-container",
cls="mb-4" if selection_content else "",
)
# Build sex options # Build sex options
sex_options = [ sex_options = [
Option("No change", value="", selected=True), Option("No change", value="", selected=True),
@@ -1036,16 +1065,20 @@ def attrs_form(
H2("Update Attributes", cls="text-xl font-bold mb-4"), H2("Update Attributes", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Filter input # Filter input with HTMX to fetch selection preview
LabelInput( LabelInput(
"Filter", "Filter",
id="filter", id="filter",
name="filter", name="filter",
value=filter_str, value=filter_str,
placeholder="e.g., species:duck life_stage:juvenile", placeholder="e.g., species:duck life_stage:juvenile",
hx_get="/api/selection-preview",
hx_trigger="change, keyup delay:500ms changed",
hx_target="#selection-container",
hx_swap="innerHTML",
), ),
# Selection component (checkboxes or simple count) # Selection container - updated via HTMX when filter changes
selection_component, selection_container,
# Attribute dropdowns # Attribute dropdowns
LabelSelect( LabelSelect(
*sex_options, *sex_options,
@@ -1222,32 +1255,39 @@ def outcome_form(
error_component = Alert(error, cls=AlertT.warning) error_component = Alert(error, cls=AlertT.warning)
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
selection_component = None # Wrapped in a container with ID for HTMX targeting
selection_content = None
subset_mode = False subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
selection_component = Div( selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
) )
subset_mode = True subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
# Fallback to simple count display # Fallback to simple count display
selection_component = Div( selection_content = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected", " animals selected",
cls="text-sm", cls="text-sm",
), ),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md",
) )
elif filter_str: elif filter_str:
selection_component = Div( selection_content = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md",
) )
# Container for HTMX updates - always present so it can be targeted
selection_container = Div(
selection_content,
id="selection-container",
cls="mb-4" if selection_content else "",
)
# Build outcome options # Build outcome options
outcome_options = [ outcome_options = [
Option("Select outcome...", value="", selected=True, disabled=True), Option("Select outcome...", value="", selected=True, disabled=True),
@@ -1323,15 +1363,20 @@ def outcome_form(
return Form( return Form(
H2("Record Outcome", cls="text-xl font-bold mb-4"), H2("Record Outcome", cls="text-xl font-bold mb-4"),
error_component, error_component,
selection_component, # Filter field with HTMX to fetch selection preview
# Filter field
LabelInput( LabelInput(
label="Filter (DSL)", label="Filter (DSL)",
id="filter", id="filter",
name="filter", name="filter",
value=filter_str, value=filter_str,
placeholder="e.g., species:duck location:Coop1", placeholder="e.g., species:duck location:Coop1",
hx_get="/api/selection-preview",
hx_trigger="change, keyup delay:500ms changed",
hx_target="#selection-container",
hx_swap="innerHTML",
), ),
# Selection container - updated via HTMX when filter changes
selection_container,
# Outcome selection # Outcome selection
LabelSelect( LabelSelect(
*outcome_options, *outcome_options,

View File

@@ -89,6 +89,8 @@ def animal_checkbox_list(
*items, *items,
cls="max-h-64 overflow-y-auto", cls="max-h-64 overflow-y-auto",
), ),
# Hidden field to indicate subset selection mode
Input(type="hidden", name="subset_mode", value="true"),
# Hidden field for roster_hash - will be updated via JS # Hidden field for roster_hash - will be updated via JS
Input(type="hidden", name="roster_hash", id="roster-hash-field"), Input(type="hidden", name="roster_hash", id="roster-hash-field"),
# Script for selection management # Script for selection management

View File

@@ -66,33 +66,40 @@ def move_form(
) )
# Selection component - show checkboxes if animals provided and > 1 # Selection component - show checkboxes if animals provided and > 1
selection_component = None # Wrapped in a container with ID for HTMX targeting
selection_content = None
subset_mode = False subset_mode = False
if animals and len(animals) > 1: if animals and len(animals) > 1:
# Show checkbox list for subset selection # Show checkbox list for subset selection
location_info = f" from {from_location_name}" if from_location_name else "" location_info = f" from {from_location_name}" if from_location_name else ""
selection_component = Div( selection_content = Div(
P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"), P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids), animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
) )
subset_mode = True subset_mode = True
elif resolved_count > 0: elif resolved_count > 0:
location_info = f" from {from_location_name}" if from_location_name else "" location_info = f" from {from_location_name}" if from_location_name else ""
selection_component = Div( selection_content = Div(
P( P(
Span(f"{resolved_count}", cls="font-bold text-lg"), Span(f"{resolved_count}", cls="font-bold text-lg"),
f" animals selected{location_info}", f" animals selected{location_info}",
cls="text-sm", cls="text-sm",
), ),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4", cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md",
) )
elif filter_str: elif filter_str:
selection_component = Div( selection_content = Div(
P("No animals match this filter", cls="text-sm text-amber-600"), P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4", cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md",
) )
# Container for HTMX updates - always present so it can be targeted
selection_container = Div(
selection_content,
id="selection-container",
cls="mb-4" if selection_content else "",
)
# Hidden fields for resolved_ids (as multiple values) # Hidden fields for resolved_ids (as multiple values)
resolved_id_fields = [ resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
@@ -102,16 +109,20 @@ def move_form(
H2("Move Animals", cls="text-xl font-bold mb-4"), H2("Move Animals", cls="text-xl font-bold mb-4"),
# Error message if present # Error message if present
error_component, error_component,
# Filter input # Filter input with HTMX to fetch selection preview
LabelInput( LabelInput(
"Filter", "Filter",
id="filter", id="filter",
name="filter", name="filter",
value=filter_str, value=filter_str,
placeholder='e.g., location:"Strip 1" species:duck', placeholder='e.g., location:"Strip 1" species:duck',
hx_get="/api/selection-preview",
hx_trigger="change, keyup delay:500ms changed",
hx_target="#selection-container",
hx_swap="innerHTML",
), ),
# Selection component (checkboxes or simple count) # Selection container - updated via HTMX when filter changes
selection_component, selection_container,
# Destination dropdown # Destination dropdown
LabelSelect( LabelSelect(
*location_options, *location_options,