Files
animaltrack/src/animaltrack/web/templates/move.py
Petru Paler 803169816b Replace onclick navigation with proper links
Converts cancel buttons that use onclick="window.location.href='...'" to
proper A tags with href. This improves accessibility (keyboard navigation,
right-click options) and semantics while maintaining the same button styling.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:25:02 +00:00

288 lines
10 KiB
Python

# ABOUTME: Templates for Move Animals form.
# ABOUTME: Provides form components for moving animals with selection context and mismatch handling.
from collections.abc import Callable
from typing import Any
from fasthtml.common import H2, A, Div, Form, Hidden, Option, P, Select, Span
from monsterui.all import Alert, AlertT, Button, ButtonT, FormLabel, LabelInput, LabelTextArea
from ulid import ULID
from animaltrack.models.events import Event
from animaltrack.models.reference import Location
from animaltrack.selection.validation import SelectionDiff
from animaltrack.web.templates.actions import event_datetime_field
from animaltrack.web.templates.recent_events import recent_events_section
def move_form(
locations: list[Location],
filter_str: str = "",
resolved_ids: list[str] | None = None,
roster_hash: str = "",
from_location_id: str | None = None,
ts_utc: int | None = None,
resolved_count: int = 0,
from_location_name: str | None = None,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-move",
animals: list | None = None,
recent_events: list[tuple[Event, bool]] | None = None,
days_since_last_move: int | None = None,
location_names: dict[str, str] | None = None,
) -> Div:
"""Create the Move Animals form.
Args:
locations: List of active locations for the dropdown.
filter_str: Current filter string (DSL).
resolved_ids: Resolved animal IDs from filter.
roster_hash: Hash of resolved selection.
from_location_id: Common source location ID (if all animals from same location).
ts_utc: Timestamp of resolution.
resolved_count: Number of resolved animals.
from_location_name: Name of source location for display.
error: Optional error message to display.
action: Route function or URL string for form submission.
animals: List of AnimalListItem for checkbox selection (optional).
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.
Returns:
Div containing form and recent events section.
"""
from animaltrack.web.templates.animal_select import animal_checkbox_list
if resolved_ids is None:
resolved_ids = []
if animals is None:
animals = []
if recent_events is None:
recent_events = []
if location_names is None:
location_names = {}
# Build destination location options (exclude from_location if set)
location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
for loc in locations:
if loc.id != from_location_id:
location_options.append(Option(loc.name, value=loc.id))
# Error display component
error_component = None
if error:
error_component = Alert(
error,
cls=AlertT.warning,
)
# Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting
selection_content = None
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_content = Div(
P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
)
elif resolved_count > 0:
location_info = f" from {from_location_name}" if from_location_name else ""
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",
)
elif filter_str:
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",
)
# 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
]
# Format function for move events
# Note: entity_refs stores animal_ids, not resolved_ids
def format_move_event(event: Event) -> tuple[str, str]:
to_loc_id = event.entity_refs.get("to_location_id", "")
to_loc_name = location_names.get(to_loc_id, "Unknown")
count = len(event.entity_refs.get("animal_ids", []))
return f"{count} animals to {to_loc_name}", event.id
# Build stats text
stat_text = None
if days_since_last_move is not None:
if days_since_last_move == 0:
stat_text = "Last move: today"
elif days_since_last_move == 1:
stat_text = "Last move: yesterday"
else:
stat_text = f"Last move: {days_since_last_move} days ago"
form = Form(
H2("Move Animals", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# 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 container - updated via HTMX when filter changes
selection_container,
# Destination dropdown - using raw Select due to MonsterUI LabelSelect value bug
Div(
FormLabel("Destination", _for="to_location_id"),
Select(*location_options, name="to_location_id", id="to_location_id", cls="uk-select"),
cls="space-y-2",
),
# Optional notes
LabelTextArea(
"Notes",
id="notes",
name="notes",
placeholder="Optional notes",
),
# Event datetime picker (for backdating)
event_datetime_field("move_datetime"),
# Hidden fields for selection context
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="from_location_id", value=from_location_id or ""),
Hidden(name="resolver_version", value="v1"),
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Move Animals", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)
return Div(
form,
recent_events_section(
title="Recent Moves",
events=recent_events,
format_fn=format_move_event,
stat_text=stat_text,
),
)
def diff_panel(
diff: SelectionDiff,
filter_str: str,
resolved_ids: list[str],
roster_hash: str,
from_location_id: str,
to_location_id: str,
ts_utc: int,
locations: list[Location],
action: Callable[..., Any] | str = "/actions/animal-move",
) -> Div:
"""Create the mismatch confirmation panel.
Shows diff information and allows user to confirm or cancel.
Args:
diff: SelectionDiff with added/removed counts.
filter_str: Original filter string.
resolved_ids: Server's resolved IDs (current).
roster_hash: Server's roster hash (current).
from_location_id: Source location ID.
to_location_id: Destination location ID.
ts_utc: Timestamp for resolution.
locations: List of locations for display.
action: Route function or URL for confirmation submit.
Returns:
Div containing the diff panel with confirm button.
"""
# Find destination location name
to_location_name = "Unknown"
for loc in locations:
if loc.id == to_location_id:
to_location_name = loc.name
break
# Build description of changes
changes = []
if diff.removed:
changes.append(f"{len(diff.removed)} animals were moved since you loaded this page")
if diff.added:
changes.append(f"{len(diff.added)} animals were added")
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="from_location_id", value=from_location_id),
Hidden(name="to_location_id", value=to_location_id),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="resolver_version", value="v1"),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
A(
"Cancel",
href="/move",
cls=ButtonT.default,
),
Button(
f"Confirm Move ({diff.server_count} animals)",
type="submit",
cls=ButtonT.primary,
hx_disabled_elt="this",
),
cls="flex gap-3 mt-4",
),
action=action,
method="post",
)
return Div(
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(
f"Would you like to proceed with the remaining {diff.server_count} animals to {to_location_name}?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
)