#[cfg(feature = "ssr")] use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; #[cfg(feature = "ssr")] use crate::common::ValueType; use crate::common::ExpandableInfo; use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm}; use chrono::{FixedOffset, TimeZone}; use leptos::Params; use leptos::either::{Either, EitherOf3}; use leptos::html::Dialog; use leptos::logging::log; use leptos::prelude::*; use leptos_router::hooks::{use_navigate, use_params}; use leptos_router::params::Params; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use leptos_router::components::Outlet; use leptos_animate::animate; use crate::common::animations::pop_in_out; // Loaner page creates a single page that contains the borrowing/returning buttons // and a panel for a form. At first, it will show a fallback view. At the borrowing // path it will show the borrow form, and at the returning path it will show the // return form. #[derive(Params, PartialEq)] struct LoanerParams { form_name: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] struct LoanerReturnOutput { /// The subject ID of the student entry. subject: String, /// The first name of the student that is borrowing. first_name: String, /// The last name of the student that is borrowing. last_name: String, /// The date that the student borrowed the loaner. date: String, } #[server] async fn get_loaners_return_list() -> Result, ServerFnError> { // We want to query the database for all loaners, but we only want the name and // date of borrowing. This will be cheaper than querying the entire table. let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); log!("Getting loaners from database."); match dbclient .scan() .table_name("loaner-info") .projection_expression("subject, first_name, last_name, date_taken") .send() .await { Ok(output) => { if let Some(items) = output.items { Ok(items .into_iter() .map(|item| { let expandable: ExpandableInfo = from_item(item).unwrap(); // This set of function calls is ridiculous, we need to make a better way to do this. LoanerReturnOutput { first_name: expandable .data .get("first_name") .unwrap() .as_string() .unwrap_or_default() .unwrap_or_default(), last_name: expandable .data .get("last_name") .unwrap() .as_string() .unwrap_or_default() .unwrap_or_default(), date: expandable .data .get("date_taken") .unwrap() .as_string() .unwrap_or_default() .unwrap_or_default(), subject: expandable.subject, } }) .collect()) } else { Ok(vec![]) } } Err(err) => { log!("{:?}", err); let msg = err.message().unwrap_or("Unknown error"); Err(ServerFnError::new(msg)) } } } #[server] async fn check_in_loaner(student: LoanerReturnOutput) -> Result<(), ServerFnError> { let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); match dbclient .delete_item() .table_name("loaner-info") .key("subject", AttributeValue::S(student.subject)) .send() .await { Ok(_) => Ok(()), Err(err) => { log!("{:?}", err); let msg = err.message().unwrap_or("Unknown error"); Err(ServerFnError::new(msg)) } } } #[server] async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> { let mut input = input; let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); let current_time = chrono::Utc::now().format("%H:%M, %m/%d/%Y").to_string(); log!("Creating entry with timestamp {}", current_time); // Add the timestamp to the data map as a string and then push it to the database. input.data.insert( "date_taken".to_string(), ValueType::String(Some(current_time.clone())), ); loop { let item: serde_dynamo::Item = to_item(&input)?; match dbclient .put_item() .table_name("loaner-info") .set_item(Some(HashMap::from(item.clone()))) .condition_expression("attribute_not_exists(subject)") .send() .await { Ok(_) => return Ok(()), Err(err) => { log!("{:?}", err); match err.code() { Some("ConditionalCheckFailedException") => { // Retry database writes until we have a good subject ID. log!("Failed conditional check, forcing new subject ID."); input.subject = uuid::Uuid::new_v4().to_string(); continue; } _ => { let msg = err.message().unwrap_or("An unknown error occurred."); return Err(ServerFnError::new(msg)); } }; } } } } /// # Loaner Page /// /// This page is going to temporarily piggyback on the general scholarship app, at least until /// we spin off the components used here into a new standalone crate. /// /// The loaner app handles management of Chromebook loaners to students. It was originally hosted /// using Google Apps Script, but has recently been broken by some unknown API error. I've chosen /// to rewrite this into a single new application, which we will host in AWS with a real database. /// /// It should use all the same components as the scholarship loaner, maybe with a few extra /// alterations. The page will only use a single database table, and it will only have a few very /// basic APIs. /// /// # Design /// /// The page should have two sections - one for logging a loaner that's being taken out (borrowing) /// and another for a loaner that's being brought back (returning). As of right now, we have two /// separate buttons that handle these functions, and they slide open a panel at the bottom of the /// screen. The borrowing menu has a single, simple form, and the returning side has a list of /// buttons that allow students to choose their name and remove it from the list. /// /// # New features /// /// I believe that with a new workflow and an overhauled server with real data management, we should /// create a true admin page for this, simply to be able to survey and override/remove information /// from the lists. #[component] pub fn LoanerShell() -> impl IntoView { // Check URL params. Depending on what string is visible here, we'll display a different form. let params = use_params::(); let form_passed = move || { params .read() .as_ref() .ok() .and_then(|params| params.form_name.clone()) .unwrap_or_default() }; // We'll create the page here - the sidebar contains all the buttons, while the form // container either shows forms or shows placeholder text. view! {
} } #[component] pub fn LoanerFallback() -> impl IntoView { view! {
"Chromebook Loaner Form"
"Select an option from the left to begin."
} } #[component] pub fn LoanerBorrowForm() -> impl IntoView { let navigate = use_navigate(); // Register server actions. let create_entry_action = ServerAction::::new(); let data = RwSignal::new(HashMap::new()); let elements_disabled = Signal::derive(move || create_entry_action.pending().get()); let on_submit = move |_| { create_entry_action.dispatch(CreateBorrowEntry { input: ExpandableInfo { subject: uuid::Uuid::new_v4().to_string(), data: data.get(), } }); }; //#region Toast Logic let mut toast_context = expect_context::(); Effect::new(move || { if let Some(Ok(_)) = create_entry_action.value().get() { create_entry_action.clear(); toast_context.toast( Toast::new() .id(uuid::Uuid::new_v4().to_string()) .header("Borrower Added!") .msg("You can take your loaner now.") ); navigate("/loaners", Default::default()); } }); //#endregion view! { } } #[component] pub fn LoanerReturnForm() -> impl IntoView { let navigate = use_navigate(); // Register server actions let check_in_action = ServerAction::::new(); // Register server resources let return_list_refresh = RwSignal::new(0); let return_list_resource: Resource> = Resource::new( move || return_list_refresh.get(), async |_| { get_loaners_return_list().await.unwrap_or_else(|e| { log!("There was an error getting the loaner list: {}", e); vec![] }) }, ); let dialog_ref = NodeRef::::new(); let chosen_student = RwSignal::new(None::); let dialog_disabled = Signal::derive(move || check_in_action.pending().get()); // Create an effect to run after check-in - refreshes the list with another scan request. // This could be beneficial if we ever decided to make multiple loaner kiosks (think SAT days) let mut toasts = expect_context::(); Effect::new(move || { if let Some(Ok(_)) = check_in_action.value().get() { // Return to the main page. if let Some(dialog) = dialog_ref.get() { dialog.close(); } toasts.toast( Toast::new() .id(uuid::Uuid::new_v4()) .header("Check in successful!") .msg("Don't forget to take your collateral.") ); navigate("/loaners", Default::default()); check_in_action.clear(); } }); // Handles checking in a student. let dialog_check_in = move |_| { chosen_student.with(|opt_student| { if let Some(student) = opt_student.as_ref() { check_in_action.dispatch(CheckInLoaner { student: student.clone(), }); } }) }; // Handles closing the dialog menu. let close_dialog = move |_| { if let Some(dialog) = dialog_ref.get() { dialog.close(); } }; view! {

"Confirmation"

{move || chosen_student.with(|opt_student| { if let Some(student) = opt_student.as_ref() { format!("Are you sure want to check in {} {}?", student.first_name, student.last_name) } else { "There is no student selected.".to_string() } })}

"Close" "Check In"

"Student List"

"Please select your name from the list."

{move || { match return_list_resource.get() { Some(loaner_list) => { let list_view = loaner_list.iter().map(|student| { let on_click = { let student_click = student.clone(); move |_| { log!("Running on_click using student {:?}", student_click); if let Some(dialog) = dialog_ref.get() { chosen_student.set(Some(student_click.clone())); dialog.show_modal().expect("Couldn't show modal dialog."); } } }; let naive_time = chrono::NaiveDateTime::parse_from_str(&student.date, "%H:%M, %m/%d/%Y") .unwrap(); let time_zone = FixedOffset::west_opt(5 * 3600).unwrap(); let student_date = time_zone.from_utc_datetime(&naive_time) .format("%l:%M %p, %m/%d/%Y") .to_string(); log!("Found current date {:?}", student_date); view! {
{student.first_name.clone()}" "{student.last_name.clone()}
{student_date.clone()}
} }).collect_view(); Either::Left(list_view) }, None => Either::Right("No loaners found.".into_view()) } }}
} }