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()
+ }}
+
+
+
+
+
+ }
+}
+
+/// # 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()
- })}
-
-
- "Create test comparisons"
-
-
-
-
-
-
-
- }
-}
-
-#[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"])
+ })
+ })
+ })
+}