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>
288 lines
10 KiB
Python
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",
|
|
)
|