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"]) + }) + }) + }) +}