From 1c836c6f7d65cb9df762014788ace5d3785e2d47 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 1 Jan 2026 19:27:17 +0000 Subject: [PATCH] feat: add HTMX trigger to filter inputs for dynamic checkbox selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/animaltrack/web/routes/api.py | 21 ++- src/animaltrack/web/templates/actions.py | 123 ++++++++++++------ .../web/templates/animal_select.py | 2 + src/animaltrack/web/templates/move.py | 31 +++-- 4 files changed, 125 insertions(+), 52 deletions(-) diff --git a/src/animaltrack/web/routes/api.py b/src/animaltrack/web/routes/api.py index cffaa5e..e00266a 100644 --- a/src/animaltrack/web/routes/api.py +++ b/src/animaltrack/web/routes/api.py @@ -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))) diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py index eab2cb4..44f29cf 100644 --- a/src/animaltrack/web/templates/actions.py +++ b/src/animaltrack/web/templates/actions.py @@ -566,31 +566,38 @@ 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) resolved_id_fields = [ 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"), # 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,31 +779,38 @@ 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 tag_options = [Option("Select tag to end...", value="", disabled=True, selected=True)] for tag in 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,31 +998,38 @@ 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 sex_options = [ Option("No change", value="", selected=True), @@ -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,32 +1255,39 @@ 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 outcome_options = [ Option("Select outcome...", value="", selected=True, disabled=True), @@ -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, diff --git a/src/animaltrack/web/templates/animal_select.py b/src/animaltrack/web/templates/animal_select.py index b058f14..1bdc9a1 100644 --- a/src/animaltrack/web/templates/animal_select.py +++ b/src/animaltrack/web/templates/animal_select.py @@ -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 diff --git a/src/animaltrack/web/templates/move.py b/src/animaltrack/web/templates/move.py index 59bb0b8..a94be6d 100644 --- a/src/animaltrack/web/templates/move.py +++ b/src/animaltrack/web/templates/move.py @@ -66,33 +66,40 @@ 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) resolved_id_fields = [ 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"), # 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,