feat: add edit, delete, and fallback capability to providers/scholarships

This commit is contained in:
alphafetus committed 2025-12-19 11:47:43 -05:00
1 parent a0049d4797
commit b687125d9b
8 files changed
+291 -222

No files matched your search

+2 -1
View File
@@ -4,5 +4,6 @@ mod form_schema;
mod user; mod user;
mod value_type; mod value_type;
mod form_params; 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::*};
+7
View File
@@ -0,0 +1,7 @@
#[derive(Debug, Clone)]
pub enum SubmitStatus {
Idle,
Sending,
Success,
Error(String),
}
+2 -2
View File
@@ -2,8 +2,8 @@ use leptos::prelude::*;
#[component] #[component]
pub fn ActionButton( pub fn ActionButton(
#[prop(optional, default = String::from("button"))] button_type: String, #[prop(default = String::from("button"))] button_type: String,
#[prop(optional, default = RwSignal::new(false))] disabled: RwSignal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
children: Children, children: Children,
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
+4 -4
View File
@@ -70,8 +70,8 @@ pub fn Chip(
pub fn ChipsList( pub fn ChipsList(
#[prop(into)] data_member: String, #[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop()] displayed_text: Vec<String>, #[prop(into)] displayed_text: Signal<Vec<String>>,
#[prop()] values: Vec<String>, #[prop(into)] values: Signal<Vec<String>>,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, #[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
#[prop(optional, into)] label: String, #[prop(optional, into)] label: String,
) -> impl IntoView { ) -> impl IntoView {
@@ -79,7 +79,7 @@ pub fn ChipsList(
<div class="m-1.5 mt-0 mb-0"> <div class="m-1.5 mt-0 mb-0">
<span class="font-bold">{label}</span> <span class="font-bold">{label}</span>
<div class="flex flex-row flex-wrap gap-2 mt-1"> <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() .into_iter()
.map(|(text, value)| { .map(|(text, value)| {
let checked_signal = Signal::derive({ let checked_signal = Signal::derive({
@@ -140,7 +140,7 @@ pub fn ChipsList(
} }
}) })
.collect_view() .collect_view()
} }}
</div> </div>
</div> </div>
} }
+2 -2
View File
@@ -9,7 +9,7 @@ pub fn Radio(
#[prop(into)] on_change: Callback<(), ()>, #[prop(into)] on_change: Callback<(), ()>,
#[prop(into)] value: String, #[prop(into)] value: String,
#[prop(into)] name: String, #[prop(into)] name: String,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
) -> impl IntoView { ) -> impl IntoView {
let id = create_unique_id(&name, &value); let id = create_unique_id(&name, &value);
@@ -53,7 +53,7 @@ pub fn RadioList(
#[prop(into)] data_member: String, #[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop()] items: Vec<String>, #[prop()] items: Vec<String>,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, #[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional, into)] label: String, #[prop(optional, into)] label: String,
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
+1 -1
View File
@@ -11,7 +11,7 @@ pub fn OutlinedTextField(
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>, #[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional)] value: RwSignal<ValueType>, #[prop(optional)] value: RwSignal<ValueType>,
#[prop(default = "text".to_owned(), into)] input_type: String, #[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)] error: RwSignal<bool>,
#[prop(optional, into)] name: String, #[prop(optional, into)] name: String,
#[prop(optional, into)] label: String, #[prop(optional, into)] label: String,
+28
View File
@@ -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))
}
}
}
+245 -212
View File
@@ -1,15 +1,16 @@
use leptos::either::Either; use leptos::either::Either;
use leptos::logging::log; use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::reactive::spawn_local;
use leptos_oidc::{AuthLoaded, Authenticated}; use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::{use_navigate, use_params}; 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 crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row};
use super::UnauthenticatedPage; use super::UnauthenticatedPage;
use traits::{AsReactive, ReactiveCapture}; use traits::{AsReactive, ReactiveCapture};
use crate::common::ComparisonData; use crate::common::ComparisonData;
use crate::pages::utils::get_user_claims; 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 /// # Scholarship Info Page
@@ -20,40 +21,13 @@ use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship
#[component] #[component]
pub fn ScholarshipInfoPage() -> impl IntoView { pub fn ScholarshipInfoPage() -> impl IntoView {
let url_params = use_params::<ScholarshipFormParams>(); let url_params = use_params::<ScholarshipFormParams>();
let scholarship_id = move || { let scholarship_id = Memo::new(move |_| {
url_params.read() url_params.read()
.as_ref() .as_ref()
.ok() .ok()
.and_then(|params| params.id.clone()) .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(); let create_test_comps = ServerAction::<CreateTestComparisons>::new();
// Remember that we only want to show the page after we've got authentication information // 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> <AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage> <Authenticated unauthenticated=UnauthenticatedPage>
<Suspense fallback=Loading> <div class="flex flex-row align-top items-start">
<div class="flex flex-row align-top items-start"> <div class="flex flex-col flex-1" />
<div class="flex flex-col flex-1" /> <ScholarshipList />
<ScholarshipList /> <Transition fallback=Loading>
{move || Suspend::new(async move { <ScholarshipForm
let response = get_scholarship_action.get(); scholarship_id=scholarship_id
let comparisons = get_comparison_info.get(); />
</Transition>
response <div class="hidden">
.zip(comparisons) <Row>
.map(|(response, comparison_list)| view! { <ActionButton
<ScholarshipForm on:click=move |_| {
response=response create_test_comps.dispatch(CreateTestComparisons {});
comparison_list=comparison_list }
submit_action=create_scholarship_action >"Create test comparisons"</ActionButton>
/> </Row>
})
.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> </div>
</Suspense> <div class="flex flex-col flex-1" />
</div>
</Authenticated> </Authenticated>
</AuthLoaded> </AuthLoaded>
} }
@@ -115,9 +77,9 @@ fn ScholarshipList() -> impl IntoView {
// Register scholarship server resource // Register scholarship server resource
let scholarships = Resource::new( let scholarships = Resource::new(
move || provider_id, move || provider_id.get(),
async move |provider_id| { async move |provider_id| {
let Some(provider_id) = provider_id.get() else { let Some(provider_id) = provider_id else {
return Ok(Vec::new()); 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(); let create_action = ServerAction::<RegisterScholarship>::new();
// Button handlers // Button handlers
@@ -164,7 +126,12 @@ fn ScholarshipList() -> impl IntoView {
match result { match result {
Ok(list) => { Ok(list) => {
let views = list.iter().map(|entry| { 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()) Either::Left(views.collect_view())
} }
@@ -180,7 +147,7 @@ fn ScholarshipList() -> impl IntoView {
}} }}
</div> </div>
<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> on:click=create_on_click>
"Create New" "Create New"
</div> </div>
@@ -192,12 +159,15 @@ fn ScholarshipList() -> impl IntoView {
} }
#[component] #[component]
fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView { fn ScholarshipListEntry(
#[prop()] scholarship: ExpandableInfo,
#[prop(into)] on_delete: Callback<()>,
) -> impl IntoView {
let navigate = use_navigate(); let navigate = use_navigate();
// Get the values that we need out of the info. We just need the subject and the name of // Get the values that we need out of the info. We just need the subject and the name of
// the scholarship. // the scholarship.
let name = scholarship.data.get("scholarship_name") let name = scholarship.data.get("name")
.unwrap_or(&ValueType::String(None)) .unwrap_or(&ValueType::String(None))
.as_string().unwrap_or_default() .as_string().unwrap_or_default()
.unwrap_or("<no name found>".to_string()); .unwrap_or("<no name found>".to_string());
@@ -214,8 +184,18 @@ fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView {
let delete_click = { let delete_click = {
let subject = scholarship.subject.clone(); let subject = scholarship.subject.clone();
move |_| { move |_| {
// Delete the scholarship. For now, we'll just log it. // Delete the scholarship.
let subject = subject.clone();
log!("Deleting scholarship with ID {:?}", subject); 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> <span>{name}</span>
</div> </div>
<div class="flex flex-row"> <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 <div class="p-2 flex-1 bg-red-800 border-r border-white rounded-bl-md text-white text-center
hover:bg-red-800 cursor-pointer transition-all" hover:bg-red-900 cursor-pointer transition-all"
on:click=edit_click on:click=edit_click
> >
"Edit" "Edit"
</div> </div>
<div class="p-2 flex-1 bg-red-700 border-l border-white rounded-br-md text-white text-center <div class="p-2 flex-1 bg-red-800 border-l border-white rounded-br-md text-white text-center
hover:bg-red-800 cursor-pointer transition-all" hover:bg-red-900 cursor-pointer transition-all"
on:click=delete_click on:click=delete_click
> >
"Delete" "Delete"
@@ -244,30 +224,71 @@ fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView {
#[component] #[component]
fn ScholarshipForm( fn ScholarshipForm(
response: ExpandableInfo, #[prop(into)] scholarship_id: Signal<Option<String>>
comparison_list: Vec<ComparisonData>,
submit_action: ServerAction<CreateScholarshipInfo>
) -> impl IntoView { ) -> impl IntoView {
let reactive_info = response.as_reactive(); let submit_status = RwSignal::new(SubmitStatus::Idle);
let elements_disabled = RwSignal::new(false); let elements_disabled = Signal::derive(move || {
match submit_status.get() {
SubmitStatus::Sending => true,
_ => false
}
});
let result_msg = Signal::derive(move || { 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() { if submit_action.pending().get() {
elements_disabled.set(true); submit_status.set(SubmitStatus::Sending);
"Sending request...".to_string() return;
} else { }
elements_disabled.set(false);
if let Some(result) = submit_action.value().get() { if let Some(result) = submit_action.value().get() {
match result { match result {
Ok(()) => "Request sent successfully".to_string(), Ok(()) => submit_status.set(SubmitStatus::Success),
Err(err) => { Err(err) => submit_status.set(SubmitStatus::Error(err.to_string()))
format!("An error occurred: {:?}", err)
}
}
} else {
"".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, // 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) // fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt)
@@ -275,127 +296,139 @@ fn ScholarshipForm(
view! { view! {
<Panel> <Panel>
<Row> <Suspense fallback=Loading>
<h1 class="text-3xl font-bold"> {move || {
"Scholarship Info Form (test)" scholarship_info.get()
</h1> .map(|res_scholarship| {
</Row> match res_scholarship {
<Row> Ok(scholarship) => {
<OutlinedTextField let reactive_info = scholarship.as_reactive();
label="Scholarship Name"
placeholder="Example Scholarship Name" Either::Left(view! {
disabled=elements_disabled <Row>
data_member="name" <h1 class="text-3xl font-bold">
data_map=reactive_info.data "Scholarship Info Form (test)"
/> </h1>
</Row> </Row>
<Row> <Row>
<OutlinedTextField <OutlinedTextField
label="Amount per award: (if multiple, enter the highest value)" label="Scholarship Name"
placeholder="Enter an amount..." placeholder="Example Scholarship Name"
disabled=elements_disabled disabled=elements_disabled
data_member="amount_per_award" data_member="name"
data_map=reactive_info.data data_map=reactive_info.data
input_type="number" />
/> </Row>
</Row> <Row>
<Row> <OutlinedTextField
<OutlinedTextField label="Amount per award: (if multiple, enter the highest value)"
label="Total number of awards:" placeholder="Enter an amount..."
placeholder="Enter an amount..." disabled=elements_disabled
disabled=elements_disabled data_member="amount_per_award"
data_member="num_awards" data_map=reactive_info.data
data_map=reactive_info.data input_type="number"
input_type="number" />
/> </Row>
</Row> <Row>
<Row> <OutlinedTextField
<OutlinedTextField label="Total number of awards:"
label="Total amount of all awards:" placeholder="Enter an amount..."
placeholder="Enter an amount..." disabled=elements_disabled
disabled=elements_disabled data_member="num_awards"
data_member="total_awards" data_map=reactive_info.data
data_map=reactive_info.data input_type="number"
input_type="number" />
/> </Row>
</Row> <Row>
<Row> <OutlinedTextField
<RadioList label="Total amount of all awards:"
data_member="fafsa_required" placeholder="Enter an amount..."
data_map=reactive_info.data disabled=elements_disabled
items=vec!["Yes".to_string(), "No".to_string()] data_member="total_awards"
disabled=elements_disabled data_map=reactive_info.data
label="Do you require student financial information?" input_type="number"
/> />
</Row> </Row>
<Row> <Row>
<RadioList <RadioList
data_member="transcript_required" data_member="fafsa_required"
data_map=reactive_info.data data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()] items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled disabled=elements_disabled
label="Do you require a student transcript?" label="Do you require student financial information?"
/> />
</Row> </Row>
<Row> <Row>
<RadioList <RadioList
data_member="award_to" data_member="transcript_required"
data_map=reactive_info.data data_map=reactive_info.data
items=vec!["School".to_string(), "Student".to_string()] items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled disabled=elements_disabled
label="Will the award be made to the school or the student?" label="Do you require a student transcript?"
/> />
</Row> </Row>
<Row> <Row>
<RadioList <RadioList
data_member="essay_required" data_member="award_to"
data_map=reactive_info.data data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()] items=vec!["School".to_string(), "Student".to_string()]
disabled=elements_disabled disabled=elements_disabled
label="Do you require a student essay?" label="Will the award be made to the school or the student?"
/> />
</Row> </Row>
<Row> <Row>
<RadioList <RadioList
data_member="essay_prompt" data_member="essay_required"
data_map=reactive_info.data data_map=reactive_info.data
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect() items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled disabled=elements_disabled
label="If so, select a prompt from the list below." label="Do you require a student essay?"
/> />
</Row> </Row>
<Row> <Row>
<ChipsList <RadioList
label="Scholarship Requirements" data_member="essay_prompt"
data_member="requirements" data_map=reactive_info.data
data_map=reactive_info.data items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect()
values=comparison_list disabled=elements_disabled
.iter() label="If so, select a prompt from the list below."
.cloned() />
.map(|comp| comp.id) </Row>
.collect() <Row>
displayed_text=comparison_list <ChipsList
.iter() label="Scholarship Requirements"
.cloned() data_member="requirements"
.map(|comp| comp.display_text) data_map=reactive_info.data
.collect() values=comparison_ids
/> displayed_text=comparison_text
</Row> />
<Row> </Row>
<ActionButton <Row>
disabled=elements_disabled <ActionButton
on:click=move |_| { disabled=elements_disabled
let captured = reactive_info.capture(); on:click=move |_| {
log!("Map values: {:?}", captured); let captured = reactive_info.capture();
submit_action.dispatch(CreateScholarshipInfo { log!("Map values: {:?}", captured);
info: captured submit_action.dispatch(CreateScholarshipInfo {
}); info: captured
} });
>"Submit"</ActionButton> }
</Row> >"Submit"</ActionButton>
<Row> </Row>
<p>{result_msg}</p> <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> </Panel>
} }
} }