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)
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 selected_ids is None:
selected_ids = [a.animal_id for a in animals]
# Render checkbox list
from fasthtml.common import to_xml
# Render checkbox list for multiple animals
return HTMLResponse(content=to_xml(animal_checkbox_list(animals, selected_ids)))

View File

@@ -566,29 +566,36 @@ def tag_add_form(
error_component = Alert(error, cls=AlertT.warning)
# 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
if animals and len(animals) > 1:
# 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"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
selection_component = Div(
selection_content = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
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:
selection_component = Div(
selection_content = Div(
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)
@@ -600,16 +607,20 @@ def tag_add_form(
H2("Add Tag", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Filter input
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
id="filter",
name="filter",
value=filter_str,
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_component,
# Selection container - updated via HTMX when filter changes
selection_container,
# Tag input
LabelInput(
"Tag",
@@ -768,29 +779,36 @@ def tag_end_form(
error_component = Alert(error, cls=AlertT.warning)
# 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
if animals and len(animals) > 1:
# 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"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
selection_component = Div(
selection_content = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
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:
selection_component = Div(
selection_content = Div(
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
@@ -807,16 +825,20 @@ def tag_end_form(
H2("End Tag", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Filter input
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
id="filter",
name="filter",
value=filter_str,
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_component,
# Selection container - updated via HTMX when filter changes
selection_container,
# Tag dropdown
LabelSelect(
*tag_options,
@@ -976,29 +998,36 @@ def attrs_form(
error_component = Alert(error, cls=AlertT.warning)
# 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
if animals and len(animals) > 1:
# 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"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
selection_component = Div(
selection_content = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
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:
selection_component = Div(
selection_content = Div(
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
@@ -1036,16 +1065,20 @@ def attrs_form(
H2("Update Attributes", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Filter input
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
id="filter",
name="filter",
value=filter_str,
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_component,
# Selection container - updated via HTMX when filter changes
selection_container,
# Attribute dropdowns
LabelSelect(
*sex_options,
@@ -1222,30 +1255,37 @@ def outcome_form(
error_component = Alert(error, cls=AlertT.warning)
# 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
if animals and len(animals) > 1:
# 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"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
# Fallback to simple count display
selection_component = Div(
selection_content = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
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:
selection_component = Div(
selection_content = Div(
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
@@ -1323,15 +1363,20 @@ def outcome_form(
return Form(
H2("Record Outcome", cls="text-xl font-bold mb-4"),
error_component,
selection_component,
# Filter field
# Filter field with HTMX to fetch selection preview
LabelInput(
label="Filter (DSL)",
id="filter",
name="filter",
value=filter_str,
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
LabelSelect(
*outcome_options,

View File

@@ -89,6 +89,8 @@ def animal_checkbox_list(
*items,
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
Input(type="hidden", name="roster_hash", id="roster-hash-field"),
# Script for selection management

View File

@@ -66,31 +66,38 @@ def move_form(
)
# 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
if animals and len(animals) > 1:
# Show checkbox list for subset selection
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"),
animal_checkbox_list(animals, resolved_ids),
cls="mb-4",
)
subset_mode = True
elif resolved_count > 0:
location_info = f" from {from_location_name}" if from_location_name else ""
selection_component = Div(
selection_content = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
f" animals selected{location_info}",
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:
selection_component = Div(
selection_content = Div(
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)
@@ -102,16 +109,20 @@ def move_form(
H2("Move Animals", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Filter input
# Filter input with HTMX to fetch selection preview
LabelInput(
"Filter",
id="filter",
name="filter",
value=filter_str,
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_component,
# Selection container - updated via HTMX when filter changes
selection_container,
# Destination dropdown
LabelSelect(
*location_options,