diff --git a/.github/workflows/pull-test-build.yml b/.github/workflows/pull-test-build.yml new file mode 100644 index 0000000..f4b1d40 --- /dev/null +++ b/.github/workflows/pull-test-build.yml @@ -0,0 +1,19 @@ +name: Docker build test + +on: + pull_request: + branches: + - "main" + - "develop" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Build image + run: | + docker build . \ No newline at end of file diff --git a/.github/workflows/release-build-runner.yml b/.github/workflows/release-build-runner.yml new file mode 100644 index 0000000..29636fe --- /dev/null +++ b/.github/workflows/release-build-runner.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push runner image + uses: docker/build-push-action@v6 + with: + push: true + tags: | + ghcr.io/r15-software-dev/scholarships-runner:latest + ghcr.io/r15-software-dev/scholarships-runner:${{ github.ref_name }} diff --git a/docker-compose.yml b/docker-compose.yml index cc725fa..d4fb058 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,5 @@ services: scholarships-rs: - build: . - image: scholarships-rs-runner:latest + image: ghcr.io/r15-software-dev/scholarships-runner:latest ports: - - '8080:8080' + - '3000:8080' diff --git a/src/app.rs b/src/app.rs index 032a1e5..785afb5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,10 +1,13 @@ -use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, ProviderPortal, TestPage}; -use leptos::logging::log; +use crate::pages::{ + AboutPage, ComparisonTestPage, HomePage, LoanerPage, ProviderPortal, 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}, + components::{ParentRoute, Route, Router, Routes}, path, }; @@ -106,12 +109,18 @@ pub fn AppWithRoutes() -> impl IntoView { // TODO Create a 404 page - - - - - - + + + + + + + + + + + + } diff --git a/src/common/mod.rs b/src/common/mod.rs index ede36ba..86f5eb2 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -3,10 +3,11 @@ mod date_info; mod expandable_info; mod form_params; mod form_schema; +mod submit_status; mod user; mod value_type; pub use self::{ - comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*, user::*, - value_type::*, + comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*, + submit_status::*, user::*, value_type::*, }; 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/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! {
- +
} } 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 4ec5301..97c391c 100644 --- a/src/pages/api/scholarships.rs +++ b/src/pages/api/scholarships.rs @@ -1,10 +1,11 @@ #[cfg(feature = "ssr")] use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue}; -use crate::common::ExpandableInfo; +use crate::common::{ExpandableInfo, ValueType}; use leptos::leptos_dom::log; use leptos::prelude::ServerFnError; use leptos::server; +use uuid::Uuid; #[cfg(feature = "ssr")] use crate::pages::utils::server_utils::create_dynamo_client; @@ -28,8 +29,10 @@ pub async fn get_scholarship_info(id: String) -> Result { @@ -89,3 +92,135 @@ pub async fn get_all_scholarship_info() -> Result, 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, 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 { + 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..d01f03e 100644 --- a/src/pages/comparison_test.rs +++ b/src/pages/comparison_test.rs @@ -1,25 +1,19 @@ 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 crate::pages::utils::get_user_claims; 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}; #[component] pub fn ComparisonTestPage() -> impl IntoView { let result_msg = RwSignal::new("Found submission, waiting for input.".to_string()); - let auth = use_context::().expect("Couldn't find user information."); - let user_claims = Signal::derive(move || { - auth.with(|auth| { - auth.authenticated().and_then(|data| { - data.decoded_access_token::(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..42c2b78 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -16,13 +16,14 @@ use crate::components::{ }; use crate::input; use crate::pages::UnauthenticatedPage; +use crate::pages::utils::get_user_claims; use base64::Engine; use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; use leptos::task::spawn_local; use leptos::wasm_bindgen::JsCast; use leptos::web_sys::HtmlAnchorElement; -use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; +use leptos_oidc::{AuthLoaded, Authenticated}; use traits::{AsReactive, ReactiveCapture}; /// # Get Student Info @@ -144,15 +145,7 @@ pub async fn create_example_pdf() -> Result, ServerFnError> { #[component] pub fn HomePage() -> impl IntoView { // Creates a reactive value to update the button - - let auth = use_context::().expect("Couldn't find user information."); - let user_claims = Signal::derive(move || { - auth.with(|auth| { - auth.authenticated().and_then(|data| { - data.decoded_access_token::(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..f7d2f3a 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::::new(); @@ -344,26 +346,18 @@ pub fn LoanerReturnForm() -> impl IntoView { let dialog_ref = NodeRef::::new(); let chosen_student = RwSignal::new(None::); - 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..04b49e6 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::*, + scholarship_info::*, test_page::*, unauthenticated_page::*, }; 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" /> 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::(); - 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 create_test_comps = ServerAction::::new(); + + let on_delete = move || { + let navigate = use_navigate(); + navigate("/providers/scholarships", Default::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) - }) + // 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! { + + + +
+
+ + + + +
+
+ + + } +} + +/// # 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! { +/// +/// +/// +/// +/// +/// } +/// ``` +#[component] +fn ScholarshipList( + /// A signal that refreshes the list when changed. Most times, we don't need to worry about the + /// user reloading our page 2 billion times. + #[prop()] + refresh_token: RwSignal, + /// A callback that is run on successful server-side deletion of an item. + #[prop(into)] + on_delete: Callback<()>, +) -> impl IntoView { + // List state + let pending_delete = RwSignal::new(None::); + let delete_dialog_ref = NodeRef::::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 }, ); - let get_comparison_info: Resource> = Resource::new( - move || Option::::None, // There's no input to this function. - async |_| { + // Register scholarship creation/deletion actions + let create_action = ServerAction::::new(); + let delete_action = ServerAction::::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 { + dialog.close(); + } + } + }); + + view! { + +

"Confirm Deletion"

+

+ "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("".to_string()) + }) + }} + "?" +

+ "Confirm" + "Cancel" +
+
+ +
+

"Scholarships"

+
+ +
+ {move || { + scholarships.get().map(|result| { + match result { + Ok(list) => { + let views = list.iter().map(|entry| { + view! { + + } + }); + Either::Left(views.collect_view()) + } + Err(err) => { + Either::Right(view! { +
+

"Failed to load scholarship list: "{err.to_string()}

+
+ }) + } + } + }).collect_view() + }} +
+
+
+ "Create New" +
+
+
+
+
+ } +} + +/// # Scholarship List Entry Component +/// +/// Displays a single scholarship within the [`ScholarshipList`] component. +/// +/// Example usage (within the [`ScholarshipList`]): +/// ``` +/// view! { +/// +/// } +/// ``` +#[component] +fn ScholarshipListEntry( + /// The scholarship information. This element will attempt to get the name of the scholarship + /// using its `name` field, and also utilizes the `subject` field. + #[prop()] + scholarship: ExpandableInfo, + /// A [`Callback`] that runs upon successful deletion of the scholarship. + #[prop(into)] + on_delete: Callback, +) -> impl IntoView { + let navigate = use_navigate(); + let user_claims = get_user_claims(); + let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone())); + + // Get the values that we need out of the info. We just need the subject and the name of + // the scholarship. + let name = scholarship + .data + .get("name") + .unwrap_or(&ValueType::String(None)) + .as_string() + .unwrap_or_default() + .unwrap_or("".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! { +
+
+ {name} +
+
+
+ "Edit" +
+
+ "Delete" +
+
+
+ } +} + +/// # 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! { +/// +/// } +/// ``` +#[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>, + /// 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::::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> = 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() @@ -59,74 +402,37 @@ pub fn ScholarshipInfoPage() -> impl IntoView { }, ); - 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 - // and if there's data for us to use. - view! { - - - - -
- {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() - })} - -
- - - - - } -} - -#[component] -fn ScholarshipForm( - response: ExpandableInfo, - comparison_list: Vec, - submit_action: ServerAction, -) -> 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) - } - } + 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 { - "".to_string() + 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::>() + }) + .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, @@ -135,127 +441,144 @@ fn ScholarshipForm( view! { - -

- "Scholarship Info Form (test)" -

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

{result_msg}

-
+ "Choose a scholarship from the right, or create a new one."

} + > + + {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() + }} +
+
} } diff --git a/src/pages/utils.rs b/src/pages/utils.rs index ac3f5a1..165bb04 100644 --- a/src/pages/utils.rs +++ b/src/pages/utils.rs @@ -1,3 +1,7 @@ +use crate::common::UserClaims; +use leptos::prelude::{Signal, With, use_context}; +use leptos_oidc::{Algorithm, AuthSignal, TokenData}; + /// # 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>> { + let auth = use_context::().expect("Couldn't find AuthSignal."); + Signal::derive(move || { + auth.with(|auth| { + auth.authenticated().and_then(|data| { + data.decoded_access_token::(Algorithm::RS256, &["account"]) + }) + }) + }) +}