Merge branch 'main' into feat/date-component
# Conflicts: # src/common/mod.rs # src/components/mod.rs
This commit is contained in:
commit
ae320f9763
40 files changed
+2599
-140
No files matched your search
@@ -0,0 +1,350 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::pages::utils::server_utils::create_dynamo_client;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
|
||||
|
||||
use crate::common::{
|
||||
ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison,
|
||||
TextComparison, TextListComparison, ValueType,
|
||||
};
|
||||
use leptos::leptos_dom::log;
|
||||
use leptos::prelude::ServerFnError;
|
||||
use leptos::server;
|
||||
|
||||
/// 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.
|
||||
fn create_sports_comparisons() -> Vec<ComparisonData> {
|
||||
vec![
|
||||
ComparisonData::new(
|
||||
"sports_football",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Football".to_string())),
|
||||
"Sports Participation",
|
||||
"Football",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_soccer",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Soccer".to_string())),
|
||||
"Sports Participation",
|
||||
"Soccer",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_cheerleading",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Cheerleading".to_string())),
|
||||
"Sports Participation",
|
||||
"Cheerleading",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_field_hockey",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Field Hockey".to_string())),
|
||||
"Sports Participation",
|
||||
"Field Hockey",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_swimming",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Swimming".to_string())),
|
||||
"Sports Participation",
|
||||
"Swimming",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_golf",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Golf".to_string())),
|
||||
"Sports Participation",
|
||||
"Golf",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_basketball",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Basketball".to_string())),
|
||||
"Sports Participation",
|
||||
"Basketball",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_track",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Track".to_string())),
|
||||
"Sports Participation",
|
||||
"Track",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_gymnastics",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Gymnastics".to_string())),
|
||||
"Sports Participation",
|
||||
"Gymnastics",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_ice_hockey",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Ice Hockey".to_string())),
|
||||
"Sports Participation",
|
||||
"Ice Hockey",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_ski",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Ski".to_string())),
|
||||
"Sports Participation",
|
||||
"Ski",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_wrestling",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Wrestling".to_string())),
|
||||
"Sports Participation",
|
||||
"Wrestling",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_lacrosse",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Lacrosse".to_string())),
|
||||
"Sports Participation",
|
||||
"Lacrosse",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_softball",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Softball".to_string())),
|
||||
"Sports Participation",
|
||||
"Softball",
|
||||
),
|
||||
ComparisonData::new(
|
||||
"sports_tennis",
|
||||
"sports_participation",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToTextList(
|
||||
"sport_name".to_string(),
|
||||
Box::new(TextListComparison::Contains),
|
||||
)),
|
||||
ValueType::String(Some("Tennis".to_string())),
|
||||
"Sports Participation",
|
||||
"Tennis",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[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 test_comp = ComparisonData::new(
|
||||
"math_sat_comp",
|
||||
"math_sat",
|
||||
ComparisonType::Number(NumberComparison::GreaterThanOrEqual),
|
||||
ValueType::Number(Some(650.to_string())),
|
||||
"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",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
|
||||
"service_hours".to_string(),
|
||||
Box::new(NumberListComparison::Sum(Box::new(
|
||||
NumberComparison::GreaterThanOrEqual,
|
||||
))),
|
||||
)),
|
||||
ValueType::Number(Some(20.to_string())),
|
||||
"Community Involvement",
|
||||
"20 service hours",
|
||||
);
|
||||
|
||||
let service_hours_30 = ComparisonData::new(
|
||||
"service_hours_30",
|
||||
"community_involvement",
|
||||
ComparisonType::MapList(MapListComparison::FlattenToNumberList(
|
||||
"service_hours".to_string(),
|
||||
Box::new(NumberListComparison::Sum(Box::new(
|
||||
NumberComparison::GreaterThanOrEqual,
|
||||
))),
|
||||
)),
|
||||
ValueType::Number(Some(30.to_string())),
|
||||
"Community Involvement",
|
||||
"30 service hours",
|
||||
);
|
||||
|
||||
let residency_southbury = ComparisonData::new(
|
||||
"residency_southbury",
|
||||
"town",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Southbury".to_string())),
|
||||
"Residency",
|
||||
"Southbury",
|
||||
);
|
||||
|
||||
let residency_middlebury = ComparisonData::new(
|
||||
"residency_middlebury",
|
||||
"town",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Middlebury".to_string())),
|
||||
"Residency",
|
||||
"Middlebury",
|
||||
);
|
||||
|
||||
let attended_bas = ComparisonData::new(
|
||||
"attended_bas",
|
||||
"attended_bas",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Miscellaneous",
|
||||
"Attended BAS",
|
||||
);
|
||||
|
||||
let midd_south = ComparisonData::new(
|
||||
"midd_south",
|
||||
"middsouth_church",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Miscellaneous",
|
||||
"Member of Midd-South Church",
|
||||
);
|
||||
|
||||
let family_military = ComparisonData::new(
|
||||
"family_military",
|
||||
"family_military_service",
|
||||
ComparisonType::Text(TextComparison::Matches),
|
||||
ValueType::String(Some("Yes".to_string())),
|
||||
"Miscellaneous",
|
||||
"Family with Military Service",
|
||||
);
|
||||
|
||||
let mut sports_comps = create_sports_comparisons();
|
||||
let mut major_comps = Vec::<ComparisonData>::new();
|
||||
|
||||
let mut comp_list = vec![
|
||||
test_comp,
|
||||
service_hours_20,
|
||||
service_hours_25,
|
||||
service_hours_30,
|
||||
residency_middlebury,
|
||||
residency_southbury,
|
||||
attended_bas,
|
||||
midd_south,
|
||||
family_military,
|
||||
];
|
||||
|
||||
comp_list.append(&mut sports_comps);
|
||||
comp_list.append(&mut major_comps);
|
||||
|
||||
for comparison in comp_list {
|
||||
if let Err(err) = client
|
||||
.put_item()
|
||||
.table_name("leptos-comparison-test")
|
||||
.set_item(Some(serde_dynamo::to_item(&comparison)?))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
return Err(ServerFnError::new(msg));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError> {
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
log!("Getting all comparisons from the database");
|
||||
|
||||
// Query the database for all comparisons. The client is only going to use the
|
||||
// id and display text, but we'll return the whole thing.
|
||||
match client
|
||||
.scan()
|
||||
.table_name("leptos-comparison-test")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
if let Some(items) = output.items {
|
||||
log!("Found comparisons from API: {:?}", items);
|
||||
Ok(serde_dynamo::from_items(items)?)
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod comparisons;
|
||||
mod scholarships;
|
||||
|
||||
pub use self::{comparisons::*, scholarships::*};
|
||||
@@ -0,0 +1,91 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue};
|
||||
|
||||
use crate::common::ExpandableInfo;
|
||||
use leptos::leptos_dom::log;
|
||||
use leptos::prelude::ServerFnError;
|
||||
use leptos::server;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::pages::utils::server_utils::create_dynamo_client;
|
||||
|
||||
#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")]
|
||||
pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> {
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
// Perform the operation - we just want to return all data that's contained in this entry,
|
||||
// or just return an empty ExpandableInfo struct.
|
||||
log!("Getting scholarship info using id {:?}", id);
|
||||
match client
|
||||
.get_item()
|
||||
.table_name("leptos-scholarship-test")
|
||||
.key("subject", AttributeValue::S(id.clone()))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
if let Some(item) = output.item {
|
||||
log!("Found output from API: {:?}", item);
|
||||
Ok(serde_dynamo::from_item(item)?)
|
||||
} else {
|
||||
log!("Couldn't find any values, returning default struct.");
|
||||
Ok(ExpandableInfo::new(id.clone()))
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server(CreateScholarshipInfo, endpoint = "/scholarship/info/create")]
|
||||
pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> {
|
||||
use crate::pages::utils::server_utils::create_dynamo_client;
|
||||
use aws_sdk_dynamodb::error::ProvideErrorMetadata;
|
||||
|
||||
let client = create_dynamo_client().await;
|
||||
|
||||
log!(
|
||||
"Creating or updating scholarship with ID {:?}",
|
||||
info.subject
|
||||
);
|
||||
|
||||
match client
|
||||
.put_item()
|
||||
.table_name("leptos-scholarship-test")
|
||||
.set_item(Some(serde_dynamo::to_item(&info)?))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server(GetAllScholarshipInfo, endpoint = "/scholarship/info/get-all")]
|
||||
pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> {
|
||||
let client = create_dynamo_client().await;
|
||||
log!("Getting all scholarship info");
|
||||
match client
|
||||
.scan()
|
||||
.table_name("leptos-scholarship-test")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
if let Some(items) = output.items {
|
||||
Ok(serde_dynamo::from_items(items)?)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred");
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use super::UnauthenticatedPage;
|
||||
use super::api::{get_all_scholarship_info, get_comparison_info};
|
||||
use super::home_page::get_submission;
|
||||
use crate::common::{ComparisonData, ExpandableInfo, UserClaims, ValueType};
|
||||
use crate::components::{ActionButton, Loading, Panel, Row};
|
||||
use leptos::either::Either;
|
||||
use leptos::leptos_dom::logging::console_log;
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
|
||||
|
||||
#[component]
|
||||
pub fn ComparisonTestPage() -> impl IntoView {
|
||||
let result_msg = RwSignal::new("Found submission, waiting for input.".to_string());
|
||||
let auth = use_context::<AuthSignal>().expect("Couldn't find user information.");
|
||||
let user_claims = Signal::derive(move || {
|
||||
auth.with(|auth| {
|
||||
auth.authenticated().and_then(|data| {
|
||||
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
// 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 student_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 scholarship_resource: Resource<Vec<ExpandableInfo>> = Resource::new(
|
||||
move || Option::<String>::None,
|
||||
async |_| {
|
||||
get_all_scholarship_info().await.unwrap_or_else(|e| {
|
||||
log!("An error occurred: {:?}", e);
|
||||
Vec::new()
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
let comparison_resource: Resource<Vec<ComparisonData>> = Resource::new(
|
||||
move || Option::<String>::None,
|
||||
async |_| {
|
||||
get_comparison_info().await.unwrap_or_else(|e| {
|
||||
log!("An error occurred: {:?}", e);
|
||||
Vec::new()
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
view! {
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
<Suspense fallback=Loading>
|
||||
{move || {
|
||||
let student_info = student_resource.get();
|
||||
let comparison_info = comparison_resource.get();
|
||||
let scholarship_info = scholarship_resource.get();
|
||||
|
||||
match (student_info, comparison_info, scholarship_info) {
|
||||
(Some(student), Some(comparisons), Some(scholarships)) => Either::Right(view! {
|
||||
<Panel>
|
||||
<Row>
|
||||
<p>{result_msg}</p>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton on:click=move |_| {
|
||||
// Iterate over all scholarships, get the comparison IDs,
|
||||
// get ComparisonData from the IDs, then perform the
|
||||
// comparisons and return a boolean. Alternatively, return
|
||||
// a list of comparisons that complete and a list that don't.
|
||||
let comp_member = "requirements".to_string();
|
||||
let comparison_results = scholarships.iter().map(|scholarship| {
|
||||
let list = match scholarship.data.get(&comp_member) {
|
||||
Some(ValueType::List(Some(list))) => list,
|
||||
_ => {
|
||||
log!("Cannot find requirements, assuming none.");
|
||||
return Ok(Some(scholarship)) // students will always qualify when there are no requirements
|
||||
}
|
||||
};
|
||||
|
||||
let mut final_result = Ok(true);
|
||||
|
||||
// The list should exist, and it should contain a series of strings.
|
||||
for val in list {
|
||||
let comp_opt = match val {
|
||||
ValueType::String(Some(comp_id)) => {
|
||||
// Find the comparison
|
||||
comparisons.iter().find(|comp| comp.id == *comp_id)
|
||||
}
|
||||
_ => None
|
||||
};
|
||||
|
||||
let comparison_outcome = match comp_opt {
|
||||
Some(comp) => {
|
||||
// Perform the comparison
|
||||
comp.compare(&student)
|
||||
}
|
||||
_ => Err("".to_string())
|
||||
};
|
||||
|
||||
match comparison_outcome {
|
||||
Ok(true) => {},
|
||||
Ok(false) => final_result = Ok(false),
|
||||
Err(e) => {
|
||||
final_result = Err(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match final_result {
|
||||
Ok(true) => Ok(Some(scholarship)),
|
||||
Ok(false) => Ok(None),
|
||||
Err(e) => Err(e)
|
||||
}
|
||||
}).collect::<Vec<Result<Option<&ExpandableInfo>, String>>>();
|
||||
|
||||
// Build a display string
|
||||
let mut strings = Vec::new();
|
||||
strings.push("Scholarship results: \n".to_string());
|
||||
for result in comparison_results {
|
||||
match result {
|
||||
Ok(Some(scholarship_info)) => {
|
||||
// Get the scholarship name.
|
||||
let name_member = "name".to_string();
|
||||
let scl_name = scholarship_info.data.get(&name_member).unwrap()
|
||||
.as_string().unwrap().unwrap();
|
||||
strings.push(scl_name);
|
||||
},
|
||||
Ok(None) => {},
|
||||
Err(msg) => strings.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
let display_text = strings.join("\n").to_string();
|
||||
|
||||
result_msg.set(display_text);
|
||||
}>"Test Comparison"</ActionButton>
|
||||
</Row>
|
||||
</Panel>
|
||||
}),
|
||||
_ => Either::Left(view! {
|
||||
<p>"Failed API requests."</p>
|
||||
})
|
||||
}
|
||||
}}
|
||||
</Suspense>
|
||||
</Authenticated>
|
||||
</AuthLoaded>
|
||||
}
|
||||
}
|
||||
+107
-89
@@ -4,18 +4,25 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu
|
||||
|
||||
#[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};
|
||||
|
||||
use crate::common::{ExpandableInfo, UserClaims};
|
||||
use crate::pages::UnauthenticatedPage;
|
||||
use crate::components::{
|
||||
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
|
||||
Select, ChipsList
|
||||
Select,
|
||||
};
|
||||
use crate::input;
|
||||
use crate::pages::UnauthenticatedPage;
|
||||
use base64::Engine;
|
||||
use leptos::leptos_dom::logging::console_log;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos::wasm_bindgen::JsCast;
|
||||
use leptos::web_sys::HtmlAnchorElement;
|
||||
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
|
||||
use std::collections::HashMap;
|
||||
use traits::{AsReactive, ReactiveCapture};
|
||||
|
||||
/// # Get Student Info
|
||||
@@ -90,14 +97,47 @@ pub async fn create_sample_submission(student_info: ExpandableInfo) -> Result<()
|
||||
}
|
||||
}
|
||||
|
||||
#[server(LogExpandableInfo, endpoint = "/log-expandable")]
|
||||
pub async fn log_expandable(info: ExpandableInfo) -> Result<(), ServerFnError> {
|
||||
console_log(format!("Was given info: {:?}", info).as_str());
|
||||
// Manually specify the type here since we're not able to infer the type later.
|
||||
let item: HashMap<String, AttributeValue> = to_item(info)?;
|
||||
console_log(format!("Converted into a DynamoDB item: {:?}", item).as_str());
|
||||
#[server(CreateExamplePdf, endpoint = "/example-pdf")]
|
||||
pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> {
|
||||
// Create typst template. This will be replaced with getting the template from S3 in the future.
|
||||
// The path I'm thinking, for now, is that we generate a series of "members" from the available
|
||||
// keys that a student contains, prefixed with "student_". This means that to get the member
|
||||
// "unweighted_gpa" we'll use the preprocess variable "student_unweighted_gpa".
|
||||
let template = r#"
|
||||
= Testing Typst
|
||||
This form will create a new student application. The PDF that's generated (like this one)
|
||||
is a template that will be used later. We want to preprocess these templates to insert the
|
||||
correct values from the API later on.
|
||||
|
||||
Ok(())
|
||||
= Second Header
|
||||
This area is going to do some math because I think that's fun. We'll typeset a simple sum:
|
||||
$ sum_(i=0)^(10)
|
||||
(n_i) $
|
||||
|
||||
= Third Test
|
||||
This is just going to create a bulleted list inside a numbered list:
|
||||
+ Numbers
|
||||
+ Number again
|
||||
- Not a number.
|
||||
"#;
|
||||
|
||||
// Create and run the typst command.
|
||||
let mut command = Command::new("typst");
|
||||
command
|
||||
.arg("compile")
|
||||
.arg("-") // Use stdin as input
|
||||
.arg("-") // Write output to stdout
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped());
|
||||
|
||||
let mut process = command.spawn()?;
|
||||
|
||||
process.stdin.take().unwrap().write(template.as_bytes())?;
|
||||
|
||||
let output = process.wait_with_output()?;
|
||||
let pdf_bytes = output.stdout;
|
||||
|
||||
Ok(pdf_bytes)
|
||||
}
|
||||
|
||||
/// The main home page component. Contains a simple contact form.
|
||||
@@ -128,7 +168,6 @@ pub fn HomePage() -> impl IntoView {
|
||||
},
|
||||
);
|
||||
let submit_action = ServerAction::<CreateSampleSubmission>::new();
|
||||
let log_action = ServerAction::<LogExpandableInfo>::new();
|
||||
|
||||
view! {
|
||||
<AuthLoaded fallback=Loading>
|
||||
@@ -142,8 +181,6 @@ pub fn HomePage() -> impl IntoView {
|
||||
let expandable_react = submission.as_reactive();
|
||||
let elements_disabled = RwSignal::new(false);
|
||||
let result_msg = Signal::derive(move || {
|
||||
// Eventually, this function will show and hide a loading symbol
|
||||
// and a success checkmark.
|
||||
if submit_action.pending().get() {
|
||||
elements_disabled.set(true);
|
||||
"Sending request...".to_owned()
|
||||
@@ -152,17 +189,16 @@ pub fn HomePage() -> impl IntoView {
|
||||
if let Some(result) = submit_action.value().get() {
|
||||
match result {
|
||||
Ok(_) => "Request sent successfully.".to_owned(),
|
||||
Err(e) => format!("Failed to send request: {:?}", e)
|
||||
Err(e) => format!("Failed to send request: {:?}", e),
|
||||
}
|
||||
} else {
|
||||
"".to_owned()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Row>
|
||||
<div class="flex flex-col flex-1"/>
|
||||
<div class="flex flex-col flex-1" />
|
||||
<Panel>
|
||||
<Row>
|
||||
<h1 class="text-3xl font-bold">
|
||||
@@ -180,15 +216,15 @@ pub fn HomePage() -> impl IntoView {
|
||||
label="First Name:"
|
||||
placeholder="John"
|
||||
disabled=elements_disabled
|
||||
data_member = "first_name"
|
||||
data_map = expandable_react.data
|
||||
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
|
||||
data_member="last_name"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -196,8 +232,8 @@ pub fn HomePage() -> impl IntoView {
|
||||
label="Contact Email:"
|
||||
placeholder="student@region15.org"
|
||||
disabled=elements_disabled
|
||||
data_member = "contact_email"
|
||||
data_map = expandable_react.data
|
||||
data_member="contact_email"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -205,8 +241,8 @@ pub fn HomePage() -> impl IntoView {
|
||||
label="Phone Number:"
|
||||
placeholder="123-456-7890"
|
||||
disabled=elements_disabled
|
||||
data_member = "phone_number"
|
||||
data_map = expandable_react.data
|
||||
data_member="phone_number"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -214,8 +250,8 @@ pub fn HomePage() -> impl IntoView {
|
||||
label="Street Address:"
|
||||
placeholder="123 Fake Street"
|
||||
disabled=elements_disabled
|
||||
data_member = "address"
|
||||
data_map = expandable_react.data
|
||||
data_member="address"
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -223,9 +259,9 @@ pub fn HomePage() -> impl IntoView {
|
||||
label="Highest Math SAT Score:"
|
||||
placeholder="600"
|
||||
disabled=elements_disabled
|
||||
data_member = "math_sat"
|
||||
data_map = expandable_react.data
|
||||
input_type = "number"
|
||||
data_member="math_sat"
|
||||
data_map=expandable_react.data
|
||||
input_type="number"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
@@ -267,59 +303,13 @@ pub fn HomePage() -> impl IntoView {
|
||||
data_map=expandable_react.data
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ChipsList
|
||||
data_member="athletic_requirements"
|
||||
data_map=expandable_react.data
|
||||
displayed_text=vec!(
|
||||
"Football", "Soccer", "Cross Country", "Cheerleading",
|
||||
"Swimming", "Wrestling", "Ski", "Basketball",
|
||||
"Lacrosse", "Softball", "Indoor/Outdoor Track",
|
||||
"Golf", "Tennis", "Volleyball"
|
||||
)
|
||||
.into_iter().map(|s| s.to_owned())
|
||||
.collect()
|
||||
values=vec!(
|
||||
"football", "soccer", "cross_country", "cheerleading",
|
||||
"swimming", "wrestling", "ski", "basketball",
|
||||
"lacrosse", "softball", "track",
|
||||
"golf", "tennis", "volleyball"
|
||||
)
|
||||
.into_iter().map(|s| s.to_owned())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
label="Sports/Athletic Requirements"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ChipsList
|
||||
data_member="community_involvement"
|
||||
data_map=expandable_react.data
|
||||
displayed_text=vec!(
|
||||
"Lion's Club", "Knights of Columbus",
|
||||
"Community Service > 20hrs"
|
||||
)
|
||||
.into_iter().map(|s| s.to_owned())
|
||||
.collect()
|
||||
values=vec!(
|
||||
"lions_club", "koc", "serv_20h"
|
||||
)
|
||||
.into_iter().map(|s| s.to_owned())
|
||||
.collect()
|
||||
disabled=elements_disabled
|
||||
label="Required Community Involvement"
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<MultiEntry
|
||||
data_map = expandable_react.data
|
||||
data_member = "test"
|
||||
name_member = "first_name"
|
||||
schema = vec![
|
||||
input!(Text, "first_name", "First Name:", "John"),
|
||||
input!(Text, "last_name", "Last Name:", "Smith"),
|
||||
input!(Select, "gender", "Gender:", ["Male", "Female"]),
|
||||
input!(Checkbox, "candy", "Favorite Candy:", ["Twizzlers", "Starburst"])
|
||||
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>
|
||||
@@ -327,21 +317,49 @@ pub fn HomePage() -> impl IntoView {
|
||||
<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,
|
||||
});
|
||||
console_log(
|
||||
format!("Map values: {:?}", captured_map).as_str(),
|
||||
);
|
||||
submit_action
|
||||
.dispatch(CreateSampleSubmission {
|
||||
student_info: captured_map,
|
||||
});
|
||||
}
|
||||
disabled=elements_disabled
|
||||
>"Submit"</ActionButton>
|
||||
>
|
||||
"Submit"
|
||||
</ActionButton>
|
||||
</Row>
|
||||
<Row>
|
||||
<p>
|
||||
{result_msg}
|
||||
</p>
|
||||
<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>
|
||||
</Panel>
|
||||
<div class="flex flex-col flex-1"/>
|
||||
<div class="flex flex-col flex-1" />
|
||||
</Row>
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue};
|
||||
|
||||
#[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,
|
||||
};
|
||||
use chrono::{FixedOffset, TimeZone};
|
||||
use leptos::Params;
|
||||
use leptos::either::{Either, EitherOf3};
|
||||
use leptos::html::Dialog;
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
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};
|
||||
|
||||
#[derive(Params, PartialEq)]
|
||||
struct LoanerParams {
|
||||
form_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
struct LoanerReturnOutput {
|
||||
/// The subject ID of the student entry.
|
||||
subject: String,
|
||||
/// The first name of the student that is borrowing.
|
||||
first_name: String,
|
||||
/// The last name of the student that is borrowing.
|
||||
last_name: String,
|
||||
/// The date that the student borrowed the loaner.
|
||||
date: String,
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn get_loaners_return_list() -> Result<Vec<LoanerReturnOutput>, ServerFnError> {
|
||||
// We want to query the database for all loaners, but we only want the name and
|
||||
// date of borrowing. This will be cheaper than querying the entire table.
|
||||
let dbclient =
|
||||
Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await);
|
||||
|
||||
log!("Getting loaners from database.");
|
||||
|
||||
match dbclient
|
||||
.scan()
|
||||
.table_name("loaner-info")
|
||||
.projection_expression("subject, first_name, last_name, date_taken")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
if let Some(items) = output.items {
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let expandable: ExpandableInfo = from_item(item).unwrap();
|
||||
// This set of function calls is ridiculous, we need to make a better way to do this.
|
||||
LoanerReturnOutput {
|
||||
first_name: expandable
|
||||
.data
|
||||
.get("first_name")
|
||||
.unwrap()
|
||||
.as_string()
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default(),
|
||||
last_name: expandable
|
||||
.data
|
||||
.get("last_name")
|
||||
.unwrap()
|
||||
.as_string()
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default(),
|
||||
date: expandable
|
||||
.data
|
||||
.get("date_taken")
|
||||
.unwrap()
|
||||
.as_string()
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default(),
|
||||
subject: expandable.subject,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log!("{:?}", err);
|
||||
let msg = err.message().unwrap_or("Unknown error");
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn check_in_loaner(student: LoanerReturnOutput) -> Result<(), ServerFnError> {
|
||||
let dbclient =
|
||||
Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await);
|
||||
|
||||
match dbclient
|
||||
.delete_item()
|
||||
.table_name("loaner-info")
|
||||
.key("subject", AttributeValue::S(student.subject))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
log!("{:?}", err);
|
||||
let msg = err.message().unwrap_or("Unknown error");
|
||||
Err(ServerFnError::new(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> {
|
||||
let mut input = input;
|
||||
let dbclient =
|
||||
Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await);
|
||||
|
||||
let current_time = chrono::Utc::now().format("%H:%M, %m/%d/%Y").to_string();
|
||||
log!("Creating entry with timestamp {}", current_time);
|
||||
|
||||
// Add the timestamp to the data map as a string and then push it to the database.
|
||||
input.data.insert(
|
||||
"date_taken".to_string(),
|
||||
ValueType::String(Some(current_time.clone())),
|
||||
);
|
||||
|
||||
loop {
|
||||
let item: serde_dynamo::Item = to_item(&input)?;
|
||||
match dbclient
|
||||
.put_item()
|
||||
.table_name("loaner-info")
|
||||
.set_item(Some(HashMap::from(item.clone())))
|
||||
.condition_expression("attribute_not_exists(subject)")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => return Ok(()),
|
||||
Err(err) => {
|
||||
log!("{:?}", err);
|
||||
match err.code() {
|
||||
Some("ConditionalCheckFailedException") => {
|
||||
// Retry database writes until we have a good subject ID.
|
||||
log!("Failed conditional check, forcing new subject ID.");
|
||||
input.subject = uuid::Uuid::new_v4().to_string();
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
let msg = err.message().unwrap_or("An unknown error occurred.");
|
||||
return Err(ServerFnError::new(msg));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// # Loaner Page
|
||||
///
|
||||
/// This page is going to temporarily piggyback on the general scholarship app, at least until
|
||||
/// we spin off the components used here into a new standalone crate.
|
||||
///
|
||||
/// The loaner app handles management of Chromebook loaners to students. It was originally hosted
|
||||
/// using Google Apps Script, but has recently been broken by some unknown API error. I've chosen
|
||||
/// to rewrite this into a single new application, which we will host in AWS with a real database.
|
||||
///
|
||||
/// It should use all the same components as the scholarship loaner, maybe with a few extra
|
||||
/// alterations. The page will only use a single database table, and it will only have a few very
|
||||
/// basic APIs.
|
||||
///
|
||||
/// # Design
|
||||
///
|
||||
/// The page should have two sections - one for logging a loaner that's being taken out (borrowing)
|
||||
/// and another for a loaner that's being brought back (returning). As of right now, we have two
|
||||
/// separate buttons that handle these functions, and they slide open a panel at the bottom of the
|
||||
/// screen. The borrowing menu has a single, simple form, and the returning side has a list of
|
||||
/// buttons that allow students to choose their name and remove it from the list.
|
||||
///
|
||||
/// # New features
|
||||
///
|
||||
/// I believe that with a new workflow and an overhauled server with real data management, we should
|
||||
/// 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 {
|
||||
// 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 || {
|
||||
params
|
||||
.read()
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|params| params.form_name.clone())
|
||||
.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! {
|
||||
<Banner
|
||||
title="Chromebook Loaners"
|
||||
logo="/PHS_Stacked_Acronym.png"
|
||||
path="/loaners"
|
||||
/>
|
||||
<div class="flex m-5">
|
||||
<div class="flex flex-1 flex-row gap-3">
|
||||
<div id="sidebar" class="flex flex-1 flex-col gap-2">
|
||||
<DashboardButton
|
||||
title="Borrowing"
|
||||
description="Select to take out a loaner."
|
||||
icon="/Edit.png"
|
||||
path="/loaners/borrowing"
|
||||
/>
|
||||
<DashboardButton
|
||||
title="Returning"
|
||||
description="Select to return a loaner."
|
||||
icon="/Person_Black.png"
|
||||
path="/loaners/returning"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-2">
|
||||
<Panel>{form_view}</Panel>
|
||||
</div>
|
||||
</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);
|
||||
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoanerReturnForm() -> impl IntoView {
|
||||
// Register server actions
|
||||
let check_in_action = ServerAction::<CheckInLoaner>::new();
|
||||
|
||||
// Register server resources
|
||||
let return_list_refresh = RwSignal::new(0);
|
||||
let return_list_resource: Resource<Vec<LoanerReturnOutput>> = Resource::new(
|
||||
move || return_list_refresh.get(),
|
||||
async |_| {
|
||||
get_loaners_return_list().await.unwrap_or_else(|e| {
|
||||
log!("There was an error getting the loaner list: {}", e);
|
||||
vec![]
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
let dialog_ref = NodeRef::<Dialog>::new();
|
||||
let chosen_student = RwSignal::new(None::<LoanerReturnOutput>);
|
||||
let dialog_disabled = RwSignal::new(false);
|
||||
|
||||
// Create an effect to run after check-in - refreshes the list with another scan request.
|
||||
// This could be beneficial if we ever decided to make multiple loaner kiosks (think SAT days)
|
||||
Effect::new(move || {
|
||||
if let Some(Ok(_)) = check_in_action.value().get() {
|
||||
// Check the local loaner list and remove the indicated student from it.
|
||||
return_list_refresh.update(|n| *n += 1);
|
||||
if let Some(dialog) = dialog_ref.get() {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// We can't use a Memo in our custom components for the disabled signal.
|
||||
Effect::new(move || {
|
||||
if check_in_action.pending().get() {
|
||||
dialog_disabled.set(true);
|
||||
} else {
|
||||
dialog_disabled.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handles checking in a student.
|
||||
let dialog_check_in = move |_| {
|
||||
chosen_student.with(|opt_student| {
|
||||
if let Some(student) = opt_student.as_ref() {
|
||||
check_in_action.dispatch(CheckInLoaner {
|
||||
student: student.clone(),
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Handles closing the dialog menu.
|
||||
let close_dialog = move |_| {
|
||||
if let Some(dialog) = dialog_ref.get() {
|
||||
dialog.close();
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<dialog
|
||||
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>
|
||||
<h4 class="text-xl mb-3">
|
||||
{move || chosen_student.with(|opt_student| {
|
||||
if let Some(student) = opt_student.as_ref() {
|
||||
format!("Are you sure want to check in {} {}?", student.first_name, student.last_name)
|
||||
} else {
|
||||
"There is no student selected.".to_string()
|
||||
}
|
||||
})}
|
||||
</h4>
|
||||
<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">
|
||||
{move || {
|
||||
match return_list_resource.get() {
|
||||
Some(loaner_list) => {
|
||||
let list_view = loaner_list.iter().map(|student| {
|
||||
let on_click = {
|
||||
let student_click = student.clone();
|
||||
move |_| {
|
||||
log!("Running on_click using student {:?}", student_click);
|
||||
if let Some(dialog) = dialog_ref.get() {
|
||||
chosen_student.set(Some(student_click.clone()));
|
||||
dialog.show_modal().expect("Couldn't show modal dialog.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let naive_time = chrono::NaiveDateTime::parse_from_str(&student.date, "%H:%M, %m/%d/%Y")
|
||||
.unwrap();
|
||||
let time_zone = FixedOffset::west_opt(5 * 3600).unwrap();
|
||||
let student_date = time_zone.from_utc_datetime(&naive_time)
|
||||
.format("%l:%M %p, %m/%d/%Y")
|
||||
.to_string();
|
||||
|
||||
log!("Found current date {:?}", student_date);
|
||||
|
||||
view! {
|
||||
<div class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33" on:click=on_click>
|
||||
<div class="flex-1">
|
||||
{student.first_name.clone()}" "{student.last_name.clone()}
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
{student_date.clone()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect_view();
|
||||
|
||||
Either::Left(list_view)
|
||||
},
|
||||
None => Either::Right("No loaners found.".into_view())
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -1,6 +1,15 @@
|
||||
mod about_page;
|
||||
mod api;
|
||||
mod comparison_test;
|
||||
mod home_page;
|
||||
mod loaner_page;
|
||||
mod provider_portal;
|
||||
mod scholarship_info;
|
||||
mod test_page;
|
||||
mod unauthenticated_page;
|
||||
mod utils;
|
||||
|
||||
pub use self::{about_page::*, home_page::*, test_page::*, unauthenticated_page::*};
|
||||
pub use self::{
|
||||
about_page::*, comparison_test::*, home_page::*, loaner_page::LoanerPage, provider_portal::*,
|
||||
test_page::*, unauthenticated_page::*,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
use super::UnauthenticatedPage;
|
||||
use super::api::{
|
||||
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::prelude::*;
|
||||
use leptos_oidc::{AuthLoaded, Authenticated};
|
||||
use leptos_router::hooks::use_params;
|
||||
use traits::{AsReactive, ReactiveCapture};
|
||||
|
||||
/// # Scholarship Info Page
|
||||
/// 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.
|
||||
///
|
||||
/// In its current testing form, we'll be using a single fixed ID. This absolutely *MUST* be
|
||||
/// changed before a full release.
|
||||
#[component]
|
||||
pub fn ScholarshipInfoPage() -> impl IntoView {
|
||||
// let scholarship_id = String::from("test-scholarship-id2");
|
||||
let url_params = use_params::<ScholarshipFormParams>();
|
||||
let scholarship_id = move || {
|
||||
url_params
|
||||
.read()
|
||||
.as_ref()
|
||||
.ok()
|
||||
.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();
|
||||
|
||||
// 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! {
|
||||
<AuthLoaded fallback=Loading>
|
||||
<Authenticated unauthenticated=UnauthenticatedPage>
|
||||
<Suspense fallback=Loading>
|
||||
<Row>
|
||||
<div class="flex flex-col flex-1" />
|
||||
{move || Suspend::new(async move {
|
||||
let response = get_scholarship_action.get();
|
||||
let comparisons = get_comparison_info.get();
|
||||
|
||||
response
|
||||
.zip(comparisons)
|
||||
.map(|(response, comparison_list)| view! {
|
||||
<ScholarshipForm
|
||||
response=response
|
||||
comparison_list=comparison_list
|
||||
submit_action=create_scholarship_action
|
||||
/>
|
||||
})
|
||||
.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>
|
||||
</AuthLoaded>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ScholarshipForm(
|
||||
response: ExpandableInfo,
|
||||
comparison_list: Vec<ComparisonData>,
|
||||
submit_action: ServerAction<CreateScholarshipInfo>,
|
||||
) -> impl IntoView {
|
||||
let reactive_info = response.as_reactive();
|
||||
let elements_disabled = RwSignal::new(false);
|
||||
let result_msg = Signal::derive(move || {
|
||||
if submit_action.pending().get() {
|
||||
elements_disabled.set(true);
|
||||
"Sending request...".to_string()
|
||||
} else {
|
||||
elements_disabled.set(false);
|
||||
if let Some(result) = submit_action.value().get() {
|
||||
match result {
|
||||
Ok(()) => "Request sent successfully".to_string(),
|
||||
Err(err) => {
|
||||
format!("An error occurred: {:?}", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
|
||||
view! {
|
||||
<Panel>
|
||||
<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_list
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|comp| comp.id)
|
||||
.collect()
|
||||
displayed_text=comparison_list
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|comp| comp.display_text)
|
||||
.collect()
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<ActionButton
|
||||
disabled=elements_disabled
|
||||
on:click=move |_| {
|
||||
let captured = reactive_info.capture();
|
||||
log!("Map values: {:?}", captured);
|
||||
submit_action.dispatch(CreateScholarshipInfo {
|
||||
info: captured
|
||||
});
|
||||
}
|
||||
>"Submit"</ActionButton>
|
||||
</Row>
|
||||
<Row>
|
||||
<p>{result_msg}</p>
|
||||
</Row>
|
||||
</Panel>
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::components::ActionButton;
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_oidc::AuthSignal;
|
||||
|
||||
@@ -12,6 +13,8 @@ pub fn UnauthenticatedPage() -> impl IntoView {
|
||||
})
|
||||
};
|
||||
|
||||
Effect::new(move || log!("Generated login url: {:?}", login_url()));
|
||||
|
||||
let navigate = leptos_router::hooks::use_navigate();
|
||||
|
||||
view! (
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/// # Server Utilities
|
||||
///
|
||||
/// These utility functions can only be used from the server. They are created to use server-side
|
||||
/// dependencies like the AWS SDK. Use of these functions will crash the front end, as these
|
||||
/// dependencies cannot be compiled for WASM (nor should they be).
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod server_utils {
|
||||
use aws_config::SdkConfig;
|
||||
|
||||
/// Creates an [`SdkConfig`] struct for use with AWS SDK structs.
|
||||
pub async fn create_aws_config() -> SdkConfig {
|
||||
aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await
|
||||
}
|
||||
|
||||
/// Creates a DynamoDB client.
|
||||
pub async fn create_dynamo_client() -> aws_sdk_dynamodb::Client {
|
||||
aws_sdk_dynamodb::Client::new(&create_aws_config().await)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user