From a0049d4797771240968efcd0210956717a889b91 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 18 Dec 2025 14:29:27 -0500 Subject: [PATCH 1/9] feat: create scholarship list skeleton Next up is linking the scholarship IDs that are contained in the link parameters to the form that's displayed upon clicking "Edit". --- src/app.rs | 15 +++- src/components/loading.rs | 2 +- src/pages/api/scholarships.rs | 77 +++++++++++++++- src/pages/comparison_test.rs | 14 +-- src/pages/home_page.rs | 15 +--- src/pages/mod.rs | 2 +- src/pages/scholarship_info.rs | 164 +++++++++++++++++++++++++++++++--- src/pages/utils.rs | 17 ++++ 8 files changed, 268 insertions(+), 38 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7a15fb8..f49199d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,7 +3,8 @@ use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_router::{components::{Route, Router, Routes}, path}; -use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, TestPage}; +use leptos_router::components::ParentRoute; +use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, ScholarshipInfoPage, TestPage}; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { @@ -96,7 +97,7 @@ pub fn AppWithRoutes() -> impl IntoView { // sets the document title - + <main> // TODO Create a 404 page <Routes fallback=|| "Page not found.".into_view()> @@ -104,8 +105,14 @@ pub fn AppWithRoutes() -> impl IntoView { <Route path=path!("about") view=AboutPage/> <Route path=path!("test_page") view=TestPage/> <Route path=path!("comparison") view=ComparisonTestPage /> - <Route path=path!("loaners") view=LoanerPage /> - <Route path=path!("loaners/:form_name") view=LoanerPage /> + <ParentRoute path=path!("providers/scholarships") view=ScholarshipInfoPage> + <Route path=path!(":id") view=ScholarshipInfoPage /> + <Route path=path!("") view=ScholarshipInfoPage /> + </ParentRoute> + <ParentRoute path=path!("loaners") view=LoanerPage> + <Route path=path!("") view=LoanerPage /> + <Route path=path!(":form_name") view=LoanerPage /> + </ParentRoute> </Routes> </main> } diff --git a/src/components/loading.rs b/src/components/loading.rs index caa82be..53f2ab3 100644 --- a/src/components/loading.rs +++ b/src/components/loading.rs @@ -4,7 +4,7 @@ use leptos::prelude::*; pub fn Loading() -> impl IntoView { view! { <div> - <img src="loading.gif" /> + <img src="/loading.gif" /> </div> } } diff --git a/src/pages/api/scholarships.rs b/src/pages/api/scholarships.rs index edc7b4c..817e83a 100644 --- a/src/pages/api/scholarships.rs +++ b/src/pages/api/scholarships.rs @@ -7,7 +7,8 @@ use aws_sdk_dynamodb::{ use leptos::leptos_dom::log; use leptos::prelude::ServerFnError; use leptos::server; -use crate::common::ExpandableInfo; +use uuid::Uuid; +use crate::common::{ExpandableInfo, ValueType}; #[cfg(feature = "ssr")] use crate::pages::utils::server_utils::create_dynamo_client; @@ -89,3 +90,77 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE } } } + +/// Gets all scholarships that are associated with the given scholarship provider's ID. +#[server(GetProviderScholarships, "/providers/scholarships/get")] +pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<ExpandableInfo>, ServerFnError> { + let client = create_dynamo_client().await; + + log!("Getting provider scholarships for provider with ID {:?}", provider_id); + + match client + .scan() + .table_name("leptos-scholarship-test") + .expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?) + .filter_expression("provider_id = :id") + .send() + .await + { + Ok(output) => { + if let Some(items) = output.items { + Ok(serde_dynamo::from_items(items)?) + } else { + Ok(Vec::new()) + } + } + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred"); + Err(ServerFnError::new(msg)) + } + } +} + +/// Creates a new scholarship with a unique ID, and then returns that ID. +#[server(RegisterScholarship, "/providers/scholarships/create")] +pub async fn register_scholarship(provider_id: String, scholarship_name: String) -> Result<String, ServerFnError> { + let client = create_dynamo_client().await; + + log!("Creating scholarship for provider with ID {:?}", provider_id); + + let mut current_uuid = "testing".to_string(); + let mut item = ExpandableInfo::new(current_uuid.clone()); + item.data.insert("provider_id".to_string(), ValueType::String(Some(provider_id))); + item.data.insert("scholarship_name".to_string(), ValueType::String(Some(scholarship_name))); + loop { + let ser_item = serde_dynamo::to_item(&item)?; + match client + .put_item() + .table_name("leptos-scholarship-test") + .set_item(Some(ser_item)) + .condition_expression("attribute_not_exists(subject)") + .send() + .await + { + Ok(_) => { + // Return the uuid that we used. + return Ok(current_uuid); + } + Err(err) => { + // Check the error to figure out if it was a key validation error. + // If so, retry with new values. + match err.code() { + Some("ConditionalCheckFailedException") => { + log!("Retrying conditional check."); + log!("Current error: {:?}", err); + current_uuid = Uuid::new_v4().to_string(); + item.subject = current_uuid.clone(); + continue + } + _ => { + return Err(ServerFnError::new(err.message().unwrap_or("An unknown error occurred"))); + } + } + } + } + } +} diff --git a/src/pages/comparison_test.rs b/src/pages/comparison_test.rs index c08f9f4..94dc7c1 100644 --- a/src/pages/comparison_test.rs +++ b/src/pages/comparison_test.rs @@ -2,26 +2,20 @@ use leptos::either::Either; use super::home_page::get_submission; use super::{UnauthenticatedPage}; use crate::common::{ - ComparisonData, ExpandableInfo, UserClaims, ValueType, + ComparisonData, ExpandableInfo, ValueType, }; use crate::components::{ActionButton, Loading, Panel, Row}; use leptos::leptos_dom::logging::console_log; use leptos::logging::log; use leptos::prelude::*; -use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; +use leptos_oidc::{AuthLoaded, Authenticated}; +use crate::pages::utils::get_user_claims; use super::api::{get_comparison_info, get_all_scholarship_info}; #[component] pub fn ComparisonTestPage() -> impl IntoView { let result_msg = RwSignal::new("Found submission, waiting for input.".to_string()); - let auth = use_context::<AuthSignal>().expect("Couldn't find user information."); - let user_claims = Signal::derive(move || { - auth.with(|auth| { - auth.authenticated().and_then(|data| { - data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"]) - }) - }) - }); + let user_claims = get_user_claims(); // Note that the value passed in MUST be equatable. // We get/unwrap the value repeatedly until we get a simple string value, then clone it so that diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 4c6ddb9..19c41ea 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -9,7 +9,7 @@ use std::process::{Command, Stdio}; #[cfg(feature = "ssr")] use std::io::Write; -use crate::common::{ExpandableInfo, UserClaims}; +use crate::common::ExpandableInfo; use crate::pages::UnauthenticatedPage; use crate::components::{ ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, @@ -18,12 +18,13 @@ use crate::components::{ use crate::input; use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; -use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; +use leptos_oidc::{AuthLoaded, Authenticated}; use base64::Engine; use leptos::task::spawn_local; use leptos::web_sys::HtmlAnchorElement; use leptos::wasm_bindgen::JsCast; use traits::{AsReactive, ReactiveCapture}; +use crate::pages::utils::get_user_claims; /// # Get Student Info /// Gets a student's information given their `subject`. @@ -146,15 +147,7 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> { #[component] pub fn HomePage() -> impl IntoView { // Creates a reactive value to update the button - - let auth = use_context::<AuthSignal>().expect("Couldn't find user information."); - let user_claims = Signal::derive(move || { - auth.with(|auth| { - auth.authenticated().and_then(|data| { - data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"]) - }) - }) - }); + let user_claims = get_user_claims(); // Note that the value passed in MUST be equatable. // We get/unwrap the value repeatedly until we get a simple string value, then clone it so that diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 6c06aaa..866fb60 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -11,5 +11,5 @@ mod loaner_page; pub use self::{ about_page::*, home_page::*, provider_portal::*, test_page::*, unauthenticated_page::*, - loaner_page::LoanerPage, comparison_test::*, + loaner_page::*, comparison_test::*, scholarship_info::*, }; diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index 63bb3ab..f737843 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -1,13 +1,15 @@ +use leptos::either::Either; use leptos::logging::log; use leptos::prelude::*; use leptos_oidc::{AuthLoaded, Authenticated}; -use leptos_router::hooks::use_params; -use crate::common::{ExpandableInfo, ScholarshipFormParams}; -use crate::components::{ActionButton, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row}; +use leptos_router::hooks::{use_navigate, use_params}; +use crate::common::{ExpandableInfo, ScholarshipFormParams, ValueType}; +use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row}; use super::UnauthenticatedPage; use traits::{AsReactive, ReactiveCapture}; use crate::common::ComparisonData; -use super::api::{get_comparison_info, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons}; +use crate::pages::utils::get_user_claims; +use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons, RegisterScholarship}; /// # Scholarship Info Page @@ -15,12 +17,8 @@ use super::api::{get_comparison_info, get_scholarship_info, CreateScholarshipInf /// scholarship ID and scholarship provider subject number. The form itself will only /// track a single scholarship (for now) and so selection of what scholarship will be edited /// must be handled *before* navigation to this page. -/// -/// In its current testing form, we'll be using a single fixed ID. This absolutely *MUST* be -/// changed before a full release. #[component] pub fn ScholarshipInfoPage() -> impl IntoView { - // let scholarship_id = String::from("test-scholarship-id2"); let url_params = use_params::<ScholarshipFormParams>(); let scholarship_id = move || { url_params.read() @@ -61,11 +59,17 @@ pub fn ScholarshipInfoPage() -> impl IntoView { // Remember that we only want to show the page after we've got authentication information // and if there's data for us to use. view! { + <Banner + title="R15 Scholarships" + logo="/PHS_Stacked_Acronym.png" + path="/" + /> <AuthLoaded fallback=Loading> <Authenticated unauthenticated=UnauthenticatedPage> <Suspense fallback=Loading> - <Row> + <div class="flex flex-row align-top items-start"> <div class="flex flex-col flex-1" /> + <ScholarshipList /> {move || Suspend::new(async move { let response = get_scholarship_action.get(); let comparisons = get_comparison_info.get(); @@ -91,13 +95,153 @@ pub fn ScholarshipInfoPage() -> impl IntoView { </Row> </div> <div class="flex flex-col flex-1" /> - </Row> + </div> </Suspense> </Authenticated> </AuthLoaded> } } +#[component] +fn ScholarshipList() -> impl IntoView { + // Get scholarship provider information from authentication. This element will not display + // unless that information is available. + let user_claims = get_user_claims(); + let provider_id = Memo::new(move |_| + user_claims.get() + .as_ref() + .map(|info| info.claims.subject.clone()) + ); + + // Register scholarship server resource + let scholarships = Resource::new( + move || provider_id, + async move |provider_id| { + let Some(provider_id) = provider_id.get() else { + return Ok(Vec::new()); + }; + + // Get the scholarships + get_provider_scholarships(provider_id).await + } + ); + + // Register scholarship creation action + let create_action = ServerAction::<RegisterScholarship>::new(); + + // Button handlers + let create_on_click = move |_| { + // In the future, we'd actually like to create/show a dialog that gets the + // desired name of the scholarship. + // After the request is sent, we'll handle it in an effect. + create_action.dispatch(RegisterScholarship { + provider_id: provider_id.get().unwrap(), + scholarship_name: "Testing Scholarship".to_string() + }); + }; + + // Register scholarship creation effect + Effect::new(move || { + // We want to wait for the action to complete first, but then find whether the request + // succeeded. + if let Some(Ok(_)) = create_action.value().get() { + // Refetch and clear. + scholarships.refetch(); + create_action.clear(); + } + }); + + view! { + <div class="w-75"> + <Panel> + <div class="pt-2"> + <h2 class="text-xl font-bold text-center">"Scholarships"</h2> + </div> + <Transition fallback=Loading> + <div class="flex flex-col gap-2"> + {move || { + scholarships.get().map(|result| { + match result { + Ok(list) => { + let views = list.iter().map(|entry| { + view! { <ScholarshipListEntry scholarship=entry.clone() /> } + }); + Either::Left(views.collect_view()) + } + Err(err) => { + Either::Right(view! { + <div> + <p>"Failed to load scholarship list: "{err.to_string()}</p> + </div> + }) + } + } + }).collect_view() + }} + </div> + <div> + <div class="p-2 bg-red-700 rounded-md text-center text-white hover:bg-red-800 transition-all" + on:click=create_on_click> + "Create New" + </div> + </div> + </Transition> + </Panel> + </div> + } +} + +#[component] +fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView { + let navigate = use_navigate(); + + // Get the values that we need out of the info. We just need the subject and the name of + // the scholarship. + let name = scholarship.data.get("scholarship_name") + .unwrap_or(&ValueType::String(None)) + .as_string().unwrap_or_default() + .unwrap_or("<no name found>".to_string()); + + // Button click handlers + let edit_click = { + let subject = scholarship.subject.clone(); + move |_| { + let path = format!("/providers/scholarships/{}", subject); + navigate(path.as_str(), Default::default()); + } + }; + + let delete_click = { + let subject = scholarship.subject.clone(); + move |_| { + // Delete the scholarship. For now, we'll just log it. + log!("Deleting scholarship with ID {:?}", subject); + } + }; + + view! { + <div class="flex flex-col rounded-md border-light inset-shadow-xs shadow-md my-2 transition-all hover:shadow-lg/33"> + <div class="p-2 text-center"> + <span>{name}</span> + </div> + <div class="flex flex-row"> + <div class="p-2 flex-1 bg-red-700 border-r border-white rounded-bl-md text-white text-center + hover:bg-red-800 cursor-pointer transition-all" + on:click=edit_click + > + "Edit" + </div> + <div class="p-2 flex-1 bg-red-700 border-l border-white rounded-br-md text-white text-center + hover:bg-red-800 cursor-pointer transition-all" + on:click=delete_click + > + "Delete" + </div> + </div> + </div> + } +} + #[component] fn ScholarshipForm( response: ExpandableInfo, diff --git a/src/pages/utils.rs b/src/pages/utils.rs index b12d842..1ae07b9 100644 --- a/src/pages/utils.rs +++ b/src/pages/utils.rs @@ -1,3 +1,7 @@ +use leptos::prelude::{use_context, Signal, With}; +use leptos_oidc::{Algorithm, AuthSignal, TokenData}; +use crate::common::UserClaims; + /// # Server Utilities /// /// These utility functions can only be used from the server. They are created to use server-side @@ -17,3 +21,16 @@ pub mod server_utils { aws_sdk_dynamodb::Client::new(&create_aws_config().await) } } + +/// Gets the current user claims. This function should only be used in an area that has +/// access to an AuthSignal, or it will result in a total failure. +pub fn get_user_claims() -> Signal<Option<TokenData<UserClaims>>> { + let auth = use_context::<AuthSignal>().expect("Couldn't find AuthSignal."); + Signal::derive(move || { + auth.with(|auth| { + auth.authenticated().and_then(|data| { + data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"]) + }) + }) + }) +} -- 2.54.0 From b687125d9bed707d4e468707f88ee7dedecf8f8a Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Fri, 19 Dec 2025 11:47:43 -0500 Subject: [PATCH 2/9] feat: add edit, delete, and fallback capability to providers/scholarships --- src/common/mod.rs | 3 +- src/common/submit_status.rs | 7 + src/components/action_button.rs | 4 +- src/components/chips.rs | 8 +- src/components/radio.rs | 4 +- src/components/text_field.rs | 2 +- src/pages/api/scholarships.rs | 28 ++ src/pages/scholarship_info.rs | 457 +++++++++++++++++--------------- 8 files changed, 291 insertions(+), 222 deletions(-) create mode 100644 src/common/submit_status.rs diff --git a/src/common/mod.rs b/src/common/mod.rs index 3dd4824..08c3cb5 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -4,5 +4,6 @@ mod form_schema; mod user; mod value_type; mod form_params; +mod submit_status; -pub use self::{comparison::*, expandable_info::*, form_schema::*, user::*, value_type::*, form_params::*}; +pub use self::{comparison::*, expandable_info::*, form_schema::*, user::*, value_type::*, form_params::*, submit_status::*}; diff --git a/src/common/submit_status.rs b/src/common/submit_status.rs new file mode 100644 index 0000000..c6b8a90 --- /dev/null +++ b/src/common/submit_status.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone)] +pub enum SubmitStatus { + Idle, + Sending, + Success, + Error(String), +} diff --git a/src/components/action_button.rs b/src/components/action_button.rs index 475dca0..8d9728e 100644 --- a/src/components/action_button.rs +++ b/src/components/action_button.rs @@ -2,8 +2,8 @@ use leptos::prelude::*; #[component] pub fn ActionButton( - #[prop(optional, default = String::from("button"))] button_type: String, - #[prop(optional, default = RwSignal::new(false))] disabled: RwSignal<bool>, + #[prop(default = String::from("button"))] button_type: String, + #[prop(optional, into)] disabled: Signal<bool>, children: Children, ) -> impl IntoView { view! { diff --git a/src/components/chips.rs b/src/components/chips.rs index 6c117b0..bf57241 100644 --- a/src/components/chips.rs +++ b/src/components/chips.rs @@ -70,8 +70,8 @@ pub fn Chip( pub fn ChipsList( #[prop(into)] data_member: String, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>, - #[prop()] displayed_text: Vec<String>, - #[prop()] values: Vec<String>, + #[prop(into)] displayed_text: Signal<Vec<String>>, + #[prop(into)] values: Signal<Vec<String>>, #[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, #[prop(optional, into)] label: String, ) -> impl IntoView { @@ -79,7 +79,7 @@ pub fn ChipsList( <div class="m-1.5 mt-0 mb-0"> <span class="font-bold">{label}</span> <div class="flex flex-row flex-wrap gap-2 mt-1"> - {displayed_text.into_iter().zip(values) + {move || { displayed_text.get().into_iter().zip(values.get()) .into_iter() .map(|(text, value)| { let checked_signal = Signal::derive({ @@ -140,7 +140,7 @@ pub fn ChipsList( } }) .collect_view() - } + }} </div> </div> } diff --git a/src/components/radio.rs b/src/components/radio.rs index 72047ef..fd8af70 100644 --- a/src/components/radio.rs +++ b/src/components/radio.rs @@ -9,7 +9,7 @@ pub fn Radio( #[prop(into)] on_change: Callback<(), ()>, #[prop(into)] value: String, #[prop(into)] name: String, - #[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, + #[prop(optional, into)] disabled: Signal<bool>, ) -> impl IntoView { let id = create_unique_id(&name, &value); @@ -53,7 +53,7 @@ pub fn RadioList( #[prop(into)] data_member: String, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] items: Vec<String>, - #[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, + #[prop(optional, into)] disabled: Signal<bool>, #[prop(optional, into)] label: String, ) -> impl IntoView { view! { diff --git a/src/components/text_field.rs b/src/components/text_field.rs index 330a3ea..4dae16c 100644 --- a/src/components/text_field.rs +++ b/src/components/text_field.rs @@ -11,7 +11,7 @@ pub fn OutlinedTextField( #[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop(optional)] value: RwSignal<ValueType>, #[prop(default = "text".to_owned(), into)] input_type: String, - #[prop(optional)] disabled: RwSignal<bool>, + #[prop(into, optional)] disabled: Signal<bool>, #[prop(optional)] error: RwSignal<bool>, #[prop(optional, into)] name: String, #[prop(optional, into)] label: String, diff --git a/src/pages/api/scholarships.rs b/src/pages/api/scholarships.rs index 817e83a..28dbfd4 100644 --- a/src/pages/api/scholarships.rs +++ b/src/pages/api/scholarships.rs @@ -164,3 +164,31 @@ pub async fn register_scholarship(provider_id: String, scholarship_name: String) } } } + +/// Deletes a provider's scholarship given their provider ID and scholarship ID. +#[server(DeleteProviderScholarship, "/providers/scholarships/delete")] +pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: String) -> Result<(), ServerFnError> { + let client = create_dynamo_client().await; + + log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id); + + // When we delete a scholarship, we need to ensure that the provider's ID matches the scholarship, + // otherwise everyone can delete anyone else's scholarships. + match client + .delete_item() + .table_name("leptos-scholarship-test") + .key("subject", AttributeValue::S(scholarship_id)) + .expression_attribute_values(":provider_id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?) + .condition_expression("provider_id = :provider_id") + .send() + .await + { + Ok(_) => { + Ok(()) + } + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred"); + Err(ServerFnError::new(msg)) + } + } +} diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index f737843..1b5845d 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -1,15 +1,16 @@ use leptos::either::Either; use leptos::logging::log; use leptos::prelude::*; +use leptos::reactive::spawn_local; use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_router::hooks::{use_navigate, use_params}; -use crate::common::{ExpandableInfo, ScholarshipFormParams, ValueType}; +use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType}; use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row}; use super::UnauthenticatedPage; use traits::{AsReactive, ReactiveCapture}; use crate::common::ComparisonData; use crate::pages::utils::get_user_claims; -use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons, RegisterScholarship}; +use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, delete_provider_scholarship, CreateScholarshipInfo, CreateTestComparisons, RegisterScholarship}; /// # Scholarship Info Page @@ -20,40 +21,13 @@ use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship #[component] pub fn ScholarshipInfoPage() -> impl IntoView { let url_params = use_params::<ScholarshipFormParams>(); - let scholarship_id = move || { + let scholarship_id = Memo::new(move |_| { url_params.read() .as_ref() .ok() .and_then(|params| params.id.clone()) - .unwrap_or_default() - }; + }); - let get_scholarship_action: Resource<ExpandableInfo> = Resource::new( - move || scholarship_id().clone(), - async |id| { - get_scholarship_info(id.clone()).await - // Note that this really should notify us of an error and then redirect or ask - // the user to try again. - .unwrap_or_else(|e| { - log!("Failed to get scholarship info: {:?}", e); - log!("Using default ExpandableInfo"); - ExpandableInfo::new(id) - }) - } - ); - - let get_comparison_info: Resource<Vec<ComparisonData>> = Resource::new( - move || Option::<String>::None, // There's no input to this function. - async |_| { - get_comparison_info().await - .unwrap_or_else(|e| { - log!("Failed to get comparison info: {:?}", e); - Vec::new() - }) - } - ); - - let create_scholarship_action = ServerAction::<CreateScholarshipInfo>::new(); let create_test_comps = ServerAction::<CreateTestComparisons>::new(); // Remember that we only want to show the page after we've got authentication information @@ -66,37 +40,25 @@ pub fn ScholarshipInfoPage() -> impl IntoView { /> <AuthLoaded fallback=Loading> <Authenticated unauthenticated=UnauthenticatedPage> - <Suspense fallback=Loading> - <div class="flex flex-row align-top items-start"> - <div class="flex flex-col flex-1" /> - <ScholarshipList /> - {move || Suspend::new(async move { - let response = get_scholarship_action.get(); - let comparisons = get_comparison_info.get(); - - response - .zip(comparisons) - .map(|(response, comparison_list)| view! { - <ScholarshipForm - response=response - comparison_list=comparison_list - submit_action=create_scholarship_action - /> - }) - .collect_view() - })} - <div class="hidden"> - <Row> - <ActionButton - on:click=move |_| { - create_test_comps.dispatch(CreateTestComparisons {}); - } - >"Create test comparisons"</ActionButton> - </Row> - </div> - <div class="flex flex-col flex-1" /> + <div class="flex flex-row align-top items-start"> + <div class="flex flex-col flex-1" /> + <ScholarshipList /> + <Transition fallback=Loading> + <ScholarshipForm + scholarship_id=scholarship_id + /> + </Transition> + <div class="hidden"> + <Row> + <ActionButton + on:click=move |_| { + create_test_comps.dispatch(CreateTestComparisons {}); + } + >"Create test comparisons"</ActionButton> + </Row> </div> - </Suspense> + <div class="flex flex-col flex-1" /> + </div> </Authenticated> </AuthLoaded> } @@ -115,9 +77,9 @@ fn ScholarshipList() -> impl IntoView { // Register scholarship server resource let scholarships = Resource::new( - move || provider_id, + move || provider_id.get(), async move |provider_id| { - let Some(provider_id) = provider_id.get() else { + let Some(provider_id) = provider_id else { return Ok(Vec::new()); }; @@ -126,7 +88,7 @@ fn ScholarshipList() -> impl IntoView { } ); - // Register scholarship creation action + // Register scholarship creation/deletion actions let create_action = ServerAction::<RegisterScholarship>::new(); // Button handlers @@ -164,7 +126,12 @@ fn ScholarshipList() -> impl IntoView { match result { Ok(list) => { let views = list.iter().map(|entry| { - view! { <ScholarshipListEntry scholarship=entry.clone() /> } + view! { + <ScholarshipListEntry scholarship=entry.clone() + on_delete=move || { + scholarships.refetch(); + }/> + } }); Either::Left(views.collect_view()) } @@ -180,7 +147,7 @@ fn ScholarshipList() -> impl IntoView { }} </div> <div> - <div class="p-2 bg-red-700 rounded-md text-center text-white hover:bg-red-800 transition-all" + <div class="p-2 bg-red-800 rounded-md text-center text-white hover:bg-red-900 transition-all" on:click=create_on_click> "Create New" </div> @@ -192,12 +159,15 @@ fn ScholarshipList() -> impl IntoView { } #[component] -fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView { +fn ScholarshipListEntry( + #[prop()] scholarship: ExpandableInfo, + #[prop(into)] on_delete: Callback<()>, +) -> impl IntoView { let navigate = use_navigate(); // Get the values that we need out of the info. We just need the subject and the name of // the scholarship. - let name = scholarship.data.get("scholarship_name") + let name = scholarship.data.get("name") .unwrap_or(&ValueType::String(None)) .as_string().unwrap_or_default() .unwrap_or("<no name found>".to_string()); @@ -214,8 +184,18 @@ fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView { let delete_click = { let subject = scholarship.subject.clone(); move |_| { - // Delete the scholarship. For now, we'll just log it. + // Delete the scholarship. + let subject = subject.clone(); log!("Deleting scholarship with ID {:?}", subject); + spawn_local(async move { + match delete_provider_scholarship( + subject, + "".to_string() + ).await { + Ok(_) => on_delete.run(()), + _ => {} + } + }); } }; @@ -225,14 +205,14 @@ fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView { <span>{name}</span> </div> <div class="flex flex-row"> - <div class="p-2 flex-1 bg-red-700 border-r border-white rounded-bl-md text-white text-center - hover:bg-red-800 cursor-pointer transition-all" + <div class="p-2 flex-1 bg-red-800 border-r border-white rounded-bl-md text-white text-center + hover:bg-red-900 cursor-pointer transition-all" on:click=edit_click > "Edit" </div> - <div class="p-2 flex-1 bg-red-700 border-l border-white rounded-br-md text-white text-center - hover:bg-red-800 cursor-pointer transition-all" + <div class="p-2 flex-1 bg-red-800 border-l border-white rounded-br-md text-white text-center + hover:bg-red-900 cursor-pointer transition-all" on:click=delete_click > "Delete" @@ -244,30 +224,71 @@ fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView { #[component] fn ScholarshipForm( - response: ExpandableInfo, - comparison_list: Vec<ComparisonData>, - submit_action: ServerAction<CreateScholarshipInfo> + #[prop(into)] scholarship_id: Signal<Option<String>> ) -> impl IntoView { - let reactive_info = response.as_reactive(); - let elements_disabled = RwSignal::new(false); + let submit_status = RwSignal::new(SubmitStatus::Idle); + let elements_disabled = Signal::derive(move || { + match submit_status.get() { + SubmitStatus::Sending => true, + _ => false + } + }); let result_msg = Signal::derive(move || { + match submit_status.get() { + SubmitStatus::Idle => "".into(), + SubmitStatus::Sending => "Sending submission...".into(), + SubmitStatus::Success => "Success".into(), + SubmitStatus::Error(msg) => format!("Error: {}", msg), + } + }); + + let submit_action = ServerAction::<CreateScholarshipInfo>::new(); + + Effect::new(move || { if submit_action.pending().get() { - elements_disabled.set(true); - "Sending request...".to_string() - } else { - elements_disabled.set(false); - if let Some(result) = submit_action.value().get() { - match result { - Ok(()) => "Request sent successfully".to_string(), - Err(err) => { - format!("An error occurred: {:?}", err) - } - } - } else { - "".to_string() + submit_status.set(SubmitStatus::Sending); + return; + } + + if let Some(result) = submit_action.value().get() { + match result { + Ok(()) => submit_status.set(SubmitStatus::Success), + Err(err) => submit_status.set(SubmitStatus::Error(err.to_string())) } } }); + + let comparison_list: Resource<Vec<ComparisonData>> = Resource::new( + move || (), // There's no input to this function. + async move |_| { + get_comparison_info().await + .unwrap_or_else(|e| { + log!("Failed to get comparison info: {:?}", e); + Vec::new() + }) + } + ); + + let scholarship_info: Resource<Result<ExpandableInfo, ServerFnError>> = Resource::new( + move || scholarship_id.get().unwrap_or_default(), + async move |id| { + get_scholarship_info(id).await + } + ); + + let comparison_ids = Memo::new(move |_| { + comparison_list.get().map(|list| list.iter() + .map(|comp| comp.clone().id) + .collect::<Vec<String>>() + ).unwrap_or_default() + }); + + let comparison_text = Memo::new(move |_| { + comparison_list.get().map(|list| list.iter() + .map(|comp| comp.clone().display_text) + .collect::<Vec<String>>() + ).unwrap_or_default() + }); // We'll collect the scholarship's name, num_awards, amount_per_award, total_awards, // fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt) @@ -275,127 +296,139 @@ fn ScholarshipForm( view! { <Panel> - <Row> - <h1 class="text-3xl font-bold"> - "Scholarship Info Form (test)" - </h1> - </Row> - <Row> - <OutlinedTextField - label="Scholarship Name" - placeholder="Example Scholarship Name" - disabled=elements_disabled - data_member="name" - data_map=reactive_info.data - /> - </Row> - <Row> - <OutlinedTextField - label="Amount per award: (if multiple, enter the highest value)" - placeholder="Enter an amount..." - disabled=elements_disabled - data_member="amount_per_award" - data_map=reactive_info.data - input_type="number" - /> - </Row> - <Row> - <OutlinedTextField - label="Total number of awards:" - placeholder="Enter an amount..." - disabled=elements_disabled - data_member="num_awards" - data_map=reactive_info.data - input_type="number" - /> - </Row> - <Row> - <OutlinedTextField - label="Total amount of all awards:" - placeholder="Enter an amount..." - disabled=elements_disabled - data_member="total_awards" - data_map=reactive_info.data - input_type="number" - /> - </Row> - <Row> - <RadioList - data_member="fafsa_required" - data_map=reactive_info.data - items=vec!["Yes".to_string(), "No".to_string()] - disabled=elements_disabled - label="Do you require student financial information?" - /> - </Row> - <Row> - <RadioList - data_member="transcript_required" - data_map=reactive_info.data - items=vec!["Yes".to_string(), "No".to_string()] - disabled=elements_disabled - label="Do you require a student transcript?" - /> - </Row> - <Row> - <RadioList - data_member="award_to" - data_map=reactive_info.data - items=vec!["School".to_string(), "Student".to_string()] - disabled=elements_disabled - label="Will the award be made to the school or the student?" - /> - </Row> - <Row> - <RadioList - data_member="essay_required" - data_map=reactive_info.data - items=vec!["Yes".to_string(), "No".to_string()] - disabled=elements_disabled - label="Do you require a student essay?" - /> - </Row> - <Row> - <RadioList - data_member="essay_prompt" - data_map=reactive_info.data - items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect() - disabled=elements_disabled - label="If so, select a prompt from the list below." - /> - </Row> - <Row> - <ChipsList - label="Scholarship Requirements" - data_member="requirements" - data_map=reactive_info.data - values=comparison_list - .iter() - .cloned() - .map(|comp| comp.id) - .collect() - displayed_text=comparison_list - .iter() - .cloned() - .map(|comp| comp.display_text) - .collect() - /> - </Row> - <Row> - <ActionButton - disabled=elements_disabled - on:click=move |_| { - let captured = reactive_info.capture(); - log!("Map values: {:?}", captured); - submit_action.dispatch(CreateScholarshipInfo { - info: captured - }); - } - >"Submit"</ActionButton> - </Row> - <Row> - <p>{result_msg}</p> - </Row> + <Suspense fallback=Loading> + {move || { + scholarship_info.get() + .map(|res_scholarship| { + match res_scholarship { + Ok(scholarship) => { + let reactive_info = scholarship.as_reactive(); + + Either::Left(view! { + <Row> + <h1 class="text-3xl font-bold"> + "Scholarship Info Form (test)" + </h1> + </Row> + <Row> + <OutlinedTextField + label="Scholarship Name" + placeholder="Example Scholarship Name" + disabled=elements_disabled + data_member="name" + data_map=reactive_info.data + /> + </Row> + <Row> + <OutlinedTextField + label="Amount per award: (if multiple, enter the highest value)" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="amount_per_award" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <OutlinedTextField + label="Total number of awards:" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="num_awards" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <OutlinedTextField + label="Total amount of all awards:" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="total_awards" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <RadioList + data_member="fafsa_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require student financial information?" + /> + </Row> + <Row> + <RadioList + data_member="transcript_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require a student transcript?" + /> + </Row> + <Row> + <RadioList + data_member="award_to" + data_map=reactive_info.data + items=vec!["School".to_string(), "Student".to_string()] + disabled=elements_disabled + label="Will the award be made to the school or the student?" + /> + </Row> + <Row> + <RadioList + data_member="essay_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require a student essay?" + /> + </Row> + <Row> + <RadioList + data_member="essay_prompt" + data_map=reactive_info.data + items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect() + disabled=elements_disabled + label="If so, select a prompt from the list below." + /> + </Row> + <Row> + <ChipsList + label="Scholarship Requirements" + data_member="requirements" + data_map=reactive_info.data + values=comparison_ids + displayed_text=comparison_text + /> + </Row> + <Row> + <ActionButton + disabled=elements_disabled + on:click=move |_| { + let captured = reactive_info.capture(); + log!("Map values: {:?}", captured); + submit_action.dispatch(CreateScholarshipInfo { + info: captured + }); + } + >"Submit"</ActionButton> + </Row> + <Row> + <p>{result_msg}</p> + </Row> + }) + } + Err(err) => { + Either::Right(view! { + <p>"Error while getting scholarship: "{err.to_string()}</p> + }) + } + } + }).collect_view() + }} + </Suspense> </Panel> } } -- 2.54.0 From bab47ac4afbf10ebeb10410d9eec6ef8aab6598d Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Fri, 19 Dec 2025 12:20:05 -0500 Subject: [PATCH 3/9] feat: improve getting server resources --- src/pages/scholarship_info.rs | 283 ++++++++++++++++++---------------- 1 file changed, 146 insertions(+), 137 deletions(-) diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index 1b5845d..43a203d 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -259,7 +259,7 @@ fn ScholarshipForm( }); let comparison_list: Resource<Vec<ComparisonData>> = Resource::new( - move || (), // There's no input to this function. + move || scholarship_id.get(), // There's no input to this function. async move |_| { get_comparison_info().await .unwrap_or_else(|e| { @@ -269,10 +269,14 @@ fn ScholarshipForm( } ); - let scholarship_info: Resource<Result<ExpandableInfo, ServerFnError>> = Resource::new( - move || scholarship_id.get().unwrap_or_default(), - async move |id| { - get_scholarship_info(id).await + let scholarship_info = Resource::new( + move || scholarship_id.get(), + async move |opt_id| { + if let Some(id) = opt_id { + get_scholarship_info(id).await + } else { + Err(ServerFnError::new("Cannot get from server with no ID.")) + } } ); @@ -296,139 +300,144 @@ fn ScholarshipForm( view! { <Panel> - <Suspense fallback=Loading> - {move || { - scholarship_info.get() - .map(|res_scholarship| { - match res_scholarship { - Ok(scholarship) => { - let reactive_info = scholarship.as_reactive(); - - Either::Left(view! { - <Row> - <h1 class="text-3xl font-bold"> - "Scholarship Info Form (test)" - </h1> - </Row> - <Row> - <OutlinedTextField - label="Scholarship Name" - placeholder="Example Scholarship Name" - disabled=elements_disabled - data_member="name" - data_map=reactive_info.data - /> - </Row> - <Row> - <OutlinedTextField - label="Amount per award: (if multiple, enter the highest value)" - placeholder="Enter an amount..." - disabled=elements_disabled - data_member="amount_per_award" - data_map=reactive_info.data - input_type="number" - /> - </Row> - <Row> - <OutlinedTextField - label="Total number of awards:" - placeholder="Enter an amount..." - disabled=elements_disabled - data_member="num_awards" - data_map=reactive_info.data - input_type="number" - /> - </Row> - <Row> - <OutlinedTextField - label="Total amount of all awards:" - placeholder="Enter an amount..." - disabled=elements_disabled - data_member="total_awards" - data_map=reactive_info.data - input_type="number" - /> - </Row> - <Row> - <RadioList - data_member="fafsa_required" - data_map=reactive_info.data - items=vec!["Yes".to_string(), "No".to_string()] - disabled=elements_disabled - label="Do you require student financial information?" - /> - </Row> - <Row> - <RadioList - data_member="transcript_required" - data_map=reactive_info.data - items=vec!["Yes".to_string(), "No".to_string()] - disabled=elements_disabled - label="Do you require a student transcript?" - /> - </Row> - <Row> - <RadioList - data_member="award_to" - data_map=reactive_info.data - items=vec!["School".to_string(), "Student".to_string()] - disabled=elements_disabled - label="Will the award be made to the school or the student?" - /> - </Row> - <Row> - <RadioList - data_member="essay_required" - data_map=reactive_info.data - items=vec!["Yes".to_string(), "No".to_string()] - disabled=elements_disabled - label="Do you require a student essay?" - /> - </Row> - <Row> - <RadioList - data_member="essay_prompt" - data_map=reactive_info.data - items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect() - disabled=elements_disabled - label="If so, select a prompt from the list below." - /> - </Row> - <Row> - <ChipsList - label="Scholarship Requirements" - data_member="requirements" - data_map=reactive_info.data - values=comparison_ids - displayed_text=comparison_text - /> - </Row> - <Row> - <ActionButton - disabled=elements_disabled - on:click=move |_| { - let captured = reactive_info.capture(); - log!("Map values: {:?}", captured); - submit_action.dispatch(CreateScholarshipInfo { - info: captured - }); - } - >"Submit"</ActionButton> - </Row> - <Row> - <p>{result_msg}</p> - </Row> - }) + <Show + when=move || scholarship_id.get().is_some() + fallback=|| view! { <p>"Choose a scholarship from the right, or create a new one."</p> } + > + <Suspense fallback=Loading> + {move || { + scholarship_info.get() + .map(|res_scholarship| { + match res_scholarship { + Ok(scholarship) => { + let reactive_info = scholarship.as_reactive(); + + Either::Left(view! { + <Row> + <h1 class="text-3xl font-bold"> + "Scholarship Info Form (test)" + </h1> + </Row> + <Row> + <OutlinedTextField + label="Scholarship Name" + placeholder="Example Scholarship Name" + disabled=elements_disabled + data_member="name" + data_map=reactive_info.data + /> + </Row> + <Row> + <OutlinedTextField + label="Amount per award: (if multiple, enter the highest value)" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="amount_per_award" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <OutlinedTextField + label="Total number of awards:" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="num_awards" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <OutlinedTextField + label="Total amount of all awards:" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="total_awards" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <RadioList + data_member="fafsa_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require student financial information?" + /> + </Row> + <Row> + <RadioList + data_member="transcript_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require a student transcript?" + /> + </Row> + <Row> + <RadioList + data_member="award_to" + data_map=reactive_info.data + items=vec!["School".to_string(), "Student".to_string()] + disabled=elements_disabled + label="Will the award be made to the school or the student?" + /> + </Row> + <Row> + <RadioList + data_member="essay_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require a student essay?" + /> + </Row> + <Row> + <RadioList + data_member="essay_prompt" + data_map=reactive_info.data + items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect() + disabled=elements_disabled + label="If so, select a prompt from the list below." + /> + </Row> + <Row> + <ChipsList + label="Scholarship Requirements" + data_member="requirements" + data_map=reactive_info.data + values=comparison_ids + displayed_text=comparison_text + /> + </Row> + <Row> + <ActionButton + disabled=elements_disabled + on:click=move |_| { + let captured = reactive_info.capture(); + log!("Map values: {:?}", captured); + submit_action.dispatch(CreateScholarshipInfo { + info: captured + }); + } + >"Submit"</ActionButton> + </Row> + <Row> + <p>{result_msg}</p> + </Row> + }) + } + Err(err) => { + Either::Right(view! { + <p>"Error while getting scholarship: "{err.to_string()}</p> + }) + } } - Err(err) => { - Either::Right(view! { - <p>"Error while getting scholarship: "{err.to_string()}</p> - }) - } - } - }).collect_view() - }} - </Suspense> + }).collect_view() + }} + </Suspense> + </Show> </Panel> } } -- 2.54.0 From ddfac3dc2e700642bb1757f6feb4cfacf076c3e2 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Fri, 19 Dec 2025 12:26:02 -0500 Subject: [PATCH 4/9] feat: redirect from loaner check in to main page --- src/pages/loaner_page.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 9595913..376724e 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -298,6 +298,8 @@ pub fn LoanerBorrowForm() -> impl IntoView { #[component] pub fn LoanerReturnForm() -> impl IntoView { + let navigate = use_navigate(); + // Register server actions let check_in_action = ServerAction::<CheckInLoaner>::new(); @@ -313,26 +315,18 @@ pub fn LoanerReturnForm() -> impl IntoView { let dialog_ref = NodeRef::<Dialog>::new(); let chosen_student = RwSignal::new(None::<LoanerReturnOutput>); - let dialog_disabled = RwSignal::new(false); + 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) Effect::new(move || { if let Some(Ok(_)) = check_in_action.value().get() { - // Check the local loaner list and remove the indicated student from it. - return_list_refresh.update(|n| *n += 1); + // Return to the main page. if let Some(dialog) = dialog_ref.get() { dialog.close(); } - } - }); - - // We can't use a Memo in our custom components for the disabled signal. - Effect::new(move || { - if check_in_action.pending().get() { - dialog_disabled.set(true); - } else { - dialog_disabled.set(false); + navigate("/loaners", Default::default()); + check_in_action.clear(); } }); -- 2.54.0 From f74e5ca0937c9a7ca3c89fe16af9ead84b9b0a02 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Fri, 19 Dec 2025 12:48:22 -0500 Subject: [PATCH 5/9] feat: improve error handling and add documentation --- src/pages/api/scholarships.rs | 4 +-- src/pages/scholarship_info.rs | 60 +++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/pages/api/scholarships.rs b/src/pages/api/scholarships.rs index 28dbfd4..b006143 100644 --- a/src/pages/api/scholarships.rs +++ b/src/pages/api/scholarships.rs @@ -32,8 +32,8 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn log!("Found output from API: {:?}", item); Ok(serde_dynamo::from_item(item)?) } else { - log!("Couldn't find any values, returning default struct."); - Ok(ExpandableInfo::new(id.clone())) + log!("Couldn't find scholarship with ID {:?}", id); + Err(ServerFnError::new("Couldn't find scholarship with given ID.")) } } Err(err) => { diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index 43a203d..94550af 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -64,6 +64,25 @@ pub fn ScholarshipInfoPage() -> impl IntoView { } } +/// # Scholarship List Component +/// +/// This component should always be wrapped in an Authenticated component. Assuming that a provider +/// is authenticated, this component will get all the scholarships that this provider owns and +/// will display them in a list, giving them the option to edit, delete, or create new scholarships. +/// +/// It does not accept any props, and instead pulls the provider's information from the surrounding +/// environment. +/// +/// Example usage: +/// ``` +/// view! { +/// <AuthLoaded> +/// <Authenticated> +/// <ScholarshipList /> +/// </Authenticated> +/// </AuthLoaded> +/// } +/// ``` #[component] fn ScholarshipList() -> impl IntoView { // Get scholarship provider information from authentication. This element will not display @@ -158,12 +177,33 @@ fn ScholarshipList() -> impl IntoView { } } +/// # Scholarship List Entry Component +/// +/// Displays a single scholarship within the [`ScholarshipList`] component. +/// +/// Example usage (within the [`ScholarshipList`]): +/// ``` +/// view! { +/// <ScholarshipListEntry +/// scholarship= /* An ExpandableInfo object containing scholarship info */ +/// on_delete=move || log!("This is a callback on successful deletion.") +/// /> +/// } +/// ``` #[component] fn ScholarshipListEntry( + /// The scholarship information. This element will attempt to get the name of the scholarship + /// using its `name` field, and also utilizes the `subject` field. #[prop()] scholarship: ExpandableInfo, + /// A [`Callback<()>`] that runs upon successful deletion of the scholarship. #[prop(into)] on_delete: Callback<()>, ) -> impl IntoView { let navigate = use_navigate(); + let user_claims = get_user_claims(); + let user_id = Memo::new(move |_| { + user_claims.get() + .map(|info| info.claims.subject.clone()) + }); // Get the values that we need out of the info. We just need the subject and the name of // the scholarship. @@ -187,15 +227,17 @@ fn ScholarshipListEntry( // Delete the scholarship. let subject = subject.clone(); log!("Deleting scholarship with ID {:?}", subject); - spawn_local(async move { - match delete_provider_scholarship( - subject, - "".to_string() - ).await { - Ok(_) => on_delete.run(()), - _ => {} - } - }); + if let Some(user_id) = user_id.get() { + spawn_local(async move { + match delete_provider_scholarship( + user_id, + subject + ).await { + Ok(_) => on_delete.run(()), + _ => {} + } + }); + } } }; -- 2.54.0 From 05d8e1527ef367f980795b130b3e2faa56b27ad7 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Sun, 21 Dec 2025 13:27:07 -0500 Subject: [PATCH 6/9] feat: allow users to confirm deletion of scholarships --- src/common/expandable_info.rs | 2 +- src/pages/scholarship_info.rs | 112 +++++++++++++++++++++++----------- 2 files changed, 78 insertions(+), 36 deletions(-) diff --git a/src/common/expandable_info.rs b/src/common/expandable_info.rs index a209606..0ef8ff0 100644 --- a/src/common/expandable_info.rs +++ b/src/common/expandable_info.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; /// now, the table will simply create a single massive `Map` field that contains all the stored /// information in the `HashMap`. We'd prefer to be able to get single values if possible, /// especially for interoperability with other applications, should they be created. -#[derive(Debug, Clone, Serialize, Deserialize, Reactive)] +#[derive(Debug, Clone, Serialize, Deserialize, Reactive, )] pub struct ExpandableInfo { /// The main key member. Required and used to get/put the information in a database. pub subject: String, diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index 94550af..07d9b92 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -1,7 +1,7 @@ use leptos::either::Either; +use leptos::html::Dialog; use leptos::logging::log; use leptos::prelude::*; -use leptos::reactive::spawn_local; use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_router::hooks::{use_navigate, use_params}; use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType}; @@ -10,7 +10,7 @@ use super::UnauthenticatedPage; use traits::{AsReactive, ReactiveCapture}; use crate::common::ComparisonData; use crate::pages::utils::get_user_claims; -use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, delete_provider_scholarship, CreateScholarshipInfo, CreateTestComparisons, RegisterScholarship}; +use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons, RegisterScholarship, DeleteProviderScholarship}; /// # Scholarship Info Page @@ -85,6 +85,10 @@ pub fn ScholarshipInfoPage() -> impl IntoView { /// ``` #[component] fn ScholarshipList() -> impl IntoView { + // List state + let pending_delete = RwSignal::new(None::<ExpandableInfo>); + let delete_dialog_ref = NodeRef::<Dialog>::new(); + // Get scholarship provider information from authentication. This element will not display // unless that information is available. let user_claims = get_user_claims(); @@ -109,30 +113,90 @@ fn ScholarshipList() -> impl IntoView { // Register scholarship creation/deletion actions let create_action = ServerAction::<RegisterScholarship>::new(); + let delete_action = ServerAction::<DeleteProviderScholarship>::new(); // Button handlers let create_on_click = move |_| { - // In the future, we'd actually like to create/show a dialog that gets the - // desired name of the scholarship. - // After the request is sent, we'll handle it in an effect. create_action.dispatch(RegisterScholarship { provider_id: provider_id.get().unwrap(), scholarship_name: "Testing Scholarship".to_string() }); }; + + let on_delete = move |s| { + pending_delete.set(Some(s)); + }; // Register scholarship creation effect Effect::new(move || { // We want to wait for the action to complete first, but then find whether the request // succeeded. if let Some(Ok(_)) = create_action.value().get() { - // Refetch and clear. + // Refetch and clear. We could alter this slightly to get the new element and + // update the list on the client side only (no refetch). We also want to set the name + // of the new scholarship before it's created on the server. scholarships.refetch(); create_action.clear(); } }); + + // Register scholarship deletion effects + let modal_disabled = Signal::derive(move || delete_action.pending().get()); + Effect::new(move || { + if let Some(Ok(_)) = delete_action.value().get() { + scholarships.refetch(); + pending_delete.set(None); + delete_action.clear(); + } + }); + + // Register scholarship pending delete effects + Effect::new(move || { + if let Some(dialog) = delete_dialog_ref.get() { + if pending_delete.get().is_some() { + // Find the modal dialog and show it. Ask for the user's confirmation. Afterward, + // take the correct action. + dialog.show_modal() + .expect("Couldn't show dialog. Is it already open?"); + } else { + dialog.close(); + } + } + }); view! { + <dialog + node_ref=delete_dialog_ref + class="m-auto p-2" + > + <h2 class="text-2xl font-bold">"Confirm Deletion"</h2> + <p> + "Are you sure you want to delete " + {move || { + pending_delete.get().map(|s| { + s.data.get("name").unwrap_or(&ValueType::String(None)) + .as_string() + .unwrap_or_default() + .unwrap_or("<no name found>".to_string()) + }) + }} + "?" + </p> + <ActionButton + on:click=move |_| { + let subject = pending_delete.get().map(|s| s.subject.clone()).unwrap_or_default(); + log!("Deleting scholarship with subject {}", subject); + delete_action.dispatch(DeleteProviderScholarship { + scholarship_id: subject, + provider_id: provider_id.get().unwrap_or_default() + }); + } + disabled=modal_disabled + >"Confirm"</ActionButton> + <ActionButton + on:click=move |_| pending_delete.set(None) + >"Cancel"</ActionButton> + </dialog> <div class="w-75"> <Panel> <div class="pt-2"> @@ -145,11 +209,9 @@ fn ScholarshipList() -> impl IntoView { match result { Ok(list) => { let views = list.iter().map(|entry| { - view! { - <ScholarshipListEntry scholarship=entry.clone() - on_delete=move || { - scholarships.refetch(); - }/> + view! { + <ScholarshipListEntry scholarship=entry.clone() + on_delete=Callback::new(on_delete)/> } }); Either::Left(views.collect_view()) @@ -186,7 +248,7 @@ fn ScholarshipList() -> impl IntoView { /// view! { /// <ScholarshipListEntry /// scholarship= /* An ExpandableInfo object containing scholarship info */ -/// on_delete=move || log!("This is a callback on successful deletion.") +/// on_delete=move |_| log!("This is a callback on successful deletion.") /// /> /// } /// ``` @@ -195,8 +257,8 @@ fn ScholarshipListEntry( /// The scholarship information. This element will attempt to get the name of the scholarship /// using its `name` field, and also utilizes the `subject` field. #[prop()] scholarship: ExpandableInfo, - /// A [`Callback<()>`] that runs upon successful deletion of the scholarship. - #[prop(into)] on_delete: Callback<()>, + /// A [`Callback<ExpandableInfo>`] that runs upon successful deletion of the scholarship. + #[prop(into)] on_delete: Callback<ExpandableInfo, ()>, ) -> impl IntoView { let navigate = use_navigate(); let user_claims = get_user_claims(); @@ -221,26 +283,6 @@ fn ScholarshipListEntry( } }; - let delete_click = { - let subject = scholarship.subject.clone(); - move |_| { - // Delete the scholarship. - let subject = subject.clone(); - log!("Deleting scholarship with ID {:?}", subject); - if let Some(user_id) = user_id.get() { - spawn_local(async move { - match delete_provider_scholarship( - user_id, - subject - ).await { - Ok(_) => on_delete.run(()), - _ => {} - } - }); - } - } - }; - view! { <div class="flex flex-col rounded-md border-light inset-shadow-xs shadow-md my-2 transition-all hover:shadow-lg/33"> <div class="p-2 text-center"> @@ -255,7 +297,7 @@ fn ScholarshipListEntry( </div> <div class="p-2 flex-1 bg-red-800 border-l border-white rounded-br-md text-white text-center hover:bg-red-900 cursor-pointer transition-all" - on:click=delete_click + on:click=move |_| on_delete.run(scholarship.clone()) > "Delete" </div> -- 2.54.0 From c2fdd30eead94caee2621c51a5c6760594dbe684 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Sun, 21 Dec 2025 22:07:22 -0500 Subject: [PATCH 7/9] feat: refresh scholarship list on form submission --- src/pages/scholarship_info.rs | 38 ++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index 07d9b92..1f99c71 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -20,6 +20,9 @@ use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship /// must be handled *before* navigation to this page. #[component] pub fn ScholarshipInfoPage() -> impl IntoView { + // Changes to this signal update the scholarship list. + let list_refresh = RwSignal::new(0); + let url_params = use_params::<ScholarshipFormParams>(); let scholarship_id = Memo::new(move |_| { url_params.read() @@ -42,21 +45,15 @@ pub fn ScholarshipInfoPage() -> impl IntoView { <Authenticated unauthenticated=UnauthenticatedPage> <div class="flex flex-row align-top items-start"> <div class="flex flex-col flex-1" /> - <ScholarshipList /> + <ScholarshipList refresh_token=list_refresh/> <Transition fallback=Loading> <ScholarshipForm scholarship_id=scholarship_id + on_submit=move || { + list_refresh.update(|n| *n += 1); + } /> </Transition> - <div class="hidden"> - <Row> - <ActionButton - on:click=move |_| { - create_test_comps.dispatch(CreateTestComparisons {}); - } - >"Create test comparisons"</ActionButton> - </Row> - </div> <div class="flex flex-col flex-1" /> </div> </Authenticated> @@ -84,7 +81,9 @@ pub fn ScholarshipInfoPage() -> impl IntoView { /// } /// ``` #[component] -fn ScholarshipList() -> impl IntoView { +fn ScholarshipList( + #[prop()] refresh_token: RwSignal<i32>, +) -> impl IntoView { // List state let pending_delete = RwSignal::new(None::<ExpandableInfo>); let delete_dialog_ref = NodeRef::<Dialog>::new(); @@ -100,8 +99,11 @@ fn ScholarshipList() -> impl IntoView { // Register scholarship server resource let scholarships = Resource::new( - move || provider_id.get(), - async move |provider_id| { + move || { + (refresh_token.get(), provider_id.get()) + }, + |(_, provider_id)| async move { + log!("Fetching from scholarship API."); let Some(provider_id) = provider_id else { return Ok(Vec::new()); }; @@ -308,7 +310,8 @@ fn ScholarshipListEntry( #[component] fn ScholarshipForm( - #[prop(into)] scholarship_id: Signal<Option<String>> + #[prop(into)] scholarship_id: Signal<Option<String>>, + #[prop(into)] on_submit: Callback<()> ) -> impl IntoView { let submit_status = RwSignal::new(SubmitStatus::Idle); let elements_disabled = Signal::derive(move || { @@ -336,7 +339,10 @@ fn ScholarshipForm( if let Some(result) = submit_action.value().get() { match result { - Ok(()) => submit_status.set(SubmitStatus::Success), + Ok(()) => { + submit_status.set(SubmitStatus::Success); + on_submit.run(()); + }, Err(err) => submit_status.set(SubmitStatus::Error(err.to_string())) } } @@ -502,7 +508,7 @@ fn ScholarshipForm( let captured = reactive_info.capture(); log!("Map values: {:?}", captured); submit_action.dispatch(CreateScholarshipInfo { - info: captured + info: captured.clone() }); } >"Submit"</ActionButton> -- 2.54.0 From 2324c493d439c71caef5bb3e4dfdb7b05bb7e37a Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Sun, 21 Dec 2025 22:17:40 -0500 Subject: [PATCH 8/9] docs: add comments to scholarship edit form --- src/pages/scholarship_info.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index 1f99c71..bd0d433 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -308,9 +308,31 @@ fn ScholarshipListEntry( } } +/// # Scholarship Edit Form Component +/// +/// Displays all information about a scholarship and allows the provider to edit that information. +/// All information is only saved when the submit button is clicked. +/// +/// The component will call the `on_submit` callback when the API returns an `Ok` result. In the +/// event that the API call fails, it will not call anything. This is mainly used to allow updates +/// to neighboring components from the parent. +/// +/// Example usage: +/// ``` +/// view! { +/// <ScholarshipForm +/// scholarship_id="SomeScholarshipID" +/// on_submit=move || log!("This is a callback!") +/// /> +/// } +/// ``` #[component] fn ScholarshipForm( + /// The ID of the scholarship to edit. The form will handle getting all information about the + /// scholarship and submitting new information. #[prop(into)] scholarship_id: Signal<Option<String>>, + /// A callback that is run when the form successfully submits new data. In the event that the + /// submission fails, this function is not run. #[prop(into)] on_submit: Callback<()> ) -> impl IntoView { let submit_status = RwSignal::new(SubmitStatus::Idle); -- 2.54.0 From 8d901453612b786f74812648b667ba47fe5d5115 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Mon, 22 Dec 2025 08:27:17 -0500 Subject: [PATCH 9/9] fix: redirect user when deleting scholarship --- src/app.rs | 17 +++++++++-------- src/pages/provider_portal.rs | 2 +- src/pages/scholarship_info.rs | 29 ++++++++++++++++++++++------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/app.rs b/src/app.rs index ac7f713..b3b1aa6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use crate::pages::{AboutPage, HomePage, LoanerPage, ProviderPortal, ScholarshipInfoPage, TestPage}; +use crate::pages::{AboutPage, HomePage, LoanerPage, ProviderPortal, ComparisonTestPage, ScholarshipInfoPage, TestPage}; use leptos::leptos_dom::log; use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; @@ -96,17 +96,18 @@ pub fn AppWithRoutes() -> impl IntoView { // sets the document title <Title text="R15 Scholarship App DEV" /> - + <main> // TODO Create a 404 page <Routes fallback=|| "Page not found.".into_view()> <Route path=path!("") view=HomePage/> - <Route path=path!("about") view=AboutPage/> - <Route path=path!("test_page") view=TestPage/> - <Route path=path!("comparison") view=ComparisonTestPage /> - <ParentRoute path=path!("providers/scholarships") view=ScholarshipInfoPage> - <Route path=path!(":id") view=ScholarshipInfoPage /> - <Route path=path!("") view=ScholarshipInfoPage /> + <Route path=path!("/about") view=AboutPage/> + <Route path=path!("/test_page") view=TestPage/> + <Route path=path!("/comparison") view=ComparisonTestPage /> + <Route path=path!("/providers") view=ProviderPortal/> + <ParentRoute path=path!("/providers/scholarships") view=ScholarshipInfoPage> + <Route path=path!(":id") view=ScholarshipInfoPage/> + <Route path=path!("") view=ScholarshipInfoPage/> </ParentRoute> <ParentRoute path=path!("loaners") view=LoanerPage> <Route path=path!("") view=LoanerPage /> diff --git a/src/pages/provider_portal.rs b/src/pages/provider_portal.rs index 9274dba..ea833c0 100644 --- a/src/pages/provider_portal.rs +++ b/src/pages/provider_portal.rs @@ -23,7 +23,7 @@ pub fn ProviderPortal() -> impl IntoView { title="Create Scholarship" description="Navigate to Scholarship Creation Page" icon="/Create_Black.png" - path="/" + path="/providers/scholarships" /> <DashboardButton title="Applicants" diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index bd0d433..b40bff1 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -33,6 +33,11 @@ pub fn ScholarshipInfoPage() -> impl IntoView { let create_test_comps = ServerAction::<CreateTestComparisons>::new(); + let on_delete = move || { + let navigate = use_navigate(); + navigate("/providers/scholarships", Default::default()); + }; + // Remember that we only want to show the page after we've got authentication information // and if there's data for us to use. view! { @@ -45,7 +50,10 @@ pub fn ScholarshipInfoPage() -> impl IntoView { <Authenticated unauthenticated=UnauthenticatedPage> <div class="flex flex-row align-top items-start"> <div class="flex flex-col flex-1" /> - <ScholarshipList refresh_token=list_refresh/> + <ScholarshipList + refresh_token=list_refresh + on_delete=on_delete + /> <Transition fallback=Loading> <ScholarshipForm scholarship_id=scholarship_id @@ -67,22 +75,28 @@ pub fn ScholarshipInfoPage() -> impl IntoView { /// is authenticated, this component will get all the scholarships that this provider owns and /// will display them in a list, giving them the option to edit, delete, or create new scholarships. /// -/// It does not accept any props, and instead pulls the provider's information from the surrounding -/// environment. -/// /// Example usage: /// ``` +/// let refresh = RwSignal::new(0); +/// /// view! { /// <AuthLoaded> /// <Authenticated> -/// <ScholarshipList /> +/// <ScholarshipList +/// refresh_token=refresh +/// on_delete=move || log!("This is run when an item is deleted on the server.") +/// /> /// </Authenticated> /// </AuthLoaded> /// } /// ``` #[component] fn ScholarshipList( + /// A signal that refreshes the list when changed. Most times, we don't need to worry about the + /// user reloading our page 2 billion times. #[prop()] refresh_token: RwSignal<i32>, + /// A callback that is run on successful server-side deletion of an item. + #[prop(into)] on_delete: Callback<()> ) -> impl IntoView { // List state let pending_delete = RwSignal::new(None::<ExpandableInfo>); @@ -125,7 +139,7 @@ fn ScholarshipList( }); }; - let on_delete = move |s| { + let on_item_delete = move |s| { pending_delete.set(Some(s)); }; @@ -149,6 +163,7 @@ fn ScholarshipList( scholarships.refetch(); pending_delete.set(None); delete_action.clear(); + on_delete.run(()); } }); @@ -213,7 +228,7 @@ fn ScholarshipList( let views = list.iter().map(|entry| { view! { <ScholarshipListEntry scholarship=entry.clone() - on_delete=Callback::new(on_delete)/> + on_delete=Callback::new(on_item_delete)/> } }); Either::Left(views.collect_view()) -- 2.54.0