Merge pull request #33 from R15-Software-Dev/feature/scholarship-edit-form
Implement provider scholarship edit form
This commit was merged in pull request #33.
This commit is contained in:
17 files changed
+714
-295
No files matched your search
+17
-16
@@ -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::leptos_dom::log;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
||||||
use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge};
|
use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge};
|
||||||
use leptos_router::{
|
use leptos_router::{components::{Route, Router, Routes, ParentRoute}, path};
|
||||||
components::{Route, Router, Routes},
|
|
||||||
path,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
@@ -47,8 +44,7 @@ pub fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn use_origin() -> String {
|
fn use_origin() -> String {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")] {
|
||||||
{
|
|
||||||
use leptos::web_sys;
|
use leptos::web_sys;
|
||||||
// Just get the current URL origin.
|
// Just get the current URL origin.
|
||||||
web_sys::window()
|
web_sys::window()
|
||||||
@@ -58,8 +54,7 @@ fn use_origin() -> String {
|
|||||||
.unwrap_or("http://localhost:3000/".to_string())
|
.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.
|
// Read the expected origin out of an environment variable.
|
||||||
std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string())
|
std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string())
|
||||||
}
|
}
|
||||||
@@ -87,7 +82,7 @@ pub fn AppWithRoutes() -> impl IntoView {
|
|||||||
|
|
||||||
let auth = Auth::signal();
|
let auth = Auth::signal();
|
||||||
provide_context(auth); // allows use of this signal in lower areas of the tree without
|
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);
|
let _ = Auth::init(parameters);
|
||||||
|
|
||||||
@@ -106,12 +101,18 @@ pub fn AppWithRoutes() -> impl IntoView {
|
|||||||
// TODO Create a 404 page
|
// TODO Create a 404 page
|
||||||
<Routes fallback=|| "Page not found.".into_view()>
|
<Routes fallback=|| "Page not found.".into_view()>
|
||||||
<Route path=path!("") view=HomePage/>
|
<Route path=path!("") view=HomePage/>
|
||||||
<Route path=path!("about") view=AboutPage/>
|
<Route path=path!("/about") view=AboutPage/>
|
||||||
<Route path=path!("test_page") view=TestPage/>
|
<Route path=path!("/test_page") view=TestPage/>
|
||||||
<Route path=path!("comparison") view=ComparisonTestPage />
|
<Route path=path!("/comparison") view=ComparisonTestPage />
|
||||||
<Route path=path!("providers") view=ProviderPortal />
|
<Route path=path!("/providers") view=ProviderPortal/>
|
||||||
<Route path=path!("loaners") view=LoanerPage />
|
<ParentRoute path=path!("/providers/scholarships") view=ScholarshipInfoPage>
|
||||||
<Route path=path!("loaners/:form_name") view=LoanerPage />
|
<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>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use std::collections::HashMap;
|
|||||||
/// now, the table will simply create a single massive `Map` field that contains all the stored
|
/// 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,
|
/// 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.
|
/// 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 {
|
pub struct ExpandableInfo {
|
||||||
/// The main key member. Required and used to get/put the information in a database.
|
/// The main key member. Required and used to get/put the information in a database.
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
|
|||||||
+2
-3
@@ -4,7 +4,6 @@ mod form_params;
|
|||||||
mod form_schema;
|
mod form_schema;
|
||||||
mod user;
|
mod user;
|
||||||
mod value_type;
|
mod value_type;
|
||||||
|
mod submit_status;
|
||||||
|
|
||||||
pub use self::{
|
pub use self::{comparison::*, expandable_info::*, form_schema::*, user::*, value_type::*, form_params::*, submit_status::*};
|
||||||
comparison::*, expandable_info::*, form_params::*, form_schema::*, user::*, value_type::*,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SubmitStatus {
|
||||||
|
Idle,
|
||||||
|
Sending,
|
||||||
|
Success,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ use leptos::prelude::*;
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ActionButton(
|
pub fn ActionButton(
|
||||||
#[prop(optional, default = String::from("button"))] button_type: String,
|
#[prop(default = String::from("button"))] button_type: String,
|
||||||
#[prop(optional, default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
#[prop(optional, into)] disabled: Signal<bool>,
|
||||||
children: Children,
|
children: Children,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ pub fn Chip(
|
|||||||
pub fn ChipsList(
|
pub fn ChipsList(
|
||||||
#[prop(into)] data_member: String,
|
#[prop(into)] data_member: String,
|
||||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||||
#[prop()] displayed_text: Vec<String>,
|
#[prop(into)] displayed_text: Signal<Vec<String>>,
|
||||||
#[prop()] values: Vec<String>,
|
#[prop(into)] values: Signal<Vec<String>>,
|
||||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
||||||
#[prop(optional, into)] label: String,
|
#[prop(optional, into)] label: String,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
@@ -79,7 +79,7 @@ pub fn ChipsList(
|
|||||||
<div class="m-1.5 mt-0 mb-0">
|
<div class="m-1.5 mt-0 mb-0">
|
||||||
<span class="font-bold">{label}</span>
|
<span class="font-bold">{label}</span>
|
||||||
<div class="flex flex-row flex-wrap gap-2 mt-1">
|
<div class="flex flex-row flex-wrap gap-2 mt-1">
|
||||||
{displayed_text.into_iter().zip(values)
|
{move || { displayed_text.get().into_iter().zip(values.get())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(text, value)| {
|
.map(|(text, value)| {
|
||||||
let checked_signal = Signal::derive({
|
let checked_signal = Signal::derive({
|
||||||
@@ -140,7 +140,7 @@ pub fn ChipsList(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect_view()
|
.collect_view()
|
||||||
}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use leptos::prelude::*;
|
|||||||
pub fn Loading() -> impl IntoView {
|
pub fn Loading() -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div>
|
<div>
|
||||||
<img src="loading.gif" />
|
<img src="/loading.gif" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ pub fn Radio(
|
|||||||
#[prop(into)] on_change: Callback<(), ()>,
|
#[prop(into)] on_change: Callback<(), ()>,
|
||||||
#[prop(into)] value: String,
|
#[prop(into)] value: String,
|
||||||
#[prop(into)] name: String,
|
#[prop(into)] name: String,
|
||||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
#[prop(optional, into)] disabled: Signal<bool>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let id = create_unique_id(&name, &value);
|
let id = create_unique_id(&name, &value);
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ pub fn RadioList(
|
|||||||
#[prop(into)] data_member: String,
|
#[prop(into)] data_member: String,
|
||||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||||
#[prop()] items: Vec<String>,
|
#[prop()] items: Vec<String>,
|
||||||
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
|
#[prop(optional, into)] disabled: Signal<bool>,
|
||||||
#[prop(optional, into)] label: String,
|
#[prop(optional, into)] label: String,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub fn OutlinedTextField(
|
|||||||
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
|
||||||
#[prop(optional)] value: RwSignal<ValueType>,
|
#[prop(optional)] value: RwSignal<ValueType>,
|
||||||
#[prop(default = "text".to_owned(), into)] input_type: String,
|
#[prop(default = "text".to_owned(), into)] input_type: String,
|
||||||
#[prop(optional)] disabled: RwSignal<bool>,
|
#[prop(into, optional)] disabled: Signal<bool>,
|
||||||
#[prop(optional)] error: RwSignal<bool>,
|
#[prop(optional)] error: RwSignal<bool>,
|
||||||
#[prop(optional, into)] name: String,
|
#[prop(optional, into)] name: String,
|
||||||
#[prop(optional, into)] label: String,
|
#[prop(optional, into)] label: String,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
#[cfg(feature = "ssr")]
|
#[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::leptos_dom::log;
|
||||||
use leptos::prelude::ServerFnError;
|
use leptos::prelude::ServerFnError;
|
||||||
use leptos::server;
|
use leptos::server;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use crate::common::{ExpandableInfo, ValueType};
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use crate::pages::utils::server_utils::create_dynamo_client;
|
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);
|
log!("Found output from API: {:?}", item);
|
||||||
Ok(serde_dynamo::from_item(item)?)
|
Ok(serde_dynamo::from_item(item)?)
|
||||||
} else {
|
} else {
|
||||||
log!("Couldn't find any values, returning default struct.");
|
log!("Couldn't find scholarship with ID {:?}", id);
|
||||||
Ok(ExpandableInfo::new(id.clone()))
|
Err(ServerFnError::new("Couldn't find scholarship with given ID."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -46,10 +50,7 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
|
|||||||
|
|
||||||
let client = create_dynamo_client().await;
|
let client = create_dynamo_client().await;
|
||||||
|
|
||||||
log!(
|
log!("Creating or updating scholarship with ID {:?}", info.subject);
|
||||||
"Creating or updating scholarship with ID {:?}",
|
|
||||||
info.subject
|
|
||||||
);
|
|
||||||
|
|
||||||
match client
|
match client
|
||||||
.put_item()
|
.put_item()
|
||||||
@@ -75,6 +76,35 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
|
|||||||
.table_name("leptos-scholarship-test")
|
.table_name("leptos-scholarship-test")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.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) => {
|
Ok(output) => {
|
||||||
if let Some(items) = output.items {
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,21 @@
|
|||||||
use super::UnauthenticatedPage;
|
use super::UnauthenticatedPage;
|
||||||
use super::api::{get_all_scholarship_info, get_comparison_info};
|
|
||||||
use super::home_page::get_submission;
|
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::components::{ActionButton, Loading, Panel, Row};
|
||||||
use leptos::either::Either;
|
use leptos::either::Either;
|
||||||
use leptos::leptos_dom::logging::console_log;
|
use leptos::leptos_dom::logging::console_log;
|
||||||
use leptos::logging::log;
|
use leptos::logging::log;
|
||||||
use leptos::prelude::*;
|
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]
|
#[component]
|
||||||
pub fn ComparisonTestPage() -> impl IntoView {
|
pub fn ComparisonTestPage() -> impl IntoView {
|
||||||
let result_msg = RwSignal::new("Found submission, waiting for input.".to_string());
|
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 = get_user_claims();
|
||||||
let user_claims = Signal::derive(move || {
|
|
||||||
auth.with(|auth| {
|
|
||||||
auth.authenticated().and_then(|data| {
|
|
||||||
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note that the value passed in MUST be equatable.
|
// 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
|
// We get/unwrap the value repeatedly until we get a simple string value, then clone it so that
|
||||||
|
|||||||
+8
-15
@@ -5,25 +5,26 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use serde_dynamo::{from_item, to_item};
|
use serde_dynamo::{from_item, to_item};
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use std::io::Write;
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
use crate::common::{ExpandableInfo, UserClaims};
|
use crate::common::{ExpandableInfo, UserClaims};
|
||||||
|
use crate::pages::UnauthenticatedPage;
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
|
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
|
||||||
Select,
|
Select,
|
||||||
};
|
};
|
||||||
use crate::input;
|
use crate::input;
|
||||||
use crate::pages::UnauthenticatedPage;
|
|
||||||
use base64::Engine;
|
|
||||||
use leptos::leptos_dom::logging::console_log;
|
use leptos::leptos_dom::logging::console_log;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||||
|
use base64::Engine;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos::wasm_bindgen::JsCast;
|
|
||||||
use leptos::web_sys::HtmlAnchorElement;
|
use leptos::web_sys::HtmlAnchorElement;
|
||||||
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
|
use leptos::wasm_bindgen::JsCast;
|
||||||
use traits::{AsReactive, ReactiveCapture};
|
use traits::{AsReactive, ReactiveCapture};
|
||||||
|
use crate::pages::utils::get_user_claims;
|
||||||
|
|
||||||
/// # Get Student Info
|
/// # Get Student Info
|
||||||
/// Gets a student's information given their `subject`.
|
/// Gets a student's information given their `subject`.
|
||||||
@@ -144,15 +145,7 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> {
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn HomePage() -> impl IntoView {
|
pub fn HomePage() -> impl IntoView {
|
||||||
// Creates a reactive value to update the button
|
// Creates a reactive value to update the button
|
||||||
|
let user_claims = get_user_claims();
|
||||||
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"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note that the value passed in MUST be equatable.
|
// 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
|
// We get/unwrap the value repeatedly until we get a simple string value, then clone it so that
|
||||||
|
|||||||
@@ -327,6 +327,8 @@ pub fn LoanerBorrowForm() -> impl IntoView {
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LoanerReturnForm() -> impl IntoView {
|
pub fn LoanerReturnForm() -> impl IntoView {
|
||||||
|
let navigate = use_navigate();
|
||||||
|
|
||||||
// Register server actions
|
// Register server actions
|
||||||
let check_in_action = ServerAction::<CheckInLoaner>::new();
|
let check_in_action = ServerAction::<CheckInLoaner>::new();
|
||||||
|
|
||||||
@@ -344,26 +346,18 @@ pub fn LoanerReturnForm() -> impl IntoView {
|
|||||||
|
|
||||||
let dialog_ref = NodeRef::<Dialog>::new();
|
let dialog_ref = NodeRef::<Dialog>::new();
|
||||||
let chosen_student = RwSignal::new(None::<LoanerReturnOutput>);
|
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.
|
// 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)
|
// This could be beneficial if we ever decided to make multiple loaner kiosks (think SAT days)
|
||||||
Effect::new(move || {
|
Effect::new(move || {
|
||||||
if let Some(Ok(_)) = check_in_action.value().get() {
|
if let Some(Ok(_)) = check_in_action.value().get() {
|
||||||
// Check the local loaner list and remove the indicated student from it.
|
// Return to the main page.
|
||||||
return_list_refresh.update(|n| *n += 1);
|
|
||||||
if let Some(dialog) = dialog_ref.get() {
|
if let Some(dialog) = dialog_ref.get() {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
}
|
navigate("/loaners", Default::default());
|
||||||
});
|
check_in_action.clear();
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -10,6 +10,6 @@ mod unauthenticated_page;
|
|||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use self::{
|
pub use self::{
|
||||||
about_page::*, comparison_test::*, home_page::*, loaner_page::LoanerPage, provider_portal::*,
|
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
|
||||||
test_page::*, unauthenticated_page::*,
|
test_page::*, unauthenticated_page::*, scholarship_info::*
|
||||||
};
|
};
|
||||||
@@ -23,7 +23,7 @@ pub fn ProviderPortal() -> impl IntoView {
|
|||||||
title="Create Scholarship"
|
title="Create Scholarship"
|
||||||
description="Navigate to Scholarship Creation Page"
|
description="Navigate to Scholarship Creation Page"
|
||||||
icon="/Create_Black.png"
|
icon="/Create_Black.png"
|
||||||
path="/"
|
path="/providers/scholarships"
|
||||||
/>
|
/>
|
||||||
<DashboardButton
|
<DashboardButton
|
||||||
title="Applicants"
|
title="Applicants"
|
||||||
|
|||||||
+525
-216
@@ -1,261 +1,570 @@
|
|||||||
use super::UnauthenticatedPage;
|
use leptos::either::Either;
|
||||||
use super::api::{
|
use leptos::html::Dialog;
|
||||||
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::logging::log;
|
use leptos::logging::log;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
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 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
|
/// # 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
|
/// 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
|
/// track a single scholarship (for now) and so selection of what scholarship will be edited
|
||||||
/// must be handled *before* navigation to this page.
|
/// 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]
|
#[component]
|
||||||
pub fn ScholarshipInfoPage() -> impl IntoView {
|
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 url_params = use_params::<ScholarshipFormParams>();
|
||||||
let scholarship_id = move || {
|
let scholarship_id = Memo::new(move |_| {
|
||||||
url_params
|
url_params.read()
|
||||||
.read()
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|params| params.id.clone())
|
.and_then(|params| params.id.clone())
|
||||||
.unwrap_or_default()
|
});
|
||||||
};
|
|
||||||
|
|
||||||
let get_scholarship_action: Resource<ExpandableInfo> = Resource::new(
|
|
||||||
move || scholarship_id().clone(),
|
|
||||||
async |id| {
|
|
||||||
get_scholarship_info(id.clone())
|
|
||||||
.await
|
|
||||||
// Note that this really should notify us of an error and then redirect or ask
|
|
||||||
// the user to try again.
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
log!("Failed to get scholarship info: {:?}", e);
|
|
||||||
log!("Using default ExpandableInfo");
|
|
||||||
ExpandableInfo::new(id)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let get_comparison_info: Resource<Vec<ComparisonData>> = Resource::new(
|
|
||||||
move || Option::<String>::None, // There's no input to this function.
|
|
||||||
async |_| {
|
|
||||||
get_comparison_info().await.unwrap_or_else(|e| {
|
|
||||||
log!("Failed to get comparison info: {:?}", e);
|
|
||||||
Vec::new()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let create_scholarship_action = ServerAction::<CreateScholarshipInfo>::new();
|
|
||||||
let create_test_comps = ServerAction::<CreateTestComparisons>::new();
|
let create_test_comps = ServerAction::<CreateTestComparisons>::new();
|
||||||
|
|
||||||
|
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
|
// Remember that we only want to show the page after we've got authentication information
|
||||||
// and if there's data for us to use.
|
// and if there's data for us to use.
|
||||||
view! {
|
view! {
|
||||||
|
<Banner
|
||||||
|
title="R15 Scholarships"
|
||||||
|
logo="/PHS_Stacked_Acronym.png"
|
||||||
|
path="/"
|
||||||
|
/>
|
||||||
<AuthLoaded fallback=Loading>
|
<AuthLoaded fallback=Loading>
|
||||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||||
<Suspense fallback=Loading>
|
<div class="flex flex-row align-top items-start">
|
||||||
<Row>
|
<div class="flex flex-col flex-1" />
|
||||||
<div class="flex flex-col flex-1" />
|
<ScholarshipList
|
||||||
{move || Suspend::new(async move {
|
refresh_token=list_refresh
|
||||||
let response = get_scholarship_action.get();
|
on_delete=on_delete
|
||||||
let comparisons = get_comparison_info.get();
|
/>
|
||||||
|
<Transition fallback=Loading>
|
||||||
response
|
<ScholarshipForm
|
||||||
.zip(comparisons)
|
scholarship_id=scholarship_id
|
||||||
.map(|(response, comparison_list)| view! {
|
on_submit=move || {
|
||||||
<ScholarshipForm
|
list_refresh.update(|n| *n += 1);
|
||||||
response=response
|
}
|
||||||
comparison_list=comparison_list
|
/>
|
||||||
submit_action=create_scholarship_action
|
</Transition>
|
||||||
/>
|
<div class="flex flex-col flex-1" />
|
||||||
})
|
</div>
|
||||||
.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>
|
|
||||||
</Authenticated>
|
</Authenticated>
|
||||||
</AuthLoaded>
|
</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]
|
#[component]
|
||||||
fn ScholarshipForm(
|
fn ScholarshipList(
|
||||||
response: ExpandableInfo,
|
/// A signal that refreshes the list when changed. Most times, we don't need to worry about the
|
||||||
comparison_list: Vec<ComparisonData>,
|
/// user reloading our page 2 billion times.
|
||||||
submit_action: ServerAction<CreateScholarshipInfo>,
|
#[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 {
|
) -> impl IntoView {
|
||||||
let reactive_info = response.as_reactive();
|
// List state
|
||||||
let elements_disabled = RwSignal::new(false);
|
let pending_delete = RwSignal::new(None::<ExpandableInfo>);
|
||||||
let result_msg = Signal::derive(move || {
|
let delete_dialog_ref = NodeRef::<Dialog>::new();
|
||||||
if submit_action.pending().get() {
|
|
||||||
elements_disabled.set(true);
|
// Get scholarship provider information from authentication. This element will not display
|
||||||
"Sending request...".to_string()
|
// unless that information is available.
|
||||||
} else {
|
let user_claims = get_user_claims();
|
||||||
elements_disabled.set(false);
|
let provider_id = Memo::new(move |_|
|
||||||
if let Some(result) = submit_action.value().get() {
|
user_claims.get()
|
||||||
match result {
|
.as_ref()
|
||||||
Ok(()) => "Request sent successfully".to_string(),
|
.map(|info| info.claims.subject.clone())
|
||||||
Err(err) => {
|
);
|
||||||
format!("An error occurred: {:?}", err)
|
|
||||||
}
|
// 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 {
|
} 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,
|
// We'll collect the scholarship's name, num_awards, amount_per_award, total_awards,
|
||||||
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt)
|
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt)
|
||||||
// award_night_remarks
|
// award_night_remarks
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Panel>
|
<Panel>
|
||||||
<Row>
|
<Show
|
||||||
<h1 class="text-3xl font-bold">
|
when=move || scholarship_id.get().is_some()
|
||||||
"Scholarship Info Form (test)"
|
fallback=|| view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
|
||||||
</h1>
|
>
|
||||||
</Row>
|
<Suspense fallback=Loading>
|
||||||
<Row>
|
{move || {
|
||||||
<OutlinedTextField
|
scholarship_info.get()
|
||||||
label="Scholarship Name"
|
.map(|res_scholarship| {
|
||||||
placeholder="Example Scholarship Name"
|
match res_scholarship {
|
||||||
disabled=elements_disabled
|
Ok(scholarship) => {
|
||||||
data_member="name"
|
let reactive_info = scholarship.as_reactive();
|
||||||
data_map=reactive_info.data
|
|
||||||
/>
|
Either::Left(view! {
|
||||||
</Row>
|
<Row>
|
||||||
<Row>
|
<h1 class="text-3xl font-bold">
|
||||||
<OutlinedTextField
|
"Scholarship Info Form (test)"
|
||||||
label="Amount per award: (if multiple, enter the highest value)"
|
</h1>
|
||||||
placeholder="Enter an amount..."
|
</Row>
|
||||||
disabled=elements_disabled
|
<Row>
|
||||||
data_member="amount_per_award"
|
<OutlinedTextField
|
||||||
data_map=reactive_info.data
|
label="Scholarship Name"
|
||||||
input_type="number"
|
placeholder="Example Scholarship Name"
|
||||||
/>
|
disabled=elements_disabled
|
||||||
</Row>
|
data_member="name"
|
||||||
<Row>
|
data_map=reactive_info.data
|
||||||
<OutlinedTextField
|
/>
|
||||||
label="Total number of awards:"
|
</Row>
|
||||||
placeholder="Enter an amount..."
|
<Row>
|
||||||
disabled=elements_disabled
|
<OutlinedTextField
|
||||||
data_member="num_awards"
|
label="Amount per award: (if multiple, enter the highest value)"
|
||||||
data_map=reactive_info.data
|
placeholder="Enter an amount..."
|
||||||
input_type="number"
|
disabled=elements_disabled
|
||||||
/>
|
data_member="amount_per_award"
|
||||||
</Row>
|
data_map=reactive_info.data
|
||||||
<Row>
|
input_type="number"
|
||||||
<OutlinedTextField
|
/>
|
||||||
label="Total amount of all awards:"
|
</Row>
|
||||||
placeholder="Enter an amount..."
|
<Row>
|
||||||
disabled=elements_disabled
|
<OutlinedTextField
|
||||||
data_member="total_awards"
|
label="Total number of awards:"
|
||||||
data_map=reactive_info.data
|
placeholder="Enter an amount..."
|
||||||
input_type="number"
|
disabled=elements_disabled
|
||||||
/>
|
data_member="num_awards"
|
||||||
</Row>
|
data_map=reactive_info.data
|
||||||
<Row>
|
input_type="number"
|
||||||
<RadioList
|
/>
|
||||||
data_member="fafsa_required"
|
</Row>
|
||||||
data_map=reactive_info.data
|
<Row>
|
||||||
items=vec!["Yes".to_string(), "No".to_string()]
|
<OutlinedTextField
|
||||||
disabled=elements_disabled
|
label="Total amount of all awards:"
|
||||||
label="Do you require student financial information?"
|
placeholder="Enter an amount..."
|
||||||
/>
|
disabled=elements_disabled
|
||||||
</Row>
|
data_member="total_awards"
|
||||||
<Row>
|
data_map=reactive_info.data
|
||||||
<RadioList
|
input_type="number"
|
||||||
data_member="transcript_required"
|
/>
|
||||||
data_map=reactive_info.data
|
</Row>
|
||||||
items=vec!["Yes".to_string(), "No".to_string()]
|
<Row>
|
||||||
disabled=elements_disabled
|
<RadioList
|
||||||
label="Do you require a student transcript?"
|
data_member="fafsa_required"
|
||||||
/>
|
data_map=reactive_info.data
|
||||||
</Row>
|
items=vec!["Yes".to_string(), "No".to_string()]
|
||||||
<Row>
|
disabled=elements_disabled
|
||||||
<RadioList
|
label="Do you require student financial information?"
|
||||||
data_member="award_to"
|
/>
|
||||||
data_map=reactive_info.data
|
</Row>
|
||||||
items=vec!["School".to_string(), "Student".to_string()]
|
<Row>
|
||||||
disabled=elements_disabled
|
<RadioList
|
||||||
label="Will the award be made to the school or the student?"
|
data_member="transcript_required"
|
||||||
/>
|
data_map=reactive_info.data
|
||||||
</Row>
|
items=vec!["Yes".to_string(), "No".to_string()]
|
||||||
<Row>
|
disabled=elements_disabled
|
||||||
<RadioList
|
label="Do you require a student transcript?"
|
||||||
data_member="essay_required"
|
/>
|
||||||
data_map=reactive_info.data
|
</Row>
|
||||||
items=vec!["Yes".to_string(), "No".to_string()]
|
<Row>
|
||||||
disabled=elements_disabled
|
<RadioList
|
||||||
label="Do you require a student essay?"
|
data_member="award_to"
|
||||||
/>
|
data_map=reactive_info.data
|
||||||
</Row>
|
items=vec!["School".to_string(), "Student".to_string()]
|
||||||
<Row>
|
disabled=elements_disabled
|
||||||
<RadioList
|
label="Will the award be made to the school or the student?"
|
||||||
data_member="essay_prompt"
|
/>
|
||||||
data_map=reactive_info.data
|
</Row>
|
||||||
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect()
|
<Row>
|
||||||
disabled=elements_disabled
|
<RadioList
|
||||||
label="If so, select a prompt from the list below."
|
data_member="essay_required"
|
||||||
/>
|
data_map=reactive_info.data
|
||||||
</Row>
|
items=vec!["Yes".to_string(), "No".to_string()]
|
||||||
<Row>
|
disabled=elements_disabled
|
||||||
<ChipsList
|
label="Do you require a student essay?"
|
||||||
label="Scholarship Requirements"
|
/>
|
||||||
data_member="requirements"
|
</Row>
|
||||||
data_map=reactive_info.data
|
<Row>
|
||||||
values=comparison_list
|
<RadioList
|
||||||
.iter()
|
data_member="essay_prompt"
|
||||||
.cloned()
|
data_map=reactive_info.data
|
||||||
.map(|comp| comp.id)
|
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect()
|
||||||
.collect()
|
disabled=elements_disabled
|
||||||
displayed_text=comparison_list
|
label="If so, select a prompt from the list below."
|
||||||
.iter()
|
/>
|
||||||
.cloned()
|
</Row>
|
||||||
.map(|comp| comp.display_text)
|
<Row>
|
||||||
.collect()
|
<ChipsList
|
||||||
/>
|
label="Scholarship Requirements"
|
||||||
</Row>
|
data_member="requirements"
|
||||||
<Row>
|
data_map=reactive_info.data
|
||||||
<ActionButton
|
values=comparison_ids
|
||||||
disabled=elements_disabled
|
displayed_text=comparison_text
|
||||||
on:click=move |_| {
|
/>
|
||||||
let captured = reactive_info.capture();
|
</Row>
|
||||||
log!("Map values: {:?}", captured);
|
<Row>
|
||||||
submit_action.dispatch(CreateScholarshipInfo {
|
<ActionButton
|
||||||
info: captured
|
disabled=elements_disabled
|
||||||
});
|
on:click=move |_| {
|
||||||
}
|
let captured = reactive_info.capture();
|
||||||
>"Submit"</ActionButton>
|
log!("Map values: {:?}", captured);
|
||||||
</Row>
|
submit_action.dispatch(CreateScholarshipInfo {
|
||||||
<Row>
|
info: captured.clone()
|
||||||
<p>{result_msg}</p>
|
});
|
||||||
</Row>
|
}
|
||||||
|
>"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>
|
</Panel>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
use leptos::prelude::{use_context, Signal, With};
|
||||||
|
use leptos_oidc::{Algorithm, AuthSignal, TokenData};
|
||||||
|
use crate::common::UserClaims;
|
||||||
|
|
||||||
/// # Server Utilities
|
/// # Server Utilities
|
||||||
///
|
///
|
||||||
/// These utility functions can only be used from the server. They are created to use server-side
|
/// 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)
|
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"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user