Compare commits
9 Commits
eee8552345
...
5be8da96f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5be8da96f2 | |||
| 803169816b | |||
| 7315e552e3 | |||
| 4e78b79745 | |||
| fc4c2a8e40 | |||
| b2132a8ef5 | |||
| a87b5cbac6 | |||
| b09d3088eb | |||
| 2fc98155c3 |
53
src/animaltrack/static/v1/datetime-picker.js
Normal file
53
src/animaltrack/static/v1/datetime-picker.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Datetime Picker Component
|
||||
*
|
||||
* Provides toggle and conversion functionality for backdating events.
|
||||
* Uses data attributes to identify related elements.
|
||||
*
|
||||
* Expected HTML structure:
|
||||
* - Toggle element: data-datetime-toggle="<field_id>"
|
||||
* - Picker container: data-datetime-picker="<field_id>"
|
||||
* - Input element: data-datetime-input="<field_id>"
|
||||
* - Hidden ts_utc field: data-datetime-ts="<field_id>"
|
||||
*/
|
||||
|
||||
/**
|
||||
* Toggle the datetime picker visibility.
|
||||
* @param {string} fieldId - The unique field ID prefix.
|
||||
*/
|
||||
function toggleDatetimePicker(fieldId) {
|
||||
var picker = document.querySelector('[data-datetime-picker="' + fieldId + '"]');
|
||||
var input = document.querySelector('[data-datetime-input="' + fieldId + '"]');
|
||||
var tsField = document.querySelector('[data-datetime-ts="' + fieldId + '"]');
|
||||
var toggle = document.querySelector('[data-datetime-toggle="' + fieldId + '"]');
|
||||
|
||||
if (!picker || !toggle) return;
|
||||
|
||||
if (picker.style.display === 'none') {
|
||||
picker.style.display = 'block';
|
||||
toggle.textContent = 'Use current time';
|
||||
} else {
|
||||
picker.style.display = 'none';
|
||||
toggle.textContent = 'Set custom date';
|
||||
if (input) input.value = '';
|
||||
if (tsField) tsField.value = '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the hidden ts_utc field when datetime input changes.
|
||||
* @param {string} fieldId - The unique field ID prefix.
|
||||
*/
|
||||
function updateDatetimeTs(fieldId) {
|
||||
var input = document.querySelector('[data-datetime-input="' + fieldId + '"]');
|
||||
var tsField = document.querySelector('[data-datetime-ts="' + fieldId + '"]');
|
||||
|
||||
if (!tsField) return;
|
||||
|
||||
if (input && input.value) {
|
||||
var date = new Date(input.value);
|
||||
tsField.value = date.getTime().toString();
|
||||
} else {
|
||||
tsField.value = '0';
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ from animaltrack.selection import compute_roster_hash, parse_filter, resolve_fil
|
||||
from animaltrack.selection.validation import SelectionContext, validate_selection
|
||||
from animaltrack.services.animal import AnimalService, ValidationError
|
||||
from animaltrack.web.auth import UserRole, require_role
|
||||
from animaltrack.web.templates import render_page
|
||||
from animaltrack.web.templates import render_page, render_page_post
|
||||
from animaltrack.web.templates.actions import (
|
||||
attrs_diff_panel,
|
||||
attrs_form,
|
||||
@@ -206,9 +206,11 @@ async def animal_cohort(request: Request, session):
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
cohort_form(locations, species_list),
|
||||
push_url="/actions/cohort",
|
||||
title="Create Cohort - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
@@ -349,9 +351,11 @@ async def hatch_recorded(request: Request, session):
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
hatch_form(locations, species_list),
|
||||
push_url="/actions/hatch",
|
||||
title="Record Hatch - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
@@ -690,9 +694,11 @@ async def animal_tag_add(request: Request, session):
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
tag_add_form(),
|
||||
push_url="/actions/tag-add",
|
||||
title="Add Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
@@ -939,9 +945,11 @@ async def animal_tag_end(request: Request, session):
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
tag_end_form(),
|
||||
push_url="/actions/tag-end",
|
||||
title="End Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
@@ -1175,9 +1183,11 @@ async def animal_attrs(request: Request, session):
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
attrs_form(),
|
||||
push_url="/actions/attrs",
|
||||
title="Update Attributes - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
@@ -1455,10 +1465,11 @@ async def animal_outcome(request: Request, session):
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
product_repo = ProductRepository(db)
|
||||
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
|
||||
|
||||
return render_page(
|
||||
return render_page_post(
|
||||
request,
|
||||
outcome_form(
|
||||
filter_str="",
|
||||
@@ -1468,6 +1479,7 @@ async def animal_outcome(request: Request, session):
|
||||
resolved_count=0,
|
||||
products=products,
|
||||
),
|
||||
push_url="/actions/outcome",
|
||||
title="Record Outcome - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
@@ -1678,7 +1690,8 @@ async def animal_status_correct(req: Request, session):
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
req,
|
||||
status_correct_form(
|
||||
filter_str="",
|
||||
@@ -1687,6 +1700,7 @@ async def animal_status_correct(req: Request, session):
|
||||
ts_utc=int(time.time() * 1000),
|
||||
resolved_count=0,
|
||||
),
|
||||
push_url="/actions/status-correct",
|
||||
title="Correct Status - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ from animaltrack.repositories.products import ProductRepository
|
||||
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
||||
from animaltrack.repositories.users import UserRepository
|
||||
from animaltrack.services.products import ProductService, ValidationError
|
||||
from animaltrack.web.templates import render_page
|
||||
from animaltrack.web.templates import render_page, render_page_post
|
||||
from animaltrack.web.templates.eggs import eggs_page
|
||||
|
||||
# 30 days in milliseconds
|
||||
@@ -365,7 +365,14 @@ async def product_collected(request: Request, session):
|
||||
# Validate location_id
|
||||
if not location_id:
|
||||
return _render_harvest_error(
|
||||
request, db, locations, products, None, "Please select a location"
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
None,
|
||||
"Please select a location",
|
||||
quantity=quantity_str,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Validate quantity
|
||||
@@ -373,12 +380,26 @@ async def product_collected(request: Request, session):
|
||||
quantity = int(quantity_str)
|
||||
except ValueError:
|
||||
return _render_harvest_error(
|
||||
request, db, locations, products, location_id, "Quantity must be a number"
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
location_id,
|
||||
"Quantity must be a number",
|
||||
quantity=quantity_str,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
if quantity < 0:
|
||||
return _render_harvest_error(
|
||||
request, db, locations, products, location_id, "Quantity cannot be negative"
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
location_id,
|
||||
"Quantity cannot be negative",
|
||||
quantity=quantity_str,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Get timestamp - use provided or current (supports backdating)
|
||||
@@ -389,7 +410,14 @@ async def product_collected(request: Request, session):
|
||||
|
||||
if not resolved_ids:
|
||||
return _render_harvest_error(
|
||||
request, db, locations, products, location_id, "No ducks at this location"
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
location_id,
|
||||
"No ducks at this location",
|
||||
quantity=quantity_str,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Create product service
|
||||
@@ -422,7 +450,16 @@ async def product_collected(request: Request, session):
|
||||
route="/actions/product-collected",
|
||||
)
|
||||
except ValidationError as e:
|
||||
return _render_harvest_error(request, db, locations, products, location_id, str(e))
|
||||
return _render_harvest_error(
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
location_id,
|
||||
str(e),
|
||||
quantity=quantity_str,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Save user defaults (only if user exists in database)
|
||||
if UserRepository(db).get(actor):
|
||||
@@ -446,7 +483,8 @@ async def product_collected(request: Request, session):
|
||||
display_data = _get_eggs_display_data(db, locations)
|
||||
|
||||
# Success: re-render form with location sticking, qty cleared
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
eggs_page(
|
||||
locations,
|
||||
@@ -457,6 +495,7 @@ async def product_collected(request: Request, session):
|
||||
sell_action=product_sold,
|
||||
**display_data,
|
||||
),
|
||||
push_url="/",
|
||||
title="Eggs - AnimalTrack",
|
||||
active_nav="eggs",
|
||||
)
|
||||
@@ -486,19 +525,48 @@ async def product_sold(request: Request, session):
|
||||
|
||||
# Validate product_code
|
||||
if not product_code:
|
||||
return _render_sell_error(request, db, locations, products, None, "Please select a product")
|
||||
return _render_sell_error(
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
None,
|
||||
"Please select a product",
|
||||
quantity=quantity_str,
|
||||
total_price_cents=total_price_str,
|
||||
buyer=buyer,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Validate quantity
|
||||
try:
|
||||
quantity = int(quantity_str)
|
||||
except ValueError:
|
||||
return _render_sell_error(
|
||||
request, db, locations, products, product_code, "Quantity must be a number"
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
product_code,
|
||||
"Quantity must be a number",
|
||||
quantity=quantity_str,
|
||||
total_price_cents=total_price_str,
|
||||
buyer=buyer,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
if quantity < 1:
|
||||
return _render_sell_error(
|
||||
request, db, locations, products, product_code, "Quantity must be at least 1"
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
product_code,
|
||||
"Quantity must be at least 1",
|
||||
quantity=quantity_str,
|
||||
total_price_cents=total_price_str,
|
||||
buyer=buyer,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Validate total_price_cents
|
||||
@@ -506,12 +574,30 @@ async def product_sold(request: Request, session):
|
||||
total_price_cents = int(total_price_str)
|
||||
except ValueError:
|
||||
return _render_sell_error(
|
||||
request, db, locations, products, product_code, "Total price must be a number"
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
product_code,
|
||||
"Total price must be a number",
|
||||
quantity=quantity_str,
|
||||
total_price_cents=total_price_str,
|
||||
buyer=buyer,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
if total_price_cents < 0:
|
||||
return _render_sell_error(
|
||||
request, db, locations, products, product_code, "Total price cannot be negative"
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
product_code,
|
||||
"Total price cannot be negative",
|
||||
quantity=quantity_str,
|
||||
total_price_cents=total_price_str,
|
||||
buyer=buyer,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Get timestamp - use provided or current (supports backdating)
|
||||
@@ -544,7 +630,18 @@ async def product_sold(request: Request, session):
|
||||
route="/actions/product-sold",
|
||||
)
|
||||
except ValidationError as e:
|
||||
return _render_sell_error(request, db, locations, products, product_code, str(e))
|
||||
return _render_sell_error(
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
product_code,
|
||||
str(e),
|
||||
quantity=quantity_str,
|
||||
total_price_cents=total_price_str,
|
||||
buyer=buyer,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# Add success toast with link to event
|
||||
add_toast(
|
||||
@@ -557,7 +654,8 @@ async def product_sold(request: Request, session):
|
||||
display_data = _get_eggs_display_data(db, locations)
|
||||
|
||||
# Success: re-render form with product sticking
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
eggs_page(
|
||||
locations,
|
||||
@@ -568,13 +666,23 @@ async def product_sold(request: Request, session):
|
||||
sell_action=product_sold,
|
||||
**display_data,
|
||||
),
|
||||
push_url="/",
|
||||
title="Eggs - AnimalTrack",
|
||||
active_nav="eggs",
|
||||
)
|
||||
|
||||
|
||||
def _render_harvest_error(request, db, locations, products, selected_location_id, error_message):
|
||||
"""Render harvest form with error message.
|
||||
def _render_harvest_error(
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
selected_location_id,
|
||||
error_message,
|
||||
quantity: str | None = None,
|
||||
notes: str | None = None,
|
||||
):
|
||||
"""Render harvest form with error message and preserved field values.
|
||||
|
||||
Args:
|
||||
request: The HTTP request.
|
||||
@@ -583,6 +691,8 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
|
||||
products: List of sellable products.
|
||||
selected_location_id: Currently selected location.
|
||||
error_message: Error message to display.
|
||||
quantity: Quantity value to preserve.
|
||||
notes: Notes value to preserve.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with 422 status.
|
||||
@@ -600,6 +710,8 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
|
||||
harvest_error=error_message,
|
||||
harvest_action=product_collected,
|
||||
sell_action=product_sold,
|
||||
harvest_quantity=quantity,
|
||||
harvest_notes=notes,
|
||||
**display_data,
|
||||
),
|
||||
title="Eggs - AnimalTrack",
|
||||
@@ -610,8 +722,19 @@ def _render_harvest_error(request, db, locations, products, selected_location_id
|
||||
)
|
||||
|
||||
|
||||
def _render_sell_error(request, db, locations, products, selected_product_code, error_message):
|
||||
"""Render sell form with error message.
|
||||
def _render_sell_error(
|
||||
request,
|
||||
db,
|
||||
locations,
|
||||
products,
|
||||
selected_product_code,
|
||||
error_message,
|
||||
quantity: str | None = None,
|
||||
total_price_cents: str | None = None,
|
||||
buyer: str | None = None,
|
||||
notes: str | None = None,
|
||||
):
|
||||
"""Render sell form with error message and preserved field values.
|
||||
|
||||
Args:
|
||||
request: The HTTP request.
|
||||
@@ -620,6 +743,10 @@ def _render_sell_error(request, db, locations, products, selected_product_code,
|
||||
products: List of sellable products.
|
||||
selected_product_code: Currently selected product code.
|
||||
error_message: Error message to display.
|
||||
quantity: Quantity value to preserve.
|
||||
total_price_cents: Total price value to preserve.
|
||||
buyer: Buyer value to preserve.
|
||||
notes: Notes value to preserve.
|
||||
|
||||
Returns:
|
||||
HTMLResponse with 422 status.
|
||||
@@ -637,6 +764,10 @@ def _render_sell_error(request, db, locations, products, selected_product_code,
|
||||
sell_error=error_message,
|
||||
harvest_action=product_collected,
|
||||
sell_action=product_sold,
|
||||
sell_quantity=quantity,
|
||||
sell_total_price_cents=total_price_cents,
|
||||
sell_buyer=buyer,
|
||||
sell_notes=notes,
|
||||
**display_data,
|
||||
),
|
||||
title="Eggs - AnimalTrack",
|
||||
|
||||
@@ -22,7 +22,7 @@ from animaltrack.repositories.locations import LocationRepository
|
||||
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
||||
from animaltrack.repositories.users import UserRepository
|
||||
from animaltrack.services.feed import FeedService, ValidationError
|
||||
from animaltrack.web.templates import render_page
|
||||
from animaltrack.web.templates import render_page, render_page_post
|
||||
from animaltrack.web.templates.feed import feed_page
|
||||
|
||||
# 30 days in milliseconds
|
||||
@@ -498,7 +498,8 @@ async def feed_given(request: Request, session):
|
||||
display_data = _get_feed_display_data(db, locations, feed_types)
|
||||
|
||||
# Success: re-render form with location/type sticking, amount reset
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
feed_page(
|
||||
locations,
|
||||
@@ -512,6 +513,7 @@ async def feed_given(request: Request, session):
|
||||
purchase_action=feed_purchased,
|
||||
**display_data,
|
||||
),
|
||||
push_url="/feed",
|
||||
title="Feed - AnimalTrack",
|
||||
active_nav="feed",
|
||||
)
|
||||
@@ -666,7 +668,8 @@ async def feed_purchased(request: Request, session):
|
||||
display_data = _get_feed_display_data(db, locations, feed_types)
|
||||
|
||||
# Success: re-render form with fields cleared
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
feed_page(
|
||||
locations,
|
||||
@@ -676,6 +679,7 @@ async def feed_purchased(request: Request, session):
|
||||
purchase_action=feed_purchased,
|
||||
**display_data,
|
||||
),
|
||||
push_url="/feed",
|
||||
title="Feed - AnimalTrack",
|
||||
active_nav="feed",
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ from animaltrack.repositories.locations import LocationRepository
|
||||
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
|
||||
from animaltrack.selection.validation import SelectionContext, validate_selection
|
||||
from animaltrack.services.animal import AnimalService, ValidationError
|
||||
from animaltrack.web.templates import render_page
|
||||
from animaltrack.web.templates import render_page, render_page_post
|
||||
from animaltrack.web.templates.move import diff_panel, move_form
|
||||
|
||||
# Milliseconds per day
|
||||
@@ -396,13 +396,15 @@ async def animal_move(request: Request, session):
|
||||
display_data = _get_move_display_data(db, locations)
|
||||
|
||||
# Success: re-render fresh form (nothing sticks per spec)
|
||||
return render_page(
|
||||
# Use render_page_post to set HX-Push-Url header for correct browser URL
|
||||
return render_page_post(
|
||||
request,
|
||||
move_form(
|
||||
locations,
|
||||
action=animal_move,
|
||||
**display_data,
|
||||
),
|
||||
push_url="/move",
|
||||
title="Move - AnimalTrack",
|
||||
active_nav="move",
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ABOUTME: Templates package for AnimalTrack web UI.
|
||||
# ABOUTME: Contains reusable UI components built with FastHTML and MonsterUI.
|
||||
|
||||
from animaltrack.web.templates.base import page, render_page
|
||||
from animaltrack.web.templates.base import page, render_page, render_page_post
|
||||
from animaltrack.web.templates.nav import BottomNav
|
||||
|
||||
__all__ = ["page", "render_page", "BottomNav"]
|
||||
__all__ = ["page", "render_page", "render_page_post", "BottomNav"]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Select, Span
|
||||
from fasthtml.common import H2, H3, A, Div, Form, Hidden, Input, Option, P, Script, Select, Span
|
||||
from monsterui.all import (
|
||||
Alert,
|
||||
AlertT,
|
||||
@@ -12,7 +12,6 @@ from monsterui.all import (
|
||||
ButtonT,
|
||||
FormLabel,
|
||||
LabelInput,
|
||||
LabelSelect,
|
||||
LabelTextArea,
|
||||
)
|
||||
from ulid import ULID
|
||||
@@ -21,6 +20,103 @@ from animaltrack.models.animals import Animal
|
||||
from animaltrack.models.reference import Location, Species
|
||||
from animaltrack.selection.validation import SelectionDiff
|
||||
|
||||
# =============================================================================
|
||||
# Selection Diff Confirmation Panel
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def diff_confirmation_panel(
|
||||
diff: SelectionDiff,
|
||||
filter_str: str,
|
||||
resolved_ids: list[str],
|
||||
roster_hash: str,
|
||||
ts_utc: int,
|
||||
action: Callable[..., Any] | str,
|
||||
action_hidden_fields: list[tuple[str, str]],
|
||||
cancel_url: str,
|
||||
confirm_button_text: str,
|
||||
question_text: str,
|
||||
confirm_button_cls: str = ButtonT.primary,
|
||||
) -> Div:
|
||||
"""Create a confirmation panel for selection mismatch scenarios.
|
||||
|
||||
This is a reusable component for all action forms that use optimistic locking.
|
||||
When the client's selection differs from the server's current state, this panel
|
||||
shows what changed and asks for confirmation before proceeding.
|
||||
|
||||
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).
|
||||
ts_utc: Timestamp for resolution.
|
||||
action: Route function or URL for confirmation submit.
|
||||
action_hidden_fields: List of (name, value) tuples for action-specific fields.
|
||||
cancel_url: URL for the cancel button.
|
||||
confirm_button_text: Text for the confirm button.
|
||||
question_text: Question shown in the alert (e.g. "Would you like to...").
|
||||
confirm_button_cls: Button style class (default: ButtonT.primary).
|
||||
|
||||
Returns:
|
||||
Div containing the diff panel with confirm button.
|
||||
"""
|
||||
# Build description of changes
|
||||
changes = []
|
||||
if diff.removed:
|
||||
changes.append(f"{len(diff.removed)} animals were removed 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
|
||||
]
|
||||
|
||||
# Build action-specific hidden fields
|
||||
action_fields = [Hidden(name=name, value=value) for name, value in action_hidden_fields]
|
||||
|
||||
confirm_form = Form(
|
||||
*resolved_id_fields,
|
||||
Hidden(name="filter", value=filter_str),
|
||||
Hidden(name="roster_hash", value=roster_hash),
|
||||
*action_fields,
|
||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
||||
Hidden(name="confirmed", value="true"),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
Div(
|
||||
A(
|
||||
"Cancel",
|
||||
href=cancel_url,
|
||||
cls=ButtonT.default,
|
||||
),
|
||||
Button(
|
||||
confirm_button_text,
|
||||
type="submit",
|
||||
cls=confirm_button_cls,
|
||||
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(question_text, cls="text-sm"),
|
||||
),
|
||||
cls=AlertT.warning,
|
||||
),
|
||||
confirm_form,
|
||||
cls="space-y-4",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Event Datetime Picker Component
|
||||
# =============================================================================
|
||||
@@ -45,43 +141,14 @@ def event_datetime_field(
|
||||
Returns:
|
||||
Div containing the datetime picker with toggle functionality.
|
||||
"""
|
||||
picker_id = f"{field_id}_picker"
|
||||
input_id = f"{field_id}_input"
|
||||
ts_utc_id = f"{field_id}_ts_utc"
|
||||
|
||||
# If initial value is set, start with picker expanded
|
||||
has_initial = bool(initial_value)
|
||||
picker_style = "display: block;" if has_initial else "display: none;"
|
||||
toggle_text = "Use current time" if has_initial else "Set custom date"
|
||||
|
||||
# Inline JavaScript for toggle click handler
|
||||
toggle_onclick = f"""
|
||||
var picker = document.getElementById('{picker_id}');
|
||||
var input = document.getElementById('{input_id}');
|
||||
var tsField = document.getElementById('{ts_utc_id}');
|
||||
if (picker.style.display === 'none') {{
|
||||
picker.style.display = 'block';
|
||||
this.textContent = 'Use current time';
|
||||
}} else {{
|
||||
picker.style.display = 'none';
|
||||
this.textContent = 'Set custom date';
|
||||
input.value = '';
|
||||
if (tsField) tsField.value = '0';
|
||||
}}
|
||||
"""
|
||||
|
||||
# Inline JavaScript for input change handler
|
||||
input_onchange = f"""
|
||||
var tsField = document.getElementById('{ts_utc_id}');
|
||||
if (tsField && this.value) {{
|
||||
var date = new Date(this.value);
|
||||
tsField.value = date.getTime().toString();
|
||||
}} else if (tsField) {{
|
||||
tsField.value = '0';
|
||||
}}
|
||||
"""
|
||||
|
||||
return Div(
|
||||
# Load static JS for datetime picker functionality
|
||||
Script(src="/static/v1/datetime-picker.js"),
|
||||
FormLabel("Event Time"),
|
||||
Div(
|
||||
P(
|
||||
@@ -90,29 +157,30 @@ def event_datetime_field(
|
||||
Span(
|
||||
toggle_text,
|
||||
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
|
||||
hx_on_click=toggle_onclick,
|
||||
data_datetime_toggle=field_id,
|
||||
hx_on_click=f"toggleDatetimePicker('{field_id}')",
|
||||
),
|
||||
cls="text-sm",
|
||||
),
|
||||
Div(
|
||||
Input(
|
||||
id=input_id,
|
||||
name=f"{field_id}_value",
|
||||
type="datetime-local",
|
||||
value=initial_value,
|
||||
cls="uk-input w-full mt-2",
|
||||
hx_on_change=input_onchange,
|
||||
data_datetime_input=field_id,
|
||||
hx_on_change=f"updateDatetimeTs('{field_id}')",
|
||||
),
|
||||
P(
|
||||
"Select date/time for this event (leave empty for current time)",
|
||||
cls="text-xs text-stone-500 mt-1",
|
||||
),
|
||||
id=picker_id,
|
||||
data_datetime_picker=field_id,
|
||||
style=picker_style,
|
||||
),
|
||||
cls="mt-1",
|
||||
),
|
||||
Hidden(id=ts_utc_id, name="ts_utc", value=initial_ts),
|
||||
Hidden(name="ts_utc", value=initial_ts, data_datetime_ts=field_id),
|
||||
cls="space-y-1",
|
||||
)
|
||||
|
||||
@@ -215,19 +283,17 @@ def cohort_form(
|
||||
H2("Create Animal Cohort", cls="text-xl font-bold mb-4"),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Species dropdown
|
||||
LabelSelect(
|
||||
*species_options,
|
||||
label="Species",
|
||||
id="species",
|
||||
name="species",
|
||||
# Species dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Species", _for="species"),
|
||||
Select(*species_options, name="species", id="species", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Location dropdown
|
||||
LabelSelect(
|
||||
*location_options,
|
||||
label="Location",
|
||||
id="location_id",
|
||||
name="location_id",
|
||||
# Location dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Location", _for="location_id"),
|
||||
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Count input
|
||||
LabelInput(
|
||||
@@ -239,26 +305,23 @@ def cohort_form(
|
||||
value=count_value,
|
||||
placeholder="Number of animals",
|
||||
),
|
||||
# Life stage dropdown
|
||||
LabelSelect(
|
||||
*life_stage_options,
|
||||
label="Life Stage",
|
||||
id="life_stage",
|
||||
name="life_stage",
|
||||
# Life stage dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Life Stage", _for="life_stage"),
|
||||
Select(*life_stage_options, name="life_stage", id="life_stage", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Sex dropdown
|
||||
LabelSelect(
|
||||
*sex_options,
|
||||
label="Sex",
|
||||
id="sex",
|
||||
name="sex",
|
||||
# Sex dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Sex", _for="sex"),
|
||||
Select(*sex_options, name="sex", id="sex", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Origin dropdown
|
||||
LabelSelect(
|
||||
*origin_options,
|
||||
label="Origin",
|
||||
id="origin",
|
||||
name="origin",
|
||||
# Origin dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Origin", _for="origin"),
|
||||
Select(*origin_options, name="origin", id="origin", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Optional notes
|
||||
LabelTextArea(
|
||||
@@ -272,7 +335,7 @@ def cohort_form(
|
||||
# Hidden nonce for idempotency
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Create Cohort", type="submit", cls=ButtonT.primary),
|
||||
Button("Create Cohort", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
@@ -350,19 +413,17 @@ def hatch_form(
|
||||
H2("Record Hatch", cls="text-xl font-bold mb-4"),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Species dropdown
|
||||
LabelSelect(
|
||||
*species_options,
|
||||
label="Species",
|
||||
id="species",
|
||||
name="species",
|
||||
# Species dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Species", _for="species"),
|
||||
Select(*species_options, name="species", id="species", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Hatch location dropdown
|
||||
LabelSelect(
|
||||
*location_options,
|
||||
label="Hatch Location",
|
||||
id="location_id",
|
||||
name="location_id",
|
||||
# Hatch location dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Hatch Location", _for="location_id"),
|
||||
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Hatched count input
|
||||
LabelInput(
|
||||
@@ -374,13 +435,17 @@ def hatch_form(
|
||||
value=hatched_live_value,
|
||||
placeholder="Number hatched",
|
||||
),
|
||||
# Brood location dropdown (optional)
|
||||
# Brood location dropdown (optional) - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
LabelSelect(
|
||||
*brood_location_options,
|
||||
label="Brood Location (optional)",
|
||||
id="assigned_brood_location_id",
|
||||
name="assigned_brood_location_id",
|
||||
Div(
|
||||
FormLabel("Brood Location (optional)", _for="assigned_brood_location_id"),
|
||||
Select(
|
||||
*brood_location_options,
|
||||
name="assigned_brood_location_id",
|
||||
id="assigned_brood_location_id",
|
||||
cls="uk-select",
|
||||
),
|
||||
cls="space-y-2",
|
||||
),
|
||||
P(
|
||||
"If different from hatch location, hatchlings will be placed here",
|
||||
@@ -399,7 +464,7 @@ def hatch_form(
|
||||
# Hidden nonce for idempotency
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Record Hatch", type="submit", cls=ButtonT.primary),
|
||||
Button("Record Hatch", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
@@ -526,6 +591,7 @@ def promote_form(
|
||||
"Save Changes" if is_rename else "Promote to Identified",
|
||||
type="submit",
|
||||
cls=ButtonT.primary,
|
||||
hx_disabled_elt="this",
|
||||
),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
@@ -652,7 +718,7 @@ def tag_add_form(
|
||||
Hidden(name="confirmed", value=""),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Add Tag", type="submit", cls=ButtonT.primary),
|
||||
Button("Add Tag", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
@@ -683,60 +749,17 @@ def tag_add_diff_panel(
|
||||
Returns:
|
||||
Div containing the diff panel with confirm button.
|
||||
"""
|
||||
# Build description of changes
|
||||
changes = []
|
||||
if diff.removed:
|
||||
changes.append(f"{len(diff.removed)} animals were removed 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="tag", value=tag),
|
||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
||||
Hidden(name="confirmed", value="true"),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
Div(
|
||||
Button(
|
||||
"Cancel",
|
||||
type="button",
|
||||
cls=ButtonT.default,
|
||||
onclick="window.location.href='/actions/tag-add'",
|
||||
),
|
||||
Button(
|
||||
f"Confirm Tag ({diff.server_count} animals)",
|
||||
type="submit",
|
||||
cls=ButtonT.primary,
|
||||
),
|
||||
cls="flex gap-3 mt-4",
|
||||
),
|
||||
return diff_confirmation_panel(
|
||||
diff=diff,
|
||||
filter_str=filter_str,
|
||||
resolved_ids=resolved_ids,
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
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 tagging {diff.server_count} animals as '{tag}'?",
|
||||
cls="text-sm",
|
||||
),
|
||||
),
|
||||
cls=AlertT.warning,
|
||||
),
|
||||
confirm_form,
|
||||
cls="space-y-4",
|
||||
action_hidden_fields=[("tag", tag)],
|
||||
cancel_url="/actions/tag-add",
|
||||
confirm_button_text=f"Confirm Tag ({diff.server_count} animals)",
|
||||
question_text=f"Would you like to proceed with tagging {diff.server_count} animals as '{tag}'?",
|
||||
)
|
||||
|
||||
|
||||
@@ -845,12 +868,11 @@ def tag_end_form(
|
||||
),
|
||||
# Selection container - updated via HTMX when filter changes
|
||||
selection_container,
|
||||
# Tag dropdown
|
||||
LabelSelect(
|
||||
*tag_options,
|
||||
label="Tag to End",
|
||||
id="tag",
|
||||
name="tag",
|
||||
# Tag dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Tag to End", _for="tag"),
|
||||
Select(*tag_options, name="tag", id="tag", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
)
|
||||
if active_tags
|
||||
else Div(
|
||||
@@ -872,7 +894,13 @@ def tag_end_form(
|
||||
Hidden(name="confirmed", value=""),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
|
||||
Button(
|
||||
"End Tag",
|
||||
type="submit",
|
||||
cls=ButtonT.primary,
|
||||
disabled=not active_tags,
|
||||
hx_disabled_elt="this",
|
||||
),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
@@ -903,60 +931,17 @@ def tag_end_diff_panel(
|
||||
Returns:
|
||||
Div containing the diff panel with confirm button.
|
||||
"""
|
||||
# Build description of changes
|
||||
changes = []
|
||||
if diff.removed:
|
||||
changes.append(f"{len(diff.removed)} animals were removed 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="tag", value=tag),
|
||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
||||
Hidden(name="confirmed", value="true"),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
Div(
|
||||
Button(
|
||||
"Cancel",
|
||||
type="button",
|
||||
cls=ButtonT.default,
|
||||
onclick="window.location.href='/actions/tag-end'",
|
||||
),
|
||||
Button(
|
||||
f"Confirm End Tag ({diff.server_count} animals)",
|
||||
type="submit",
|
||||
cls=ButtonT.primary,
|
||||
),
|
||||
cls="flex gap-3 mt-4",
|
||||
),
|
||||
return diff_confirmation_panel(
|
||||
diff=diff,
|
||||
filter_str=filter_str,
|
||||
resolved_ids=resolved_ids,
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
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 ending tag '{tag}' on {diff.server_count} animals?",
|
||||
cls="text-sm",
|
||||
),
|
||||
),
|
||||
cls=AlertT.warning,
|
||||
),
|
||||
confirm_form,
|
||||
cls="space-y-4",
|
||||
action_hidden_fields=[("tag", tag)],
|
||||
cancel_url="/actions/tag-end",
|
||||
confirm_button_text=f"Confirm End Tag ({diff.server_count} animals)",
|
||||
question_text=f"Would you like to proceed with ending tag '{tag}' on {diff.server_count} animals?",
|
||||
)
|
||||
|
||||
|
||||
@@ -1115,7 +1100,7 @@ def attrs_form(
|
||||
Hidden(name="confirmed", value=""),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Update Attributes", type="submit", cls=ButtonT.primary),
|
||||
Button("Update Attributes", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
@@ -1150,62 +1135,21 @@ def attrs_diff_panel(
|
||||
Returns:
|
||||
Div containing the diff panel with confirm button.
|
||||
"""
|
||||
# Build description of changes
|
||||
changes = []
|
||||
if diff.removed:
|
||||
changes.append(f"{len(diff.removed)} animals were removed 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="sex", value=sex or ""),
|
||||
Hidden(name="life_stage", value=life_stage or ""),
|
||||
Hidden(name="repro_status", value=repro_status or ""),
|
||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
||||
Hidden(name="confirmed", value="true"),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
Div(
|
||||
Button(
|
||||
"Cancel",
|
||||
type="button",
|
||||
cls=ButtonT.default,
|
||||
onclick="window.location.href='/actions/attrs'",
|
||||
),
|
||||
Button(
|
||||
f"Confirm Update ({diff.server_count} animals)",
|
||||
type="submit",
|
||||
cls=ButtonT.primary,
|
||||
),
|
||||
cls="flex gap-3 mt-4",
|
||||
),
|
||||
return diff_confirmation_panel(
|
||||
diff=diff,
|
||||
filter_str=filter_str,
|
||||
resolved_ids=resolved_ids,
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
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 updating {diff.server_count} animals?",
|
||||
cls="text-sm",
|
||||
),
|
||||
),
|
||||
cls=AlertT.warning,
|
||||
),
|
||||
confirm_form,
|
||||
cls="space-y-4",
|
||||
action_hidden_fields=[
|
||||
("sex", sex or ""),
|
||||
("life_stage", life_stage or ""),
|
||||
("repro_status", repro_status or ""),
|
||||
],
|
||||
cancel_url="/actions/attrs",
|
||||
confirm_button_text=f"Confirm Update ({diff.server_count} animals)",
|
||||
question_text=f"Would you like to proceed with updating {diff.server_count} animals?",
|
||||
)
|
||||
|
||||
|
||||
@@ -1318,20 +1262,22 @@ def outcome_form(
|
||||
yield_section = Div(
|
||||
H3("Yield Items", cls="text-lg font-semibold mt-4 mb-2"),
|
||||
P("Optional: record products collected from harvest", cls="text-sm text-stone-500 mb-3"),
|
||||
# Using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
LabelSelect(
|
||||
*product_options,
|
||||
label="Product",
|
||||
id="yield_product_code",
|
||||
name="yield_product_code",
|
||||
cls="flex-1",
|
||||
Div(
|
||||
FormLabel("Product", _for="yield_product_code"),
|
||||
Select(
|
||||
*product_options,
|
||||
name="yield_product_code",
|
||||
id="yield_product_code",
|
||||
cls="uk-select",
|
||||
),
|
||||
cls="space-y-2 flex-1",
|
||||
),
|
||||
LabelSelect(
|
||||
*unit_options,
|
||||
label="Unit",
|
||||
id="yield_unit",
|
||||
name="yield_unit",
|
||||
cls="w-32",
|
||||
Div(
|
||||
FormLabel("Unit", _for="yield_unit"),
|
||||
Select(*unit_options, name="yield_unit", id="yield_unit", cls="uk-select"),
|
||||
cls="space-y-2 w-32",
|
||||
),
|
||||
cls="flex gap-3",
|
||||
),
|
||||
@@ -1377,13 +1323,11 @@ def outcome_form(
|
||||
),
|
||||
# Selection container - updated via HTMX when filter changes
|
||||
selection_container,
|
||||
# Outcome selection
|
||||
LabelSelect(
|
||||
*outcome_options,
|
||||
label="Outcome",
|
||||
id="outcome",
|
||||
name="outcome",
|
||||
required=True,
|
||||
# Outcome selection - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Outcome", _for="outcome"),
|
||||
Select(*outcome_options, name="outcome", id="outcome", cls="uk-select", required=True),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Reason field
|
||||
LabelInput(
|
||||
@@ -1410,7 +1354,7 @@ def outcome_form(
|
||||
Hidden(name="confirmed", value=""),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Record Outcome", type="submit", cls=ButtonT.destructive),
|
||||
Button("Record Outcome", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
@@ -1451,65 +1395,25 @@ def outcome_diff_panel(
|
||||
Returns:
|
||||
Div containing the diff panel with confirm button.
|
||||
"""
|
||||
# Build description of changes
|
||||
changes = []
|
||||
if diff.removed:
|
||||
changes.append(f"{len(diff.removed)} animals were removed 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="outcome", value=outcome),
|
||||
Hidden(name="reason", value=reason or ""),
|
||||
Hidden(name="yield_product_code", value=yield_product_code or ""),
|
||||
Hidden(name="yield_unit", value=yield_unit or ""),
|
||||
Hidden(name="yield_quantity", value=str(yield_quantity) if yield_quantity else ""),
|
||||
Hidden(name="yield_weight_kg", value=str(yield_weight_kg) if yield_weight_kg else ""),
|
||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
||||
Hidden(name="confirmed", value="true"),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
Div(
|
||||
Button(
|
||||
"Cancel",
|
||||
type="button",
|
||||
cls=ButtonT.default,
|
||||
onclick="window.location.href='/actions/outcome'",
|
||||
),
|
||||
Button(
|
||||
f"Confirm Outcome ({diff.server_count} animals)",
|
||||
type="submit",
|
||||
cls=ButtonT.destructive,
|
||||
),
|
||||
cls="flex gap-3 mt-4",
|
||||
),
|
||||
return diff_confirmation_panel(
|
||||
diff=diff,
|
||||
filter_str=filter_str,
|
||||
resolved_ids=resolved_ids,
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
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 recording {outcome} for {diff.server_count} animals?",
|
||||
cls="text-sm",
|
||||
),
|
||||
),
|
||||
cls=AlertT.warning,
|
||||
),
|
||||
confirm_form,
|
||||
cls="space-y-4",
|
||||
action_hidden_fields=[
|
||||
("outcome", outcome),
|
||||
("reason", reason or ""),
|
||||
("yield_product_code", yield_product_code or ""),
|
||||
("yield_unit", yield_unit or ""),
|
||||
("yield_quantity", str(yield_quantity) if yield_quantity else ""),
|
||||
("yield_weight_kg", str(yield_weight_kg) if yield_weight_kg else ""),
|
||||
],
|
||||
cancel_url="/actions/outcome",
|
||||
confirm_button_text=f"Confirm Outcome ({diff.server_count} animals)",
|
||||
question_text=f"Would you like to proceed with recording {outcome} for {diff.server_count} animals?",
|
||||
confirm_button_cls=ButtonT.destructive,
|
||||
)
|
||||
|
||||
|
||||
@@ -1599,13 +1503,13 @@ def status_correct_form(
|
||||
value=filter_str,
|
||||
placeholder="e.g., species:duck location:Coop1",
|
||||
),
|
||||
# New status selection
|
||||
LabelSelect(
|
||||
*status_options,
|
||||
label="New Status",
|
||||
id="new_status",
|
||||
name="new_status",
|
||||
required=True,
|
||||
# New status selection - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("New Status", _for="new_status"),
|
||||
Select(
|
||||
*status_options, name="new_status", id="new_status", cls="uk-select", required=True
|
||||
),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Reason field (required for admin actions)
|
||||
LabelInput(
|
||||
@@ -1631,7 +1535,7 @@ def status_correct_form(
|
||||
Hidden(name="confirmed", value=""),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Correct Status", type="submit", cls=ButtonT.destructive),
|
||||
Button("Correct Status", type="submit", cls=ButtonT.destructive, hx_disabled_elt="this"),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
@@ -1664,59 +1568,19 @@ def status_correct_diff_panel(
|
||||
Returns:
|
||||
Div containing the diff panel with confirm button.
|
||||
"""
|
||||
# Build description of changes
|
||||
changes = []
|
||||
if diff.removed:
|
||||
changes.append(f"{len(diff.removed)} animals were removed 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="new_status", value=new_status),
|
||||
Hidden(name="reason", value=reason),
|
||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
||||
Hidden(name="confirmed", value="true"),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
Div(
|
||||
Button(
|
||||
"Cancel",
|
||||
type="button",
|
||||
cls=ButtonT.default,
|
||||
onclick="window.location.href='/actions/status-correct'",
|
||||
),
|
||||
Button(
|
||||
f"Confirm Correction ({diff.server_count} animals)",
|
||||
type="submit",
|
||||
cls=ButtonT.destructive,
|
||||
),
|
||||
cls="flex gap-3 mt-4",
|
||||
),
|
||||
return diff_confirmation_panel(
|
||||
diff=diff,
|
||||
filter_str=filter_str,
|
||||
resolved_ids=resolved_ids,
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
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 correcting status to {new_status} for {diff.server_count} animals?",
|
||||
cls="text-sm",
|
||||
),
|
||||
),
|
||||
cls=AlertT.warning,
|
||||
),
|
||||
confirm_form,
|
||||
cls="space-y-4",
|
||||
action_hidden_fields=[
|
||||
("new_status", new_status),
|
||||
("reason", reason),
|
||||
],
|
||||
cancel_url="/actions/status-correct",
|
||||
confirm_button_text=f"Confirm Correction ({diff.server_count} animals)",
|
||||
question_text=f"Would you like to proceed with correcting status to {new_status} for {diff.server_count} animals?",
|
||||
confirm_button_cls=ButtonT.destructive,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# ABOUTME: Base HTML template for AnimalTrack pages.
|
||||
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
|
||||
|
||||
from fasthtml.common import Container, Div, Script, Style, Title
|
||||
from fasthtml.common import Container, Div, HttpHeader, Script, Style, Title
|
||||
from starlette.requests import Request
|
||||
|
||||
from animaltrack.models.reference import UserRole
|
||||
from animaltrack.web.templates.nav import BottomNav, BottomNavStyles
|
||||
from animaltrack.web.templates.shared_scripts import slide_over_script
|
||||
from animaltrack.web.templates.sidebar import (
|
||||
MenuDrawer,
|
||||
Sidebar,
|
||||
@@ -79,29 +80,13 @@ def EventSlideOverStyles(): # noqa: N802
|
||||
|
||||
def EventSlideOverScript(): # noqa: N802
|
||||
"""JavaScript for event slide-over panel open/close behavior."""
|
||||
return Script("""
|
||||
function openEventPanel() {
|
||||
document.getElementById('event-slide-over').classList.add('open');
|
||||
document.getElementById('event-backdrop').classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus the panel for keyboard events
|
||||
document.getElementById('event-slide-over').focus();
|
||||
}
|
||||
|
||||
function closeEventPanel() {
|
||||
document.getElementById('event-slide-over').classList.remove('open');
|
||||
document.getElementById('event-backdrop').classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// HTMX event: after loading event content, open the panel
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.detail.target.id === 'event-slide-over' ||
|
||||
evt.detail.target.id === 'event-panel-content') {
|
||||
openEventPanel();
|
||||
}
|
||||
});
|
||||
""")
|
||||
return slide_over_script(
|
||||
panel_id="event-slide-over",
|
||||
backdrop_id="event-backdrop",
|
||||
open_fn_name="openEventPanel",
|
||||
close_fn_name="closeEventPanel",
|
||||
htmx_auto_open_targets=["event-slide-over", "event-panel-content"],
|
||||
)
|
||||
|
||||
|
||||
def CsrfHeaderScript(): # noqa: N802
|
||||
@@ -157,6 +142,8 @@ def EventSlideOver(): # noqa: N802
|
||||
"shadow-2xl border-l border-stone-700 overflow-hidden",
|
||||
tabindex="-1",
|
||||
hx_on_keydown="if(event.key==='Escape') closeEventPanel()",
|
||||
role="dialog",
|
||||
aria_label="Event details",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -215,7 +202,7 @@ def page(
|
||||
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
|
||||
),
|
||||
# Toast container with hx-preserve to survive body swaps for OOB toast injection
|
||||
Div(id="fh-toast-container", hx_preserve=True),
|
||||
Div(id="fh-toast-container", hx_preserve=True, aria_live="polite"),
|
||||
# Mobile bottom nav
|
||||
BottomNav(active_id=active_nav),
|
||||
)
|
||||
@@ -243,3 +230,26 @@ def render_page(request: Request, content, **page_kwargs):
|
||||
user_role=auth.role if auth else None,
|
||||
**page_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def render_page_post(request: Request, content, push_url: str, **page_kwargs):
|
||||
"""Wrapper for POST responses that sets HX-Push-Url header.
|
||||
|
||||
When HTMX boosted forms submit via POST, the browser URL may not be updated
|
||||
correctly. This wrapper returns the rendered page with an HX-Push-Url header
|
||||
to ensure the browser history shows the correct URL.
|
||||
|
||||
This fixes the issue where window.location.reload() after form submission
|
||||
would reload the wrong URL (the action URL instead of the display URL).
|
||||
|
||||
Args:
|
||||
request: The Starlette request object.
|
||||
content: Page content (FT components).
|
||||
push_url: The URL to push to browser history (e.g., '/feed', '/move').
|
||||
**page_kwargs: Additional arguments passed to page() (title, active_nav).
|
||||
|
||||
Returns:
|
||||
Tuple of (FT components, HttpHeader) that FastHTML processes together.
|
||||
"""
|
||||
page_content = render_page(request, content, **page_kwargs)
|
||||
return (*page_content, HttpHeader("HX-Push-Url", push_url))
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul
|
||||
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Select, Ul
|
||||
from monsterui.all import (
|
||||
Button,
|
||||
ButtonT,
|
||||
FormLabel,
|
||||
LabelInput,
|
||||
LabelSelect,
|
||||
LabelTextArea,
|
||||
TabContainer,
|
||||
)
|
||||
@@ -37,6 +37,13 @@ def eggs_page(
|
||||
cost_per_egg: float | None = None,
|
||||
sales_stats: dict | None = None,
|
||||
location_names: dict[str, str] | None = None,
|
||||
# Field value preservation on errors
|
||||
harvest_quantity: str | None = None,
|
||||
harvest_notes: str | None = None,
|
||||
sell_quantity: str | None = None,
|
||||
sell_total_price_cents: str | None = None,
|
||||
sell_buyer: str | None = None,
|
||||
sell_notes: str | None = None,
|
||||
):
|
||||
"""Create the Eggs page with tabbed forms.
|
||||
|
||||
@@ -56,6 +63,12 @@ def eggs_page(
|
||||
cost_per_egg: 30-day average cost per egg in EUR.
|
||||
sales_stats: Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents'.
|
||||
location_names: Dict mapping location_id to location name for display.
|
||||
harvest_quantity: Preserved quantity value on error.
|
||||
harvest_notes: Preserved notes value on error.
|
||||
sell_quantity: Preserved quantity value on error.
|
||||
sell_total_price_cents: Preserved total price value on error.
|
||||
sell_buyer: Preserved buyer value on error.
|
||||
sell_notes: Preserved notes value on error.
|
||||
|
||||
Returns:
|
||||
Page content with tabbed forms.
|
||||
@@ -85,6 +98,8 @@ def eggs_page(
|
||||
eggs_per_day=eggs_per_day,
|
||||
cost_per_egg=cost_per_egg,
|
||||
location_names=location_names,
|
||||
default_quantity=harvest_quantity,
|
||||
default_notes=harvest_notes,
|
||||
),
|
||||
cls="uk-active" if harvest_active else None,
|
||||
),
|
||||
@@ -96,6 +111,10 @@ def eggs_page(
|
||||
action=sell_action,
|
||||
recent_events=sell_events,
|
||||
sales_stats=sales_stats,
|
||||
default_quantity=sell_quantity,
|
||||
default_total_price_cents=sell_total_price_cents,
|
||||
default_buyer=sell_buyer,
|
||||
default_notes=sell_notes,
|
||||
),
|
||||
cls=None if harvest_active else "uk-active",
|
||||
),
|
||||
@@ -113,6 +132,8 @@ def harvest_form(
|
||||
eggs_per_day: float | None = None,
|
||||
cost_per_egg: float | None = None,
|
||||
location_names: dict[str, str] | None = None,
|
||||
default_quantity: str | None = None,
|
||||
default_notes: str | None = None,
|
||||
) -> Div:
|
||||
"""Create the Harvest form for egg collection.
|
||||
|
||||
@@ -125,6 +146,8 @@ def harvest_form(
|
||||
eggs_per_day: 30-day average eggs per day.
|
||||
cost_per_egg: 30-day average cost per egg in EUR.
|
||||
location_names: Dict mapping location_id to location name for display.
|
||||
default_quantity: Preserved quantity value on error.
|
||||
default_notes: Preserved notes value on error.
|
||||
|
||||
Returns:
|
||||
Div containing form and recent events section.
|
||||
@@ -177,12 +200,11 @@ def harvest_form(
|
||||
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Location dropdown
|
||||
LabelSelect(
|
||||
*location_options,
|
||||
label="Location",
|
||||
id="location_id",
|
||||
name="location_id",
|
||||
# Location dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Location", _for="location_id"),
|
||||
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Quantity input (integer only, 0 allowed for "checked but found none")
|
||||
LabelInput(
|
||||
@@ -194,6 +216,7 @@ def harvest_form(
|
||||
step="1",
|
||||
placeholder="Number of eggs",
|
||||
required=True,
|
||||
value=default_quantity or "",
|
||||
),
|
||||
# Optional notes
|
||||
LabelTextArea(
|
||||
@@ -201,13 +224,14 @@ def harvest_form(
|
||||
id="notes",
|
||||
name="notes",
|
||||
placeholder="Optional notes",
|
||||
value=default_notes or "",
|
||||
),
|
||||
# Event datetime picker (for backdating)
|
||||
event_datetime_field("harvest_datetime"),
|
||||
# Hidden nonce for idempotency
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Record Harvest", type="submit", cls=ButtonT.primary),
|
||||
Button("Record Harvest", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
@@ -232,6 +256,10 @@ def sell_form(
|
||||
action: Callable[..., Any] | str = "/actions/product-sold",
|
||||
recent_events: list[tuple[Event, bool]] | None = None,
|
||||
sales_stats: dict | None = None,
|
||||
default_quantity: str | None = None,
|
||||
default_total_price_cents: str | None = None,
|
||||
default_buyer: str | None = None,
|
||||
default_notes: str | None = None,
|
||||
) -> Div:
|
||||
"""Create the Sell form for recording sales.
|
||||
|
||||
@@ -242,6 +270,10 @@ def sell_form(
|
||||
action: Route function or URL string for form submission.
|
||||
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
||||
sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales.
|
||||
default_quantity: Preserved quantity value on error.
|
||||
default_total_price_cents: Preserved total price value on error.
|
||||
default_buyer: Preserved buyer value on error.
|
||||
default_notes: Preserved notes value on error.
|
||||
|
||||
Returns:
|
||||
Div containing form and recent events section.
|
||||
@@ -300,12 +332,11 @@ def sell_form(
|
||||
H2("Sell Products", cls="text-xl font-bold mb-4"),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Product dropdown
|
||||
LabelSelect(
|
||||
*product_options,
|
||||
label="Product",
|
||||
id="product_code",
|
||||
name="product_code",
|
||||
# Product dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Product", _for="product_code"),
|
||||
Select(*product_options, name="product_code", id="product_code", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Quantity input (integer only, min=1)
|
||||
LabelInput(
|
||||
@@ -317,6 +348,7 @@ def sell_form(
|
||||
step="1",
|
||||
placeholder="Number of items sold",
|
||||
required=True,
|
||||
value=default_quantity or "",
|
||||
),
|
||||
# Total price in cents
|
||||
LabelInput(
|
||||
@@ -328,6 +360,7 @@ def sell_form(
|
||||
step="1",
|
||||
placeholder="Total price in cents",
|
||||
required=True,
|
||||
value=default_total_price_cents or "",
|
||||
),
|
||||
# Optional buyer
|
||||
LabelInput(
|
||||
@@ -336,6 +369,7 @@ def sell_form(
|
||||
name="buyer",
|
||||
type="text",
|
||||
placeholder="Optional buyer name",
|
||||
value=default_buyer or "",
|
||||
),
|
||||
# Optional notes
|
||||
LabelTextArea(
|
||||
@@ -343,13 +377,14 @@ def sell_form(
|
||||
id="sell_notes",
|
||||
name="notes",
|
||||
placeholder="Optional notes",
|
||||
value=default_notes or "",
|
||||
),
|
||||
# Event datetime picker (for backdating)
|
||||
event_datetime_field("sell_datetime"),
|
||||
# Hidden nonce for idempotency
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Record Sale", type="submit", cls=ButtonT.primary),
|
||||
Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
|
||||
@@ -260,7 +260,7 @@ def give_feed_form(
|
||||
# Hidden nonce
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Record Feed Given", type="submit", cls=ButtonT.primary),
|
||||
Button("Record Feed Given", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
action=action,
|
||||
method="post",
|
||||
cls="space-y-4",
|
||||
@@ -404,7 +404,7 @@ def purchase_feed_form(
|
||||
# Hidden nonce
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Record Purchase", type="submit", cls=ButtonT.primary),
|
||||
Button("Record Purchase", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
action=action,
|
||||
method="post",
|
||||
cls="space-y-4",
|
||||
|
||||
@@ -47,7 +47,7 @@ def location_list(
|
||||
placeholder="Enter location name",
|
||||
),
|
||||
Hidden(name="nonce", value=str(uuid4())),
|
||||
Button("Create Location", type="submit", cls=ButtonT.primary),
|
||||
Button("Create Location", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
hx_post="/actions/location-created",
|
||||
hx_target="#location-list",
|
||||
hx_swap="outerHTML",
|
||||
@@ -160,7 +160,7 @@ def rename_form(
|
||||
Hidden(name="nonce", value=str(uuid4())),
|
||||
DivFullySpaced(
|
||||
Button("Cancel", type="button", cls=ButtonT.ghost, hx_get="/locations"),
|
||||
Button("Rename", type="submit", cls=ButtonT.primary),
|
||||
Button("Rename", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
),
|
||||
hx_post="/actions/location-renamed",
|
||||
hx_target="#location-list",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import H2, Div, Form, Hidden, Option, P, Span
|
||||
from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect, LabelTextArea
|
||||
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
|
||||
@@ -151,12 +151,11 @@ def move_form(
|
||||
),
|
||||
# Selection container - updated via HTMX when filter changes
|
||||
selection_container,
|
||||
# Destination dropdown
|
||||
LabelSelect(
|
||||
*location_options,
|
||||
label="Destination",
|
||||
id="to_location_id",
|
||||
name="to_location_id",
|
||||
# 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(
|
||||
@@ -175,7 +174,7 @@ def move_form(
|
||||
Hidden(name="confirmed", value=""),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Move Animals", type="submit", cls=ButtonT.primary),
|
||||
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",
|
||||
@@ -254,16 +253,16 @@ def diff_panel(
|
||||
Hidden(name="confirmed", value="true"),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
Div(
|
||||
Button(
|
||||
A(
|
||||
"Cancel",
|
||||
type="button",
|
||||
href="/move",
|
||||
cls=ButtonT.default,
|
||||
onclick="window.location.href='/move'",
|
||||
),
|
||||
Button(
|
||||
f"Confirm Move ({diff.server_count} animals)",
|
||||
type="submit",
|
||||
cls=ButtonT.primary,
|
||||
hx_disabled_elt="this",
|
||||
),
|
||||
cls="flex gap-3 mt-4",
|
||||
),
|
||||
|
||||
@@ -102,6 +102,7 @@ def BottomNav(active_id: str = "eggs"): # noqa: N802
|
||||
onclick="openMenuDrawer()",
|
||||
cls=wrapper_cls,
|
||||
type="button",
|
||||
aria_label="Open navigation menu",
|
||||
)
|
||||
|
||||
# Regular nav items are links
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import H2, Form, Hidden, Option
|
||||
from monsterui.all import Button, ButtonT, LabelInput, LabelSelect, LabelTextArea
|
||||
from fasthtml.common import H2, Div, Form, Hidden, Option, P, Select
|
||||
from monsterui.all import Button, ButtonT, FormLabel, LabelInput, LabelTextArea
|
||||
from ulid import ULID
|
||||
|
||||
from animaltrack.models.reference import Product
|
||||
@@ -47,8 +47,6 @@ def product_sold_form(
|
||||
# Error display component
|
||||
error_component = None
|
||||
if error:
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
error_component = Div(
|
||||
P(error, cls="text-red-500 text-sm"),
|
||||
cls="mb-4",
|
||||
@@ -58,12 +56,11 @@ def product_sold_form(
|
||||
H2("Record Sale", cls="text-xl font-bold mb-4"),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Product dropdown
|
||||
LabelSelect(
|
||||
*product_options,
|
||||
label="Product",
|
||||
id="product_code",
|
||||
name="product_code",
|
||||
# Product dropdown - using raw Select due to MonsterUI LabelSelect value bug
|
||||
Div(
|
||||
FormLabel("Product", _for="product_code"),
|
||||
Select(*product_options, name="product_code", id="product_code", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Quantity input (integer only, min=1)
|
||||
LabelInput(
|
||||
@@ -105,7 +102,7 @@ def product_sold_form(
|
||||
# Hidden nonce for idempotency
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Record Sale", type="submit", cls=ButtonT.primary),
|
||||
Button("Record Sale", type="submit", cls=ButtonT.primary, hx_disabled_elt="this"),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
|
||||
@@ -107,7 +107,12 @@ def registry_header(filter_str: str, total_count: int) -> Div:
|
||||
),
|
||||
# Buttons container
|
||||
Div(
|
||||
Button("Apply", type="submit", cls=f"{ButtonT.primary} px-4"),
|
||||
Button(
|
||||
"Apply",
|
||||
type="submit",
|
||||
cls=f"{ButtonT.primary} px-4",
|
||||
hx_disabled_elt="this",
|
||||
),
|
||||
# Clear button (only shown if filter is active)
|
||||
A(
|
||||
"Clear",
|
||||
|
||||
58
src/animaltrack/web/templates/shared_scripts.py
Normal file
58
src/animaltrack/web/templates/shared_scripts.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# ABOUTME: Shared JavaScript script generators for AnimalTrack templates.
|
||||
# ABOUTME: Provides reusable script components to reduce code duplication.
|
||||
|
||||
from fasthtml.common import Script
|
||||
|
||||
|
||||
def slide_over_script(
|
||||
panel_id: str,
|
||||
backdrop_id: str,
|
||||
open_fn_name: str,
|
||||
close_fn_name: str,
|
||||
htmx_auto_open_targets: list[str] | None = None,
|
||||
) -> Script:
|
||||
"""Generate JavaScript for slide-over panel open/close behavior.
|
||||
|
||||
Creates global functions for opening and closing a slide-over panel with
|
||||
backdrop. Optionally auto-opens when HTMX swaps content into specified targets.
|
||||
|
||||
Args:
|
||||
panel_id: DOM ID of the slide-over panel element.
|
||||
backdrop_id: DOM ID of the backdrop overlay element.
|
||||
open_fn_name: Name of the global function to open the panel.
|
||||
close_fn_name: Name of the global function to close the panel.
|
||||
htmx_auto_open_targets: List of target element IDs that trigger auto-open
|
||||
when HTMX swaps content into them.
|
||||
|
||||
Returns:
|
||||
Script element containing the JavaScript code.
|
||||
"""
|
||||
# Build HTMX auto-open listener if targets specified
|
||||
htmx_listener = ""
|
||||
if htmx_auto_open_targets:
|
||||
conditions = " ||\n ".join(
|
||||
f"evt.detail.target.id === '{target}'" for target in htmx_auto_open_targets
|
||||
)
|
||||
htmx_listener = f"""
|
||||
// HTMX event: after loading content, open the panel
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {{
|
||||
if ({conditions}) {{
|
||||
{open_fn_name}();
|
||||
}}
|
||||
}});"""
|
||||
|
||||
return Script(f"""
|
||||
function {open_fn_name}() {{
|
||||
document.getElementById('{panel_id}').classList.add('open');
|
||||
document.getElementById('{backdrop_id}').classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus the panel for keyboard events
|
||||
document.getElementById('{panel_id}').focus();
|
||||
}}
|
||||
|
||||
function {close_fn_name}() {{
|
||||
document.getElementById('{panel_id}').classList.remove('open');
|
||||
document.getElementById('{backdrop_id}').classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}}{htmx_listener}
|
||||
""")
|
||||
@@ -1,12 +1,13 @@
|
||||
# ABOUTME: Responsive sidebar and menu drawer components for AnimalTrack.
|
||||
# ABOUTME: Desktop shows persistent sidebar, mobile shows slide-out drawer.
|
||||
|
||||
from fasthtml.common import A, Button, Div, Nav, Script, Span, Style
|
||||
from fasthtml.common import A, Button, Div, Nav, Span, Style
|
||||
from fasthtml.svg import Path, Svg
|
||||
|
||||
from animaltrack.build_info import get_build_info
|
||||
from animaltrack.models.reference import UserRole
|
||||
from animaltrack.web.templates.icons import EggIcon, FeedIcon, MoveIcon
|
||||
from animaltrack.web.templates.shared_scripts import slide_over_script
|
||||
|
||||
|
||||
def SidebarStyles(): # noqa: N802
|
||||
@@ -73,21 +74,12 @@ def SidebarStyles(): # noqa: N802
|
||||
|
||||
def SidebarScript(): # noqa: N802
|
||||
"""JavaScript for menu drawer open/close behavior."""
|
||||
return Script("""
|
||||
function openMenuDrawer() {
|
||||
document.getElementById('menu-drawer').classList.add('open');
|
||||
document.getElementById('menu-backdrop').classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus the drawer for keyboard events
|
||||
document.getElementById('menu-drawer').focus();
|
||||
}
|
||||
|
||||
function closeMenuDrawer() {
|
||||
document.getElementById('menu-drawer').classList.remove('open');
|
||||
document.getElementById('menu-backdrop').classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
""")
|
||||
return slide_over_script(
|
||||
panel_id="menu-drawer",
|
||||
backdrop_id="menu-backdrop",
|
||||
open_fn_name="openMenuDrawer",
|
||||
close_fn_name="closeMenuDrawer",
|
||||
)
|
||||
|
||||
|
||||
def _primary_nav_item(label: str, href: str, icon_fn, is_active: bool):
|
||||
@@ -264,6 +256,7 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
|
||||
hx_on_click="closeMenuDrawer()",
|
||||
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
|
||||
type="button",
|
||||
aria_label="Close menu",
|
||||
),
|
||||
cls="flex items-center justify-between px-4 py-4 border-b border-stone-800",
|
||||
),
|
||||
@@ -276,6 +269,8 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
|
||||
cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl",
|
||||
tabindex="-1",
|
||||
hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()",
|
||||
role="dialog",
|
||||
aria_label="Navigation menu",
|
||||
),
|
||||
cls="md:hidden",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user