From b687125d9bed707d4e468707f88ee7dedecf8f8a Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Fri, 19 Dec 2025 11:47:43 -0500 Subject: [PATCH] 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, + #[prop(default = String::from("button"))] button_type: String, + #[prop(optional, into)] disabled: Signal, 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>, - #[prop()] displayed_text: Vec, - #[prop()] values: Vec, + #[prop(into)] displayed_text: Signal>, + #[prop(into)] values: Signal>, #[prop(default = RwSignal::new(false))] disabled: RwSignal, #[prop(optional, into)] label: String, ) -> impl IntoView { @@ -79,7 +79,7 @@ pub fn ChipsList(
{label}
- {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() - } + }}
} 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, + #[prop(optional, into)] disabled: Signal, ) -> 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>, #[prop()] items: Vec, - #[prop(default = RwSignal::new(false))] disabled: RwSignal, + #[prop(optional, into)] disabled: Signal, #[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>, #[prop(optional)] value: RwSignal, #[prop(default = "text".to_owned(), into)] input_type: String, - #[prop(optional)] disabled: RwSignal, + #[prop(into, optional)] disabled: Signal, #[prop(optional)] error: RwSignal, #[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::(); - 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 = 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> = Resource::new( - move || Option::::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::::new(); let create_test_comps = ServerAction::::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 { /> - -
-
- - {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! { - - }) - .collect_view() - })} - -
+
+
+ + + + + - +
+
} @@ -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::::new(); // Button handlers @@ -164,7 +126,12 @@ fn ScholarshipList() -> impl IntoView { match result { Ok(list) => { let views = list.iter().map(|entry| { - view! { } + view! { + + } }); Either::Left(views.collect_view()) } @@ -180,7 +147,7 @@ fn ScholarshipList() -> impl IntoView { }}
-
"Create New"
@@ -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("".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 { {name}
-
"Edit"
-
"Delete" @@ -244,30 +224,71 @@ fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView { #[component] fn ScholarshipForm( - response: ExpandableInfo, - comparison_list: Vec, - submit_action: ServerAction + #[prop(into)] scholarship_id: Signal> ) -> 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::::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> = 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> = 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::>() + ).unwrap_or_default() + }); + + let comparison_text = Memo::new(move |_| { + comparison_list.get().map(|list| list.iter() + .map(|comp| comp.clone().display_text) + .collect::>() + ).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! { - -

- "Scholarship Info Form (test)" -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - "Submit" - - -

{result_msg}

-
+ + {move || { + scholarship_info.get() + .map(|res_scholarship| { + match res_scholarship { + Ok(scholarship) => { + let reactive_info = scholarship.as_reactive(); + + Either::Left(view! { + +

+ "Scholarship Info Form (test)" +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "Submit" + + +

{result_msg}

+
+ }) + } + Err(err) => { + Either::Right(view! { +

"Error while getting scholarship: "{err.to_string()}

+ }) + } + } + }).collect_view() + }} +
} }