diff --git a/src/app.rs b/src/app.rs index c51ccdc..b3b1aa6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,12 +1,9 @@ -use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, ProviderPortal, 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}; use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; -use leptos_router::{ - components::{Route, Router, Routes}, - path, -}; +use leptos_router::{components::{Route, Router, Routes, ParentRoute}, path}; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { @@ -35,7 +32,7 @@ pub fn App() -> impl IntoView { // injects a stylesheet into the document // id=leptos means cargo-leptos will hot-reload this stylesheet - + // sets the document title @@ -47,8 +44,7 @@ pub fn App() -> impl IntoView { } fn use_origin() -> String { - #[cfg(target_arch = "wasm32")] - { + #[cfg(target_arch = "wasm32")] { use leptos::web_sys; // Just get the current URL origin. web_sys::window() @@ -57,9 +53,8 @@ fn use_origin() -> String { .origin() .unwrap_or("http://localhost:3000/".to_string()) } - - #[cfg(not(target_arch = "wasm32"))] - { + + #[cfg(not(target_arch = "wasm32"))] { // Read the expected origin out of an environment variable. std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string()) } @@ -68,7 +63,7 @@ fn use_origin() -> String { #[component] pub fn AppWithRoutes() -> impl IntoView { provide_meta_context(); - + let current_origin = use_origin(); log!("Current origin: {}", current_origin); @@ -87,7 +82,7 @@ pub fn AppWithRoutes() -> impl IntoView { let auth = Auth::signal(); provide_context(auth); // allows use of this signal in lower areas of the tree without - // explicitly passing it through the html tree + // explicitly passing it through the html tree let _ = Auth::init(parameters); @@ -101,17 +96,23 @@ 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 /> - <Route path=path!("providers") view=ProviderPortal /> - <Route path=path!("loaners") view=LoanerPage /> - <Route path=path!("loaners/:form_name") view=LoanerPage /> + <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 /> + <Route path=path!(":form_name") view=LoanerPage /> + </ParentRoute> </Routes> </main> } 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/common/mod.rs b/src/common/mod.rs index 135600c..a34a9ec 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -4,7 +4,6 @@ mod form_params; mod form_schema; mod user; mod value_type; +mod submit_status; -pub use self::{ - comparison::*, expandable_info::*, form_params::*, form_schema::*, user::*, value_type::*, -}; +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/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/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 4ec5301..b006143 100644 --- a/src/pages/api/scholarships.rs +++ b/src/pages/api/scholarships.rs @@ -1,10 +1,14 @@ #[cfg(feature = "ssr")] -use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue}; +use aws_sdk_dynamodb::{ + types::AttributeValue, + error::ProvideErrorMetadata, +}; -use crate::common::ExpandableInfo; use leptos::leptos_dom::log; use leptos::prelude::ServerFnError; use leptos::server; +use uuid::Uuid; +use crate::common::{ExpandableInfo, ValueType}; #[cfg(feature = "ssr")] use crate::pages::utils::server_utils::create_dynamo_client; @@ -28,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) => { @@ -46,10 +50,7 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF let client = create_dynamo_client().await; - log!( - "Creating or updating scholarship with ID {:?}", - info.subject - ); + log!("Creating or updating scholarship with ID {:?}", info.subject); match client .put_item() @@ -75,6 +76,35 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE .table_name("leptos-scholarship-test") .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)) + } + } +} + +/// 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 { @@ -89,3 +119,76 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE } } } + +/// 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"))); + } + } + } + } + } +} + +/// 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/comparison_test.rs b/src/pages/comparison_test.rs index 4ef31cb..8bb9ab8 100644 --- a/src/pages/comparison_test.rs +++ b/src/pages/comparison_test.rs @@ -1,25 +1,21 @@ use super::UnauthenticatedPage; -use super::api::{get_all_scholarship_info, get_comparison_info}; use super::home_page::get_submission; -use crate::common::{ComparisonData, ExpandableInfo, UserClaims, ValueType}; +use crate::common::{ + ComparisonData, ExpandableInfo, ValueType, +}; use crate::components::{ActionButton, Loading, Panel, Row}; use leptos::either::Either; 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 c3ca850..f4c6ed0 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -5,25 +5,26 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; #[cfg(feature = "ssr")] -use std::io::Write; -#[cfg(feature = "ssr")] use std::process::{Command, Stdio}; +#[cfg(feature = "ssr")] +use std::io::Write; use crate::common::{ExpandableInfo, UserClaims}; +use crate::pages::UnauthenticatedPage; use crate::components::{ ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, Select, }; use crate::input; -use crate::pages::UnauthenticatedPage; -use base64::Engine; use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; +use leptos_oidc::{AuthLoaded, Authenticated}; +use base64::Engine; use leptos::task::spawn_local; -use leptos::wasm_bindgen::JsCast; use leptos::web_sys::HtmlAnchorElement; -use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; +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`. @@ -144,15 +145,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/loaner_page.rs b/src/pages/loaner_page.rs index b4acf04..b03cd9b 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -327,6 +327,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(); @@ -344,26 +346,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(); } }); diff --git a/src/pages/mod.rs b/src/pages/mod.rs index cd9f5f5..b3d9f41 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -10,6 +10,6 @@ mod unauthenticated_page; mod utils; pub use self::{ - about_page::*, comparison_test::*, home_page::*, loaner_page::LoanerPage, provider_portal::*, - test_page::*, unauthenticated_page::*, + about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*, + test_page::*, unauthenticated_page::*, scholarship_info::* }; 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 420dd9f..b40bff1 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -1,261 +1,570 @@ -use super::UnauthenticatedPage; -use super::api::{ - CreateScholarshipInfo, CreateTestComparisons, get_comparison_info, get_scholarship_info, -}; -use crate::common::ComparisonData; -use crate::common::{ExpandableInfo, ScholarshipFormParams}; -use crate::components::{ - ActionButton, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, -}; +use leptos::either::Either; +use leptos::html::Dialog; use leptos::logging::log; use leptos::prelude::*; use leptos_oidc::{AuthLoaded, Authenticated}; -use leptos_router::hooks::use_params; +use leptos_router::hooks::{use_navigate, use_params}; +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, DeleteProviderScholarship}; + /// # Scholarship Info Page -/// This page will handle creating or editing scholarship information given a specific +/// This page will handle creating or editing scholarship information given a specific /// 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"); + // Changes to this signal update the scholarship list. + let list_refresh = RwSignal::new(0); + let url_params = use_params::<ScholarshipFormParams>(); - let scholarship_id = move || { - url_params - .read() + 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(); - + + 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! { + <Banner + title="R15 Scholarships" + logo="/PHS_Stacked_Acronym.png" + path="/" + /> <AuthLoaded fallback=Loading> <Authenticated unauthenticated=UnauthenticatedPage> - <Suspense fallback=Loading> - <Row> - <div class="flex flex-col flex-1" /> - {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" /> - </Row> - </Suspense> + <div class="flex flex-row align-top items-start"> + <div class="flex flex-col flex-1" /> + <ScholarshipList + refresh_token=list_refresh + on_delete=on_delete + /> + <Transition fallback=Loading> + <ScholarshipForm + scholarship_id=scholarship_id + on_submit=move || { + list_refresh.update(|n| *n += 1); + } + /> + </Transition> + <div class="flex flex-col flex-1" /> + </div> </Authenticated> </AuthLoaded> } } +/// # 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. +/// +/// Example usage: +/// ``` +/// let refresh = RwSignal::new(0); +/// +/// view! { +/// <AuthLoaded> +/// <Authenticated> +/// <ScholarshipList +/// refresh_token=refresh +/// on_delete=move || log!("This is run when an item is deleted on the server.") +/// /> +/// </Authenticated> +/// </AuthLoaded> +/// } +/// ``` #[component] -fn ScholarshipForm( - response: ExpandableInfo, - comparison_list: Vec<ComparisonData>, - submit_action: ServerAction<CreateScholarshipInfo>, +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 { - let reactive_info = response.as_reactive(); - let elements_disabled = RwSignal::new(false); - let result_msg = Signal::derive(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) - } - } + // 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(); + 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 || { + (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()); + }; + + // Get the scholarships + get_provider_scholarships(provider_id).await + } + ); + + // Register scholarship creation/deletion actions + let create_action = ServerAction::<RegisterScholarship>::new(); + let delete_action = ServerAction::<DeleteProviderScholarship>::new(); + + // Button handlers + let create_on_click = move |_| { + create_action.dispatch(RegisterScholarship { + provider_id: provider_id.get().unwrap(), + scholarship_name: "Testing Scholarship".to_string() + }); + }; + + let on_item_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. 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(); + on_delete.run(()); + } + }); + + // 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 { - "".to_string() + 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"> + <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() + on_delete=Callback::new(on_item_delete)/> + } + }); + 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-800 rounded-md text-center text-white hover:bg-red-900 transition-all" + on:click=create_on_click> + "Create New" + </div> + </div> + </Transition> + </Panel> + </div> + } +} + +/// # 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<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(); + 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. + let name = scholarship.data.get("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()); + } + }; + + 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-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-800 border-l border-white rounded-br-md text-white text-center + hover:bg-red-900 cursor-pointer transition-all" + on:click=move |_| on_delete.run(scholarship.clone()) + > + "Delete" + </div> + </div> + </div> + } +} + +/// # 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); + 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() { + submit_status.set(SubmitStatus::Sending); + return; + } + + if let Some(result) = submit_action.value().get() { + match result { + Ok(()) => { + submit_status.set(SubmitStatus::Success); + on_submit.run(()); + }, + Err(err) => submit_status.set(SubmitStatus::Error(err.to_string())) + } + } + }); + + let comparison_list: Resource<Vec<ComparisonData>> = Resource::new( + move || scholarship_id.get(), // 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::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.")) + } + } + ); + + 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) // award_night_remarks - + 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> + <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.clone() + }); + } + >"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> + </Show> </Panel> } } diff --git a/src/pages/utils.rs b/src/pages/utils.rs index ac3f5a1..03cf49e 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"]) + }) + }) + }) +}