Merge branch 'main' into feat/date-component

# Conflicts:
#	src/app.rs
#	src/common/mod.rs
#	src/components/mod.rs
#	src/pages/api/scholarships.rs
#	src/pages/home_page.rs
#	src/pages/mod.rs
#	src/pages/scholarship_info.rs
This commit is contained in:
alphafetus committed 2026-01-14 11:24:27 -05:00
commit 26ee1cf7b4
50 files changed
+3428 -2237

No files matched your search

+151 -30
View File
@@ -1,19 +1,25 @@
use std::collections::HashMap;
#[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client;
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
#[cfg(feature = "ssr")]
use leptos::logging::log;
use crate::common::{
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
TextComparison, TextListComparison, ValueType,
};
use leptos::leptos_dom::log;
use leptos::prelude::ServerFnError;
use leptos::server;
static COMPARISONS_TABLE: &str = "leptos-comparison-test";
/// Helper function to create all sports comparisons. Only used to create initial
/// comparison data, and should be removed upon completion of a comparison editor page.
#[allow(unused)]
fn create_sports_comparisons() -> Vec<ComparisonData> {
vec![
ComparisonData::new(
@@ -184,11 +190,74 @@ fn create_sports_comparisons() -> Vec<ComparisonData> {
]
}
#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")]
pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
let client = create_dynamo_client().await;
fn create_major_comparison(
id: impl Into<String>,
display_text: impl Into<String>,
) -> ComparisonData {
let display = display_text.into();
ComparisonData::new(
id,
"major",
ComparisonType::Text(TextComparison::Contains),
ValueType::String(Some(display.clone())),
"Majors",
display,
)
}
log!("Creating test comparisons");
fn create_major_comparisons() -> Vec<ComparisonData> {
let display_texts = vec![
"Music",
"Education",
"Special Education",
"Speech Pathology",
"School Psychology",
"School Counseling",
"Occupational Therapy",
"Physical Therapy",
"Nursing",
"Allied Health",
"Fine/Performing Arts",
"Writing/Communication",
"History",
"Government",
"Political Science",
"Social Work",
"Sports Medicine",
"Athletic Training",
"Horticulture",
"Conservation Studies",
"Ecology",
"Environmental Studies",
"Urban Planning",
"Landscaping",
"Legal Studies",
"Criminal Justice",
];
let ids = display_texts
.iter()
.map(|text|
format!("{}_{}", "major", text.to_lowercase().replace(" ", "_").replace("/", "_"))
)
.collect::<Vec<String>>();
ids.iter().zip(display_texts).map(|(id, display_text)| {
create_major_comparison(id, display_text)
})
.collect::<Vec<ComparisonData>>()
}
#[allow(unused)]
fn make_comp_list() -> Vec<ComparisonData> {
let gpa_3 = ComparisonData::new(
"gpa_3",
"weighted_gpa",
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
ValueType::Number(Some(3.0.to_string())),
"GPA Limits",
"GPA >= 3.0",
);
let test_comp = ComparisonData::new(
"math_sat_comp",
@@ -198,21 +267,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
"Academic Information",
"Math SAT Score > 650",
);
let service_hours_25 = ComparisonData::new(
"map_sum_comp",
"community_involvement",
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
"service_hours".to_string(),
Box::new(NumberListComparison::Sum(Box::new(
NumberComparison::GreaterThanOrEqual,
))),
)),
ValueType::Number(Some(25.to_string())),
"Community Involvement",
"25 service hours",
);
let service_hours_20 = ComparisonData::new(
"service_hours_20",
"community_involvement",
@@ -223,8 +278,22 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
))),
)),
ValueType::Number(Some(20.to_string())),
"Community Involvement",
"20 service hours",
"Community Service",
"20+ service hours",
);
let service_hours_25 = ComparisonData::new(
"service_hours_25",
"community_involvement",
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
"service_hours".to_string(),
Box::new(NumberListComparison::Sum(Box::new(
NumberComparison::GreaterThanOrEqual,
))),
)),
ValueType::Number(Some(25.to_string())),
"Community Service",
"25+ service hours",
);
let service_hours_30 = ComparisonData::new(
@@ -237,8 +306,8 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
))),
)),
ValueType::Number(Some(30.to_string())),
"Community Involvement",
"30 service hours",
"Community Service",
"30+ service hours",
);
let residency_southbury = ComparisonData::new(
@@ -264,7 +333,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
"attended_bas",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Miscellaneous",
"Additional Eligibility Factors",
"Attended BAS",
);
@@ -273,7 +342,7 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
"middsouth_church",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Miscellaneous",
"Additional Eligibility Factors",
"Member of Midd-South Church",
);
@@ -282,14 +351,15 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
"family_military_service",
ComparisonType::Text(TextComparison::Matches),
ValueType::String(Some("Yes".to_string())),
"Miscellaneous",
"Additional Eligibility Factors",
"Family with Military Service",
);
let mut sports_comps = create_sports_comparisons();
let mut major_comps = Vec::<ComparisonData>::new();
let mut major_comps = create_major_comparisons();
let mut comp_list = vec![
gpa_3,
test_comp,
service_hours_20,
service_hours_25,
@@ -304,10 +374,21 @@ pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
comp_list.append(&mut sports_comps);
comp_list.append(&mut major_comps);
comp_list
}
#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")]
pub async fn create_test_comparisons() -> Result<(), ServerFnError> {
let client = create_dynamo_client().await;
log!("Creating test comparisons");
let comp_list = make_comp_list();
for comparison in comp_list {
if let Err(err) = client
.put_item()
.table_name("leptos-comparison-test")
.table_name(COMPARISONS_TABLE)
.set_item(Some(serde_dynamo::to_item(&comparison)?))
.send()
.await
@@ -330,7 +411,7 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
// id and display text, but we'll return the whole thing.
match client
.scan()
.table_name("leptos-comparison-test")
.table_name(COMPARISONS_TABLE)
.send()
.await
{
@@ -348,3 +429,43 @@ pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError>
}
}
}
#[server]
pub async fn get_comparisons_categorized() -> Result<HashMap<String, Vec<ComparisonData>>, ServerFnError> {
let client = create_dynamo_client().await;
log!("Getting all comparisons from the database, by category.");
match client
.scan()
.table_name(COMPARISONS_TABLE)
.send()
.await
{
Ok(output) => {
if let Some(items) = output.items {
log!("Found comparisons, categorizing.");
let items: Vec<ComparisonData> = serde_dynamo::from_items(items)?;
let categorized = items.iter().fold(
HashMap::<String, Vec<ComparisonData>>::new(), |mut map, comp| {
// Load each comparison into a map, then return that map.
map.entry(comp.category.clone())
.and_modify(|vec| vec.push(comp.clone()))
.or_insert(vec![comp.clone()]);
map
}
);
Ok(categorized)
} else {
Err(ServerFnError::new("Couldn't find any comparisons."))
}
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
Err(ServerFnError::new(msg))
}
}
}
+45 -71
View File
@@ -1,14 +1,23 @@
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue};
use crate::common::{ExpandableInfo, ValueType};
use aws_sdk_dynamodb::{
types::AttributeValue,
error::ProvideErrorMetadata,
};
#[cfg(feature = "ssr")]
use leptos::leptos_dom::log;
use leptos::prelude::ServerFnError;
use leptos::server;
#[cfg(feature = "ssr")]
use uuid::Uuid;
#[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client;
#[cfg(feature = "ssr")]
use crate::common::ValueType;
use crate::common::ExpandableInfo;
use leptos::prelude::ServerFnError;
use leptos::server;
static SCHOLARSHIPS_TABLE: &str = "leptos-scholarship-test";
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
@@ -19,7 +28,7 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
log!("Getting scholarship info using id {:?}", id);
match client
.get_item()
.table_name("leptos-scholarship-test")
.table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(id.clone()))
.send()
.await
@@ -30,9 +39,7 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
Ok(serde_dynamo::from_item(item)?)
} else {
log!("Couldn't find scholarship with ID {:?}", id);
Err(ServerFnError::new(
"Couldn't find scholarship with given ID.",
))
Err(ServerFnError::new("Couldn't find scholarship with given ID."))
}
}
Err(err) => {
@@ -49,14 +56,11 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF
let client = create_dynamo_client().await;
log!(
"Creating or updating scholarship with ID {:?}",
info.subject
);
log!("Creating or updating scholarship with ID {:?}", info.subject);
match client
.put_item()
.table_name("leptos-scholarship-test")
.table_name(SCHOLARSHIPS_TABLE)
.set_item(Some(serde_dynamo::to_item(&info)?))
.send()
.await
@@ -75,7 +79,7 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
log!("Getting all scholarship info");
match client
.scan()
.table_name("leptos-scholarship-test")
.table_name(SCHOLARSHIPS_TABLE)
.send()
.await
{
@@ -85,7 +89,7 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
} else {
Ok(Vec::new())
}
}
},
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
Err(ServerFnError::new(msg))
@@ -95,23 +99,15 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
/// Gets all scholarships that are associated with the given scholarship provider's ID.
#[server(GetProviderScholarships, "/providers/scholarships/get")]
pub async fn get_provider_scholarships(
provider_id: String,
) -> Result<Vec<ExpandableInfo>, ServerFnError> {
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
);
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)))?,
)
.table_name(SCHOLARSHIPS_TABLE)
.expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.filter_expression("provider_id = :id")
.send()
.await
@@ -132,32 +128,20 @@ pub async fn get_provider_scholarships(
/// 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> {
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
);
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)),
);
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")
.table_name(SCHOLARSHIPS_TABLE)
.set_item(Some(ser_item))
.condition_expression("attribute_not_exists(subject)")
.send()
@@ -176,12 +160,10 @@ pub async fn register_scholarship(
log!("Current error: {:?}", err);
current_uuid = Uuid::new_v4().to_string();
item.subject = current_uuid.clone();
continue;
continue
}
_ => {
return Err(ServerFnError::new(
err.message().unwrap_or("An unknown error occurred"),
));
return Err(ServerFnError::new(err.message().unwrap_or("An unknown error occurred")));
}
}
}
@@ -191,33 +173,25 @@ pub async fn register_scholarship(
/// 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> {
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
);
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")
.table_name(SCHOLARSHIPS_TABLE)
.key("subject", AttributeValue::S(scholarship_id))
.expression_attribute_values(
":provider_id",
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_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(()),
Ok(_) => {
Ok(())
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
Err(ServerFnError::new(msg))
+232 -205
View File
@@ -1,30 +1,31 @@
// Server dependencies
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue};
#[cfg(feature = "ssr")]
use serde_dynamo::{from_item, to_item};
#[cfg(feature = "ssr")]
use std::io::Write;
#[cfg(feature = "ssr")]
use std::process::{Command, Stdio};
#[cfg(feature = "ssr")]
use std::io::Write;
use crate::common::{ExpandableInfo, UserClaims};
use crate::components::{
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
Select,
};
use crate::input;
use crate::common::{ComparisonData, ExpandableInfo};
use crate::pages::UnauthenticatedPage;
use crate::pages::utils::get_user_claims;
use base64::Engine;
use crate::components::{
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
Select, TextFieldType, ValidatedForm
};
use crate::input;
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::{AuthLoaded, Authenticated};
use traits::{AsReactive, ReactiveCapture};
use base64::Engine;
use leptos::logging::log;
use leptos::task::spawn_local;
use leptos::web_sys::HtmlAnchorElement;
use leptos::wasm_bindgen::JsCast;
use std::collections::HashMap;
use crate::pages::api::get_comparisons_categorized;
/// # Get Student Info
/// Gets a student's information given their `subject`.
@@ -144,24 +145,96 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> {
/// The main home page component. Contains a simple contact form.
#[component]
pub fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button
//#region Server Resources
let user_claims = get_user_claims();
let user_subject = Memo::new(move |_| {
user_claims.get()
.map(|claim| claim.claims.subject.clone())
});
// 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 don't lose access to it in the future, should we need it again.
let server_resource: Resource<ExpandableInfo> = Resource::new(
move || user_claims.get().map(|claim| claim.claims.subject.clone()),
async |opt_username| match opt_username {
Some(subject) => get_submission(subject.clone()).await.unwrap_or_else(|e| {
console_log(e.to_string().as_str());
ExpandableInfo::new(subject)
}),
None => ExpandableInfo::new("".to_owned()),
},
let form_data = RwSignal::new(HashMap::new());
let server_resource = Resource::new(
move || user_subject.get(),
async move |subject| get_submission(subject.unwrap_or_default()).await
);
let submit_action = ServerAction::<CreateSampleSubmission>::new();
Effect::new(move || {
match server_resource.get() {
Some(Ok(submission)) => {
form_data.set(submission.data);
}
Some(Err(err)) => {
log!("Error while getting information: {:?}", err);
}
_ => {
log!("An unknown error occurred.");
}
}
});
//#endregion
//#region Component State
let elements_disabled = Signal::derive(move || {
submit_action.pending().get()
});
//#endregion
//#region Event logic
let on_submit = move |_| {
let mut exp_info = ExpandableInfo::new(user_subject.get().unwrap_or_default());
exp_info.data = form_data.get();
submit_action.dispatch(CreateSampleSubmission {
student_info: exp_info
});
};
let pdf_button_click = move |_| {
console_log("Attempting to get PDF from server endpoint");
spawn_local(async move {
let result = create_example_pdf().await;
if let Ok(bytes) = result {
let base64 = base64::engine::general_purpose::STANDARD
.encode(bytes);
let data_url = format!(
"data:application/pdf;base64,{}",
base64,
);
console_log("Found document, opening in new tab...");
let link = document()
.create_element("a")
.unwrap()
.dyn_into::<HtmlAnchorElement>()
.unwrap();
link.set_href(&*data_url);
link.set_target("_blank");
link.click();
}
});
};
//#endregion
let comparison_resource = Resource::new(
move || user_subject.get(),
async move |_| get_comparisons_categorized().await
);
let comparison_info = RwSignal::new(HashMap::<String, Vec<ComparisonData>>::new());
Effect::new(move || {
match comparison_resource.get() {
Some(Ok(comparisons)) => {
log!("Found comparison info: {:?}", comparisons);
comparison_info.set(comparisons);
}
Some(Err(err)) => {
log!("Error while getting comparisons: {:?}", err);
}
_ => {
log!("An unknown error occurred.");
}
}
});
view! {
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
@@ -170,195 +243,149 @@ pub fn HomePage() -> impl IntoView {
{move || {
server_resource
.get()
.map(|submission| {
let expandable_react = submission.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_owned()
} else {
elements_disabled.set(false);
if let Some(result) = submit_action.value().get() {
match result {
Ok(_) => "Request sent successfully.".to_owned(),
Err(e) => format!("Failed to send request: {:?}", e),
}
} else {
"".to_owned()
}
}
});
.map(|_| {
view! {
<Row>
<div class="flex flex-col flex-1" />
<Panel>
<Row>
<h1 class="text-3xl font-bold">
"Region 15 Scholarship Application (DEMO)"
</h1>
</Row>
<Row>
<p class="text-sm">
"This is a very simple demonstration of the scholarship application written in Rust."
</p>
</Row>
<Row>
// Text { data_member, label, placeholder }
<OutlinedTextField
label="First Name:"
placeholder="John"
disabled=elements_disabled
data_member="first_name"
data_map=expandable_react.data
/>
<OutlinedTextField
label="Last Name:"
placeholder="Smith"
disabled=elements_disabled
data_member="last_name"
data_map=expandable_react.data
/>
</Row>
<Row>
<OutlinedTextField
label="Contact Email:"
placeholder="student@region15.org"
disabled=elements_disabled
data_member="contact_email"
data_map=expandable_react.data
/>
</Row>
<Row>
<OutlinedTextField
label="Phone Number:"
placeholder="123-456-7890"
disabled=elements_disabled
data_member="phone_number"
data_map=expandable_react.data
/>
</Row>
<Row>
<OutlinedTextField
label="Street Address:"
placeholder="123 Fake Street"
disabled=elements_disabled
data_member="address"
data_map=expandable_react.data
/>
</Row>
<Row>
<OutlinedTextField
label="Highest Math SAT Score:"
placeholder="600"
disabled=elements_disabled
data_member="math_sat"
data_map=expandable_react.data
input_type="number"
/>
</Row>
<Row>
// Radio { data_member, label, items }
<RadioList
label="Town:"
items=vec!["Southbury", "Middlebury"]
.into_iter()
.map(|s| s.into())
.collect()
disabled=elements_disabled
data_member="town"
data_map = expandable_react.data
/>
</Row>
<Row>
// Checkbox { data_member, label, items }
<CheckboxList
label="Favorite Candies:"
items=vec!["Twizzlers", "Reese's", "Starburst"]
.into_iter()
.map(|s| s.into())
.collect()
disabled=elements_disabled
data_member="favorite_candies"
data_map=expandable_react.data
/>
</Row>
<Row>
// Select { data_member, label, items }
<Select
label="Gender:"
value_list=vec!["Male", "Female", "Prefer not to answer"]
.into_iter()
.map(|s| s.to_owned())
.collect()
disabled=elements_disabled
data_member="gender"
data_map=expandable_react.data
/>
</Row>
<Row>
<MultiEntry
data_map=expandable_react.data
data_member="community_involvement"
schema=vec![
input!(Text, "service_name", "Activity Name:", "Some service activity..."),
input!(Number, "service_hours", "Total Service Hours", "20")
]
/>
</Row>
<Row>
<ActionButton
on:click=move |_| {
let captured_map = expandable_react.capture();
console_log(
format!("Map values: {:?}", captured_map).as_str(),
);
submit_action
.dispatch(CreateSampleSubmission {
student_info: captured_map,
});
}
disabled=elements_disabled
>
"Submit"
</ActionButton>
</Row>
<Row>
<ActionButton on:click=move |_| {
console_log("Attempting to get PDF from server endpoint");
spawn_local(async move {
let result = create_example_pdf().await;
if let Ok(bytes) = result {
let base64 = base64::engine::general_purpose::STANDARD
.encode(bytes);
let data_url = format!(
"data:application/pdf;base64,{}",
base64,
);
console_log("Found document, opening in new tab...");
let link = document()
.create_element("a")
.unwrap()
.dyn_into::<HtmlAnchorElement>()
.unwrap();
link.set_href(&*data_url);
link.set_target("_blank");
link.click();
}
});
}>"Get PDF"</ActionButton>
</Row>
<Row>
<p>{result_msg}</p>
</Row>
<ValidatedForm
on_submit=Callback::new(on_submit)
title="Home Page Test Form"
description="This really is just a test."
>
<Row>
<h1 class="text-3xl font-bold">
"Region 15 Scholarship Application (DEMO)"
</h1>
</Row>
<Row>
<p class="text-sm">
"This is a very simple demonstration of the scholarship application written in Rust."
</p>
</Row>
<Row>
<OutlinedTextField
label="First Name:"
placeholder="John"
disabled=elements_disabled
data_member="first_name"
data_map=form_data
required=true
/>
<OutlinedTextField
label="Last Name:"
placeholder="Smith"
disabled=elements_disabled
data_member="last_name"
data_map=form_data
required=true
/>
</Row>
<Row>
<OutlinedTextField
label="Contact Email:"
placeholder="student@region15.org"
disabled=elements_disabled
data_member="contact_email"
data_map=form_data
input_type=TextFieldType::Email(
vec!["fakeemail.org".to_string(), "gmail.com".to_string()]
)
required=true
/>
</Row>
<Row>
<OutlinedTextField
label="Phone Number:"
placeholder="123-456-7890"
disabled=elements_disabled
data_member="phone_number"
data_map=form_data
required=true
/>
</Row>
<Row>
<OutlinedTextField
label="Street Address:"
placeholder="123 Fake Street"
disabled=elements_disabled
data_member="address"
data_map=form_data
required=true
/>
</Row>
<Row>
<OutlinedTextField
label="Highest Math SAT Score:"
placeholder="600"
disabled=elements_disabled
data_member="math_sat"
data_map=form_data
input_type=TextFieldType::Number
required=true
/>
</Row>
<Row>
<RadioList
label="Town:"
items=vec!["Southbury", "Middlebury"]
.into_iter()
.map(|s| s.into())
.collect()
disabled=elements_disabled
data_member="town"
data_map=form_data
required=true
/>
</Row>
<Row>
<CheckboxList
label="Favorite Candies:"
items=vec!["Twizzlers", "Reese's", "Starburst"]
.into_iter()
.map(|s| s.into())
.collect()
disabled=elements_disabled
data_member="favorite_candies"
data_map=form_data
required=true
/>
</Row>
<Row>
<Select
label="Gender:"
value_list=vec!["Male", "Female", "Prefer not to answer"]
.into_iter()
.map(|s| s.to_owned())
.collect()
disabled=elements_disabled
data_member="gender"
data_map=form_data
required=true
/>
</Row>
<Row>
<MultiEntry
data_map=form_data
data_member="community_involvement"
schema=vec![
input!(Text, "service_name", "Activity Name:", true, "Some service activity..."),
input!(Number, "service_hours", "Total Service Hours", true, "20")
]
/>
</Row>
</ValidatedForm>
</Panel>
<div class="flex flex-col flex-1" />
</Row>
}
})
}}
<Row>
<ActionButton on:click=pdf_button_click>"Get PDF"</ActionButton>
</Row>
</Suspense>
</Authenticated>
</AuthLoaded>
}
}.into_any()
}
+149 -96
View File
@@ -4,10 +4,11 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu
#[cfg(feature = "ssr")]
use serde_dynamo::{from_item, to_item};
use crate::common::{ExpandableInfo, ValueType};
use crate::components::{
ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select,
};
#[cfg(feature = "ssr")]
use crate::common::ValueType;
use crate::common::ExpandableInfo;
use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use chrono::{FixedOffset, TimeZone};
use leptos::Params;
use leptos::either::{Either, EitherOf3};
@@ -18,7 +19,13 @@ use leptos_router::hooks::{use_navigate, use_params};
use leptos_router::params::Params;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use traits::{AsReactive, ReactiveCapture};
use leptos_router::components::Outlet;
use leptos_animate::animate;
use crate::common::animations::pop_in_out;
// Loaner page creates a single page that contains the borrowing/returning buttons
// and a panel for a form. At first, it will show a fallback view. At the borrowing
// path it will show the borrow form, and at the returning path it will show the
// return form.
#[derive(Params, PartialEq)]
struct LoanerParams {
@@ -191,7 +198,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError>
/// create a true admin page for this, simply to be able to survey and override/remove information
/// from the lists.
#[component]
pub fn LoanerPage() -> impl IntoView {
pub fn LoanerShell() -> impl IntoView {
// Check URL params. Depending on what string is visible here, we'll display a different form.
let params = use_params::<LoanerParams>();
let form_passed = move || {
@@ -203,12 +210,6 @@ pub fn LoanerPage() -> impl IntoView {
.unwrap_or_default()
};
let form_view = move || match form_passed().as_str() {
"borrowing" => EitherOf3::A(LoanerBorrowForm),
"returning" => EitherOf3::B(LoanerReturnForm),
_ => EitherOf3::C("Choose something from the left.".into_view()),
};
// We'll create the page here - the sidebar contains all the buttons, while the form
// container either shows forms or shows placeholder text.
view! {
@@ -234,94 +235,134 @@ pub fn LoanerPage() -> impl IntoView {
/>
</div>
<div class="flex flex-2">
<Panel>{form_view}</Panel>
<Panel>
<Outlet />
</Panel>
</div>
</div>
</div>
}
}
#[component]
pub fn LoanerFallback() -> impl IntoView {
view! {
<div
class="flex flex-col items-center justify-center"
use:animate=pop_in_out()
>
<div class="text-3xl font-bold mb-2">
"Chromebook Loaner Form"
</div>
<div class="text-lg">
"Select an option from the left to begin."
</div>
</div>
}
}
#[component]
pub fn LoanerBorrowForm() -> impl IntoView {
// Register server actions.
let create_entry_action = ServerAction::<CreateBorrowEntry>::new();
let navigate = use_navigate();
let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string());
let temp_reactive = temp.as_reactive();
let elements_disabled = RwSignal::new(false);
// Register server actions.
let create_entry_action = ServerAction::<CreateBorrowEntry>::new();
let data = RwSignal::new(HashMap::new());
let elements_disabled = Signal::derive(move || create_entry_action.pending().get());
let on_submit = move |_| {
create_entry_action.dispatch(CreateBorrowEntry {
input: ExpandableInfo {
subject: uuid::Uuid::new_v4().to_string(),
data: data.get(),
}
});
};
//#region Toast Logic
let mut toast_context = expect_context::<ToastContext>();
Effect::new(move || {
if let Some(Ok(_)) = create_entry_action.value().get() {
create_entry_action.clear();
toast_context.toast(
Toast::new()
.id(uuid::Uuid::new_v4().to_string())
.header("Borrower Added!")
.msg("You can take your loaner now.")
);
navigate("/loaners", Default::default());
}
});
//#endregion
view! {
<div class="flex flex-col justify-center gap-2 mb-1 py-3 px-2">
<h2 class="text-2xl font-bold">"Borrower Information"</h2>
<p class="text-lg">"Please fill out the form below."</p>
</div>
<Row>
<OutlinedTextField
label="First Name:"
placeholder="John"
disabled=elements_disabled
data_member="first_name"
data_map=temp_reactive.data
/>
<OutlinedTextField
label="Last Name:"
placeholder="Smith"
disabled=elements_disabled
data_member="last_name"
data_map=temp_reactive.data
/>
</Row>
<Row>
<OutlinedTextField
label="Region 15 Email:"
placeholder="example@region15.org"
disabled=elements_disabled
data_member="email"
data_map=temp_reactive.data
/>
</Row>
<Row>
<Select
label="Collateral:"
value_list=vec![
"Phone".to_string(),
"Earbuds/Headphones".to_string(),
"Keys".to_string(),
"Wallet".to_string(),
"Laptop".to_string(),
]
disabled=elements_disabled
data_member="collateral"
data_map=temp_reactive.data
/>
</Row>
<Row>
<Select
label="Loan Taken:"
value_list=vec![
"Chromebook".to_string(),
"Charger".to_string(),
"Headphones".to_string(),
]
disabled=elements_disabled
data_member="loan"
data_map=temp_reactive.data
/>
</Row>
<Row>
<ActionButton
on:click=move |_| {
let captured = temp_reactive.capture();
log!("Loaner record taken.");
log!("{:?}", captured);
create_entry_action.dispatch(CreateBorrowEntry {
input: captured
});
navigate("/loaners", Default::default());
}
>"Submit"</ActionButton>
</Row>
<ValidatedForm
use:animate=pop_in_out()
on_submit=Callback::new(on_submit)
title="Borrower Information"
description="Please fill out the form below."
>
<Row>
<OutlinedTextField
label="First Name:"
placeholder="John"
disabled=elements_disabled
data_member="first_name"
data_map=data
required=true
/>
<OutlinedTextField
label="Last Name:"
placeholder="Smith"
disabled=elements_disabled
data_member="last_name"
data_map=data
required=true
/>
</Row>
<Row>
<OutlinedTextField
label="Region 15 Email:"
placeholder="example@region15.org"
disabled=elements_disabled
data_member="email"
data_map=data
input_type=TextFieldType::Email(vec!["region15.org".to_string()])
required=true
/>
</Row>
<Row>
<Select
label="Collateral:"
value_list=vec![
"Phone".to_string(),
"Earbuds/Headphones".to_string(),
"Keys".to_string(),
"Wallet".to_string(),
"Laptop".to_string(),
]
disabled=elements_disabled
data_member="collateral"
data_map=data
required=true
/>
</Row>
<Row>
<Select
label="Loan Taken:"
value_list=vec![
"Chromebook".to_string(),
"Charger".to_string(),
"Headphones".to_string(),
]
disabled=elements_disabled
data_member="loan"
data_map=data
required=true
/>
</Row>
</ValidatedForm>
}
}
@@ -350,12 +391,19 @@ pub fn LoanerReturnForm() -> impl IntoView {
// 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)
let mut toasts = expect_context::<ToastContext>();
Effect::new(move || {
if let Some(Ok(_)) = check_in_action.value().get() {
// Return to the main page.
if let Some(dialog) = dialog_ref.get() {
dialog.close();
}
toasts.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.header("Check in successful!")
.msg("Don't forget to take your collateral.")
);
navigate("/loaners", Default::default());
check_in_action.clear();
}
@@ -381,6 +429,7 @@ pub fn LoanerReturnForm() -> impl IntoView {
view! {
<dialog
use:animate=pop_in_out()
class="p-3 m-auto rounded-md shadow-lg/33 backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter"
node_ref=dialog_ref>
<h2 class="text-2xl font-bold">"Confirmation"</h2>
@@ -396,12 +445,16 @@ pub fn LoanerReturnForm() -> impl IntoView {
<ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton>
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>"Check In"</ActionButton>
</dialog>
<div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2">
<h2 class="text-2xl font-bold">"Student List"</h2>
<p class="text-lg">"Please select your name from the list."</p>
</div>
<Suspense fallback=move || "Loading...".into_view()>
<div class="flex flex-col gap-2 m-2">
<div
class="flex flex-col gap-2 m-2"
use:animate=pop_in_out()
>
<div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2">
<h2 class="text-2xl font-bold">"Student List"</h2>
<p class="text-lg">"Please select your name from the list."</p>
</div>
<Suspense fallback=move || "Loading...".into_view()>
{move || {
match return_list_resource.get() {
Some(loaner_list) => {
@@ -443,7 +496,7 @@ pub fn LoanerReturnForm() -> impl IntoView {
None => Either::Right("No loaners found.".into_view())
}
}}
</div>
</Suspense>
</Suspense>
</div>
}
}
+2 -1
View File
@@ -8,8 +8,9 @@ mod scholarship_info;
mod test_page;
mod unauthenticated_page;
mod utils;
mod provider_contact;
pub use self::{
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
scholarship_info::*, test_page::*, unauthenticated_page::*,
test_page::*, unauthenticated_page::*, scholarship_info::*, provider_contact::*
};
+227
View File
@@ -0,0 +1,227 @@
use std::collections::HashMap;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use crate::common::ValueType;
use crate::components::{Banner, Loading, OutlinedTextField, Panel, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use crate::pages::UnauthenticatedPage;
use crate::pages::utils::get_user_claims;
#[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client;
#[cfg(feature = "ssr")]
static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts";
#[server]
async fn get_provider_contact(id: String) -> Result<HashMap<String, ValueType>, ServerFnError> {
use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
let client = create_dynamo_client().await;
log!("Getting contact info for provider {}", id);
match client
.get_item()
.table_name(PROVIDER_CONTACT_TABLE)
.key("subject", AttributeValue::S(id))
.send()
.await
{
Ok(output) => {
let Some(item) = output.item else {
return Ok(HashMap::new());
};
let map = item.iter().map(|(key, val)| {
let val_type = ValueType::from(val);
(key.clone(), val_type)
}).collect::<HashMap<String, ValueType>>();
Ok(map)
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
#[server]
async fn put_provider_contact(id: String, contact_info: HashMap<String, ValueType>) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
use crate::pages::utils::server_utils::into_attr_map;
let client = create_dynamo_client().await;
let mut item = into_attr_map(contact_info);
item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id));
match client
.put_item()
.table_name(PROVIDER_CONTACT_TABLE)
.set_item(Some(item))
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred.");
Err(ServerFnError::new(msg))
}
}
}
#[component]
pub fn ProviderContactPage() -> impl IntoView {
// Get user claims
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| {
user_claims.get()
.map(|info| info.claims.subject.clone())
});
// Get contact information
let contact_info = RwSignal::new(HashMap::new());
let contact_resource = Resource::new(
move || user_id.get(),
async move |id| {
let Some(id) = id else {
return Ok(HashMap::new());
};
get_provider_contact(id).await
}
);
Effect::new(move || {
match contact_resource.get() {
Some(Ok(map)) => contact_info.set(map),
Some(Err(e)) => log!("Error getting contact info: {}", e),
None => log!("Contact info resource is not ready yet."),
}
});
// Set up submit action
let submit_action = ServerAction::<PutProviderContact>::new();
let on_submit = move || {
// Submit the form by reading the contact_info.
submit_action.dispatch(PutProviderContact {
id: user_id.get().unwrap_or_default(),
contact_info: contact_info.get()
});
};
let elements_disabled = Signal::derive(move || {
submit_action.pending().get()
});
let mut toasts_context = expect_context::<ToastContext>();
Effect::new(move || {
submit_action.value().get().is_some()
.then(|| {
toasts_context.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.header("Submission Successful")
.msg("You can go back or continue editing your responses.")
);
submit_action.clear();
});
});
// Display contact form.
view! {
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/" />
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<Suspense fallback=Loading>
<div class="flex flex-row">
<div class="flex flex-col flex-1" />
<Panel>
<ValidatedForm
on_submit=on_submit
title="Contact Form"
description="Enter your contact information and click submit."
>
{move || {
contact_resource
.get()
.map(|_| {
view! {
<Row>
<OutlinedTextField
label="First Name:"
placeholder="John"
disabled=elements_disabled
data_member="first_name"
data_map=contact_info
required=true
/>
<OutlinedTextField
label="Last Name:"
placeholder="Smith"
disabled=elements_disabled
data_member="last_name"
data_map=contact_info
required=true
/>
</Row>
<Row>
<OutlinedTextField
label="Contact Email:"
placeholder="student@region15.org"
disabled=elements_disabled
data_member="contact_email"
data_map=contact_info
input_type=TextFieldType::Email(vec!["*".to_string()])
required=true
/>
</Row>
<Row>
<OutlinedTextField
label="Phone Number:"
placeholder="123-456-7890"
disabled=elements_disabled
data_member="phone_number"
data_map=contact_info
required=true
/>
</Row>
<Row>
<OutlinedTextField
label="Street Address:"
placeholder="123 Fake Street"
disabled=elements_disabled
data_member="address"
data_map=contact_info
required=true
/>
</Row>
<Row>
<OutlinedTextField
label="Testing Number"
placeholder="Just put a number"
disabled=elements_disabled
data_member="testing_number"
data_map=contact_info
input_type=TextFieldType::Number
required=true
/>
</Row>
}
})
}}
</ValidatedForm>
</Panel>
<div class="flex flex-col flex-1" />
</div>
</Suspense>
</Authenticated>
</AuthLoaded>
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub fn ProviderPortal() -> impl IntoView {
title="Profile"
description="Edit user profile"
icon="/Person_Black.png"
path="/"
path="/providers/profile"
/>
<DashboardButton
title="Create Scholarship"
+410 -308
View File
@@ -1,24 +1,19 @@
use super::UnauthenticatedPage;
use super::api::{
CreateScholarshipInfo, CreateTestComparisons, DeleteProviderScholarship, RegisterScholarship,
get_comparison_info, get_provider_scholarships, get_scholarship_info,
};
use crate::common::ComparisonData;
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
use crate::components::{
ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row,
};
use crate::pages::utils::get_user_claims;
use std::collections::HashMap;
use leptos::either::Either;
use leptos::html::Dialog;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::{use_navigate, use_params};
use traits::{AsReactive, ReactiveCapture};
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, TextFieldType, Toast, ToastContext, ToastList, ValidatedForm};
use super::UnauthenticatedPage;
use crate::pages::utils::get_user_claims;
use super::api::{get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, RegisterScholarship, DeleteProviderScholarship, get_comparisons_categorized, CreateTestComparisons};
/// # Scholarship Info Page
/// This page will handle creating or editing scholarship information given a specific
/// This page will handle creating or editing scholarship information given a specific
/// scholarship ID and scholarship provider subject number. The form itself will only
/// track a single scholarship (for now) and so selection of what scholarship will be edited
/// must be handled *before* navigation to this page.
@@ -29,65 +24,59 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
let url_params = use_params::<ScholarshipFormParams>();
let scholarship_id = Memo::new(move |_| {
url_params
.read()
url_params.read()
.as_ref()
.ok()
.and_then(|params| params.id.clone())
});
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
// and if there's data for us to use.
view! {
<Banner
title="R15 Scholarships"
logo="/PHS_Stacked_Acronym.png"
path="/"
/>
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<div class="flex flex-row align-top items-start">
<div class="flex flex-col flex-1" />
<ScholarshipList
refresh_token=list_refresh
on_delete=on_delete
/>
<Transition fallback=Loading>
<ScholarshipForm
scholarship_id=scholarship_id
on_submit=move || {
list_refresh.update(|n| *n += 1);
}
/>
</Transition>
<div class="flex flex-col flex-1" />
</div>
</Authenticated>
</AuthLoaded>
<Banner title="R15 Scholarships" logo="/PHS_Stacked_Acronym.png" path="/" />
<ToastList>
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<div class="flex flex-row align-top items-start">
<div class="flex flex-col flex-1" />
<ScholarshipList refresh_token=list_refresh on_delete=on_delete />
<Transition fallback=Loading>
<ScholarshipForm
scholarship_id=scholarship_id
on_submit=move || {
list_refresh.update(|n| *n += 1);
}
/>
</Transition>
<div class="flex flex-col flex-1" />
</div>
</Authenticated>
</AuthLoaded>
</ToastList>
}
}
/// # 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
/// 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
/// <ScholarshipList
/// refresh_token=refresh
/// on_delete=move || log!("This is run when an item is deleted on the server.")
/// />
@@ -99,11 +88,9 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
fn ScholarshipList(
/// A signal that refreshes the list when changed. Most times, we don't need to worry about the
/// user reloading our page 2 billion times.
#[prop()]
refresh_token: RwSignal<i32>,
#[prop()] refresh_token: RwSignal<i32>,
/// A callback that is run on successful server-side deletion of an item.
#[prop(into)]
on_delete: Callback<()>,
#[prop(into)] on_delete: Callback<()>
) -> impl IntoView {
// List state
let pending_delete = RwSignal::new(None::<ExpandableInfo>);
@@ -112,43 +99,44 @@ fn ScholarshipList(
// 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()
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()),
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(),
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
@@ -179,47 +167,50 @@ fn ScholarshipList(
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()
dialog.show_modal()
.expect("Couldn't show dialog. Is it already open?");
} else {
dialog.close();
}
}
});
view! {
<dialog
node_ref=delete_dialog_ref
class="m-auto p-2"
>
<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())
})
}}
"?"
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();
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()
});
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>
>
"Confirm"
</ActionButton>
<ActionButton on:click=move |_| pending_delete.set(None)>"Cancel"</ActionButton>
</dialog>
<div class="w-75">
<Panel>
@@ -229,31 +220,42 @@ fn ScholarshipList(
<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())
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>
},
)
}
}
Err(err) => {
Either::Right(view! {
<div>
<p>"Failed to load scholarship list: "{err.to_string()}</p>
</div>
})
}
}
}).collect_view()
})
.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>
<div
class="p-2 bg-red-800 rounded-md text-center text-white hover:bg-red-900 transition-all cursor-pointer"
on:click=create_on_click
>
"Create New"
</div>
</div>
@@ -264,9 +266,9 @@ fn ScholarshipList(
}
/// # Scholarship List Entry Component
///
///
/// Displays a single scholarship within the [`ScholarshipList`] component.
///
///
/// Example usage (within the [`ScholarshipList`]):
/// ```
/// view! {
@@ -280,26 +282,24 @@ fn ScholarshipList(
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,
#[prop()] scholarship: ExpandableInfo,
/// A [`Callback<ExpandableInfo>`] that runs upon successful deletion of the scholarship.
#[prop(into)]
on_delete: Callback<ExpandableInfo, ()>,
#[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
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")
let name = scholarship.data.get("name")
.unwrap_or(&ValueType::String(None))
.as_string()
.unwrap_or_default()
.as_string().unwrap_or_default()
.unwrap_or("<no name found>".to_string());
// Button click handlers
let edit_click = {
let subject = scholarship.subject.clone();
@@ -308,20 +308,22 @@ fn ScholarshipListEntry(
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
<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
<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())
>
@@ -354,54 +356,48 @@ fn ScholarshipListEntry(
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>>,
#[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<()>,
#[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();
let submit_status = RwSignal::new(SubmitStatus::Idle);
let elements_disabled = Signal::derive(move || {
matches!(submit_action.pending().get(), true)
});
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())),
},
Err(_err) => submit_status.set(SubmitStatus::Error(()))
}
}
});
let comparison_list: Resource<Vec<ComparisonData>> = Resource::new(
move || scholarship_id.get(), // There's no input to this function.
let comparison_lists = Resource::new(
move || scholarship_id.get().is_some(),
async move |_| {
get_comparison_info().await.unwrap_or_else(|e| {
log!("Failed to get comparison info: {:?}", e);
Vec::new()
})
},
get_comparisons_categorized().await
}
);
Effect::new(move || {
if let Some(Ok(map)) = comparison_lists.get() {
log!("Available categories: {:?}", map.keys());
}
});
let scholarship_info = Resource::new(
move || scholarship_id.get(),
async move |opt_id| {
@@ -410,175 +406,281 @@ fn ScholarshipForm(
} 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 form_data = RwSignal::new(HashMap::new());
let chips_data = RwSignal::new(HashMap::new());
Effect::new(move || {
if let Some(Ok(scholarship)) = scholarship_info.get() {
// Get the default chips data.
let chips_default = if let Some(ValueType::Map(Some(map))) = scholarship.data.get("requirements") {
log!("Found requirements map: {:?}", map);
map.clone()
} else {
log!("Couldn't find requirements map in scholarship info. Using empty map.");
HashMap::new()
};
chips_data.set(chips_default);
form_data.set(scholarship.data);
}
});
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()
let get_comp_category = move |category: &str| {
if let Some(Ok(map)) = comparison_lists.get() {
map.get(category).unwrap_or(&Vec::new()).clone()
} else {
Vec::new()
}
};
let get_comp_ids = move |category: &str| {
get_comp_category(category)
.into_iter()
.map(|comp| comp.id)
.collect::<Vec<String>>()
};
let get_comp_text = move |category: &str| {
get_comp_category(category)
.into_iter()
.map(|comp| comp.display_text)
.collect::<Vec<String>>()
};
// Get comparison lists.
let sports_ids = Signal::derive(move || get_comp_ids("Sports Participation"));
let sports_text = Signal::derive(move || get_comp_text("Sports Participation"));
let misc_ids = Signal::derive(move || get_comp_ids("Additional Eligibility Factors"));
let misc_text = Signal::derive(move || get_comp_text("Additional Eligibility Factors"));
let comm_ids = Signal::derive(move || get_comp_ids("Community Service"));
let comm_text = Signal::derive(move || get_comp_text("Community Service"));
let res_ids = Signal::derive(move || get_comp_ids("Residency"));
let res_text = Signal::derive(move || get_comp_text("Residency"));
let gpa_ids = Signal::derive(move || get_comp_ids("GPA Limits"));
let gpa_text = Signal::derive(move || get_comp_text("GPA Limits"));
let major_ids = Signal::derive(move || get_comp_ids("Majors"));
let major_text = Signal::derive(move || get_comp_text("Majors"));
let mut toasts = expect_context::<ToastContext>();
let on_submit = move |_| {
let mut info = ExpandableInfo::new(scholarship_id.get().unwrap_or_default());
info.data = form_data.get();
info.data.insert("requirements".to_string(), ValueType::Map(Some(chips_data.get())));
log!("Map values: {:?}", info.data);
submit_action.dispatch(CreateScholarshipInfo {
info
});
};
Effect::new(move || {
submit_action.value().get().is_some()
.then(|| {
log!("Displaying toasts!");
toasts.toast(
Toast::new()
.id(uuid::Uuid::new_v4())
.msg("Succesfully submitted.")
);
submit_action.clear();
});
});
// We'll collect the scholarship's name, num_awards, amount_per_award, total_awards,
// fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt)
// award_night_remarks
let create_comps = ServerAction::<CreateTestComparisons>::new();
view! {
<Panel>
<Show
when=move || scholarship_id.get().is_some()
fallback=|| view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
fallback=|| {
view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
}
>
<Suspense fallback=Loading>
{move || {
scholarship_info.get()
.map(|res_scholarship| {
match res_scholarship {
Ok(scholarship) => {
let reactive_info = scholarship.as_reactive();
Either::Left(view! {
<Row>
<h1 class="text-3xl font-bold">
"Scholarship Info Form (test)"
</h1>
</Row>
<Row>
<OutlinedTextField
label="Scholarship Name"
placeholder="Example Scholarship Name"
disabled=elements_disabled
data_member="name"
data_map=reactive_info.data
/>
</Row>
<Row>
<OutlinedTextField
label="Amount per award: (if multiple, enter the highest value)"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="amount_per_award"
data_map=reactive_info.data
input_type="number"
/>
</Row>
<Row>
<OutlinedTextField
label="Total number of awards:"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="num_awards"
data_map=reactive_info.data
input_type="number"
/>
</Row>
<Row>
<OutlinedTextField
label="Total amount of all awards:"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="total_awards"
data_map=reactive_info.data
input_type="number"
/>
</Row>
<Row>
<RadioList
data_member="fafsa_required"
data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require student financial information?"
/>
</Row>
<Row>
<RadioList
data_member="transcript_required"
data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student transcript?"
/>
</Row>
<Row>
<RadioList
data_member="award_to"
data_map=reactive_info.data
items=vec!["School".to_string(), "Student".to_string()]
disabled=elements_disabled
label="Will the award be made to the school or the student?"
/>
</Row>
<Row>
<RadioList
data_member="essay_required"
data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student essay?"
/>
</Row>
<Row>
<RadioList
data_member="essay_prompt"
data_map=reactive_info.data
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect()
disabled=elements_disabled
label="If so, select a prompt from the list below."
/>
</Row>
<Row>
<ChipsList
label="Scholarship Requirements"
data_member="requirements"
data_map=reactive_info.data
values=comparison_ids
displayed_text=comparison_text
/>
</Row>
<Row>
<ActionButton
disabled=elements_disabled
on:click=move |_| {
let captured = reactive_info.capture();
log!("Map values: {:?}", captured);
submit_action.dispatch(CreateScholarshipInfo {
info: captured.clone()
});
}
>"Submit"</ActionButton>
</Row>
<Row>
<p>{result_msg}</p>
</Row>
})
<ValidatedForm
on_submit=Callback::new(on_submit)
title="Scholarship Info Form"
description="Create or edit a scholarship. Input all information and click Submit."
>
{move || {
scholarship_info
.get()
.map(|res_scholarship| {
match res_scholarship {
Ok(_) => {
Either::Left(
view! {
<Row>
<OutlinedTextField
label="Scholarship Name"
placeholder="Example Scholarship Name"
disabled=elements_disabled
data_member="name"
data_map=form_data
/>
</Row>
<Row>
<OutlinedTextField
label="Amount per award: (if multiple, enter the highest value)"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="amount_per_award"
data_map=form_data
input_type=TextFieldType::Number
/>
</Row>
<Row>
<OutlinedTextField
label="Total number of awards:"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="num_awards"
data_map=form_data
input_type=TextFieldType::Number
/>
</Row>
<Row>
<OutlinedTextField
label="Total amount of all award(s): (estimates are acceptable)"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="total_awards"
data_map=form_data
input_type=TextFieldType::Number
/>
</Row>
<Row>
<RadioList
data_member="fafsa_required"
data_map=form_data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require student FAFSA information?"
/>
</Row>
<Row>
<RadioList
data_member="transcript_required"
data_map=form_data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student transcript?"
/>
</Row>
<Row>
<RadioList
data_member="award_to"
data_map=form_data
items=vec!["School".to_string(), "Student".to_string()]
disabled=elements_disabled
label="Will the award be made to the school or the student?"
/>
</Row>
<Row>
<RadioList
data_member="essay_required"
data_map=form_data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student essay?"
/>
</Row>
<Row>
<RadioList
data_member="essay_prompt"
data_map=form_data
items=vec!["Test 1", "Test 2", "Test 3"]
.iter()
.map(|s| s.to_string())
.collect()
disabled=elements_disabled
label="If so, select a prompt from the list below."
/>
</Row>
<Row>
<ChipsList
label="GPA Requirements"
data_member="gpa"
data_map=chips_data
values=gpa_ids
displayed_text=gpa_text
/>
</Row>
<Row>
<ChipsList
label="Sports Participation"
data_member="sports_participation"
data_map=chips_data
values=sports_ids
displayed_text=sports_text
/>
</Row>
<Row>
<ChipsList
label="Community Service"
data_member="community_service"
data_map=chips_data
values=comm_ids
displayed_text=comm_text
/>
</Row>
<Row>
<ChipsList
label="Residency"
data_member="residency"
data_map=chips_data
values=res_ids
displayed_text=res_text
/>
</Row>
<Row>
<ChipsList
label="Majors"
data_member="major"
data_map=chips_data
values=major_ids
displayed_text=major_text
/>
</Row>
<Row>
<ChipsList
label="Additional Eligibility Factors"
data_member="misc"
data_map=chips_data
values=misc_ids
displayed_text=misc_text
/>
</Row>
},
)
}
Err(err) => {
Either::Right(
view! {
<p>"Error while getting scholarship: "{err.to_string()}</p>
},
)
}
}
Err(err) => {
Either::Right(view! {
<p>"Error while getting scholarship: "{err.to_string()}</p>
})
}
}
}).collect_view()
}}
})
.collect_view()
}}
</ValidatedForm>
<ActionButton on:click=move |_| {
create_comps.dispatch(CreateTestComparisons {});
}>"Create Comparisons"</ActionButton>
</Suspense>
</Show>
</Panel>
}
}.into_any()
}
+6
View File
@@ -9,7 +9,9 @@ use leptos_oidc::{Algorithm, AuthSignal, TokenData};
/// dependencies cannot be compiled for WASM (nor should they be).
#[cfg(feature = "ssr")]
pub mod server_utils {
use std::collections::HashMap;
use aws_config::SdkConfig;
use crate::common::ValueType;
/// Creates an [`SdkConfig`] struct for use with AWS SDK structs.
pub async fn create_aws_config() -> SdkConfig {
@@ -20,6 +22,10 @@ pub mod server_utils {
pub async fn create_dynamo_client() -> aws_sdk_dynamodb::Client {
aws_sdk_dynamodb::Client::new(&create_aws_config().await)
}
pub fn into_attr_map(map: HashMap<String, ValueType>) -> HashMap<String, aws_sdk_dynamodb::types::AttributeValue> {
map.into_iter().map(|(k, v)| (k, v.into())).collect()
}
}
/// Gets the current user claims. This function should only be used in an area that has