From 97ca4a64e94355074eab51d7080e0c58fd34bd74 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 23 Oct 2025 14:00:59 -0400 Subject: [PATCH 01/22] feat: implement demo for pdf generation using typst --- Cargo.lock | 3 +- Cargo.toml | 3 +- src/pages/home_page.rs | 78 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa67db7..4017ea9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2938,7 +2938,7 @@ dependencies = [ "aws-config", "aws-sdk-dynamodb", "axum", - "chrono", + "base64 0.22.1", "console_error_panic_hook", "leptos", "leptos_axum", @@ -2946,7 +2946,6 @@ dependencies = [ "leptos_oidc", "leptos_router", "macros", - "reactive_stores", "serde", "serde_dynamo", "tokio", diff --git a/Cargo.toml b/Cargo.toml index ed0fae9..059da02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,11 +22,10 @@ serde_dynamo = { version = "4.2.14", features = ["aws-sdk-dynamodb+1"], optional uuid = { version = "1.17.0", features = ["serde", "v4", "js"] } leptos_oidc = "0.9.0" url = "2.5.4" +base64 = "0.22.1" macros = { path = "./macros" } traits = { path = "./traits" } -chrono = { version = "0.4.42", features = ["serde"] } -reactive_stores = "0.2.5" [features] hydrate = [ diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 7c78aec..bec7509 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -1,9 +1,12 @@ // 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::process::{Command, Stdio}; +#[cfg(feature = "ssr")] +use std::io::Write; use crate::common::{ExpandableInfo, UserClaims}; use crate::pages::UnauthenticatedPage; @@ -16,6 +19,11 @@ use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; use std::collections::HashMap; +use base64::Engine; +use leptos::task::spawn_local; +use leptos::web_sys::HtmlAnchorElement; +use leptos_router::hooks::use_navigate; +use leptos::wasm_bindgen::JsCast; use traits::{AsReactive, ReactiveCapture}; /// # Get Student Info @@ -100,6 +108,48 @@ pub async fn log_expandable(info: ExpandableInfo) -> Result<(), ServerFnError> { Ok(()) } +#[server(CreateExamplePdf, endpoint = "/example-pdf")] +pub async fn create_example_pdf() -> Result, ServerFnError> { + // Create typst template. This will be replaced with getting the template from S3 in the future. + 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. + + = 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. #[component] pub fn HomePage() -> impl IntoView { @@ -129,6 +179,9 @@ pub fn HomePage() -> impl IntoView { ); let submit_action = ServerAction::::new(); let log_action = ServerAction::::new(); + let pdf_action = ServerAction::::new(); + + let navigate = use_navigate(); view! { @@ -322,6 +375,29 @@ pub fn HomePage() -> impl IntoView { disabled=elements_disabled >"Submit" + + () + .unwrap(); + link.set_href(&*data_url); + link.set_target("_blank"); + link.click(); + } + }); + } + >"Get PDF" +

{result_msg} From 1402fd24d918ca08c481d36124e0fc90cda9f735 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 23 Oct 2025 14:06:52 -0400 Subject: [PATCH 02/22] chore: remove unused variables and functions --- src/pages/home_page.rs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index bec7509..36c7aa0 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -18,11 +18,9 @@ use crate::input; use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; -use std::collections::HashMap; use base64::Engine; use leptos::task::spawn_local; use leptos::web_sys::HtmlAnchorElement; -use leptos_router::hooks::use_navigate; use leptos::wasm_bindgen::JsCast; use traits::{AsReactive, ReactiveCapture}; @@ -98,16 +96,6 @@ 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 = to_item(info)?; - console_log(format!("Converted into a DynamoDB item: {:?}", item).as_str()); - - Ok(()) -} - #[server(CreateExamplePdf, endpoint = "/example-pdf")] pub async fn create_example_pdf() -> Result, ServerFnError> { // Create typst template. This will be replaced with getting the template from S3 in the future. @@ -178,10 +166,6 @@ pub fn HomePage() -> impl IntoView { }, ); let submit_action = ServerAction::::new(); - let log_action = ServerAction::::new(); - let pdf_action = ServerAction::::new(); - - let navigate = use_navigate(); view! { From 431a8f244ab3b8fb6ff41d5bcb4d014af78d565b Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Fri, 24 Oct 2025 13:46:13 -0400 Subject: [PATCH 03/22] chore: add comment for ideas later --- src/pages/home_page.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 36c7aa0..2cf3138 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -99,6 +99,9 @@ pub async fn create_sample_submission(student_info: ExpandableInfo) -> Result<() #[server(CreateExamplePdf, endpoint = "/example-pdf")] pub async fn create_example_pdf() -> Result, 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) From 83e46f4064cd9b517c14c0173233d0b77ef7ccdf Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Tue, 18 Nov 2025 13:17:32 -0500 Subject: [PATCH 04/22] feat: implement comparison skeleton This commit lays out the general idea of how the ComparisonData struct will work. It is expandable and I'm sure we will add more to it as time goes on. --- docs/schema_notes.pdf | Bin 0 -> 36180 bytes docs/schema_notes.typ | 113 ++++++++ src/app.rs | 29 +- src/common/comparison/base.rs | 91 ++++++ src/common/comparison/lists.rs | 477 ++++++++++++++++++++++++++++++++ src/common/comparison/mod.rs | 145 ++++++++++ src/common/comparison/number.rs | 44 +++ src/common/comparison/text.rs | 36 +++ src/common/comparison/traits.rs | 5 + src/common/comparison/utils.rs | 28 ++ src/common/expandable_info.rs | 4 +- src/common/mod.rs | 5 +- src/lib.rs | 1 + src/pages/comparison_test.rs | 89 ++++++ src/pages/home_page.rs | 157 ++++++----- src/pages/mod.rs | 3 +- src/utils/mod.rs | 23 ++ 17 files changed, 1161 insertions(+), 89 deletions(-) create mode 100644 docs/schema_notes.pdf create mode 100644 docs/schema_notes.typ create mode 100644 src/common/comparison/base.rs create mode 100644 src/common/comparison/lists.rs create mode 100644 src/common/comparison/mod.rs create mode 100644 src/common/comparison/number.rs create mode 100644 src/common/comparison/text.rs create mode 100644 src/common/comparison/traits.rs create mode 100644 src/common/comparison/utils.rs create mode 100644 src/pages/comparison_test.rs create mode 100644 src/utils/mod.rs diff --git a/docs/schema_notes.pdf b/docs/schema_notes.pdf new file mode 100644 index 0000000000000000000000000000000000000000..881240e31dc82a47ecd4e874673fd44e40f86a9b GIT binary patch literal 36180 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 literal 0 HcmV?d00001 diff --git a/docs/schema_notes.typ b/docs/schema_notes.typ new file mode 100644 index 0000000..ab59562 --- /dev/null +++ b/docs/schema_notes.typ @@ -0,0 +1,113 @@ +#set heading(numbering: "1.1") +#set par(justify: true) + += Purpose + +The purpose of this document is to define how the binding and filtering +algorithm works within the Region 15 Scholarship Application. As such, it +must define how the student and provider schema works and is editable by the +application administrators. + += Student and Provider Schema + +Administrators of the program shall be able to define a specific structure +for the data that should be collected from the students and providers. This +requires the ability to edit forms based on a series of options. + +== Data Types + +The application shall have a series of data types, so far limited to: + - Text + - Numbers + - Lists (multiple text or number values) + - Mapped values (larger structures comprised of multiple separate text, number, or list values) + +== Input Types + +The application has a series of components that have been or will be built +to support this data collection: + - `OutlinedTextField`: supports text and numbers + - `Dropdown`: supports strictly defined text + - `CheckboxList`: supports strictly defined text lists + - `RadioList`: supports strictly defined text + - `MultiEntry`: supports mapped values + +Student information is defined within a specific form schema. The required values +for each input type to work is well-defined, and shall be editable by the scholarship +administrators. An interface shall be created that allows for editing with a simple +preview. + += Student and Provider Binding + +For the purposes of this document, binding is defined as the ability to relate data +members defined in the student and provider data structures so that they may be +procedurally or programmatically filtered through a database. For example, binding +the `first_name` member from the student with the `scholarship_name` member from the +provider means that the application administrators shall be able to perform comparisons +from the `first_name` to the `scholarship_name`, or vice versa. + +== Data Types in comparisons + +Values that do not have the same type shall not be bindable. For example, a member that +contains a text value can _only_ be bound with another member that also contains text. +The same applies for numbers and lists. + +Mapped values are slightly different, in that they contain a series of extra values. +Since mapped values contain other data types, binding must happen on a more precise scale. +The administrators shall be able to choose individual members from within the map and link +them to other members of the same type. For example, a data map `example_map` contains a +member `text_member`. Binding `text_member` is only possible with another member that +contains text. + += Methodology + +== Constraints + +Administrators will create a series of comparisons dependent upon the types of the members +that are being bound. For example, number values will present inequality options. Text +values are a little more complex, in that they may have a specific set of options that +are available (users are shown a `RadioList` or `Dropdown`). Depending on what these +values are defined as, the available options for comparison need to match. + +The full list of possible comparisons is as follows: + - Numbers: inequality operators ($>$, $<$, $<=$, $>=$, $=$) + - Text: + - Single text values: direct matching based on input string + - Selection text values: direct matching based on string options + - Multiple text values: + +== Appearance + +Administrators will be able to select a specific schema - provider or student - and the +members will be displayed in a list-like format. Depending on the type of each member +shown, the available options for comparisons will follow the rules above. The +administrators can choose and create multiple of these conditions, which will be +displayed to the providers while they are choosing requirements. + +Comparisons will have a member that contains a "display text." This text is displayed +to the providers and acts as an abstraction for the actual comparison. For example, +a GPA comparison may consist of the `Student.gpa` member, a $>$ condition, and a +conditional value of `3.2`. The display text for this comparison may be "GPA $> 3.2$", +or anything that the administrator may choose. + +After being created, comparisons will be sortable within the list that the providers +will see. The list may have multiple categories and multiple conditions may be selected +by the providers. + +Members with associated comparisons shall have a boolean value that indicates whether +multiple conditions are selectable. For example, perhaps a provider wants to select +multiple sports (`athletics` has several comparisons, one for each sport). The +`athletics` member will be able to determine that multiple values are selectable, +allowing the providers to select multiple types of sports. + +*NOTE: The issue with this is that even though multiple options are selected, we do not +currently have a method to determine if the options are joined with an AND or OR +condition. This means that selecting multiple sports does not distinguish between +a student playing football AND soccer, or a student playing football OR soccer.* + += Tasks left to do: + + - finalize dashboard plan + - create filtering algorithm (and more theory?) + - file input component + - pdf generation diff --git a/src/app.rs b/src/app.rs index e854ce4..84079ef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,27 +1,27 @@ use leptos::prelude::*; -use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; -use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge, LoginLink}; +use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; +use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_router::{ - StaticSegment, components::{Route, Router, Routes}, + StaticSegment, }; use url::Url; -use crate::pages::{AboutPage, HomePage, UnauthenticatedPage}; +use crate::pages::{AboutPage, ComparisonTestPage, HomePage}; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { - - + + - - + + - + } @@ -45,28 +45,29 @@ pub fn App() -> impl IntoView { let auth = Auth::signal(); provide_context(auth); // allows use of this signal in lower areas of the tree without - // explicitly passing it through the html tree + // explicitly passing it through the html tree let _ = Auth::init(parameters); view! { // injects a stylesheet into the document // id=leptos means cargo-leptos will hot-reload this stylesheet - + //

"Authentication is loading"

// // sets the document title - + <Title text="R15 Scholarship App DEV" /> // content for this welcome page <Router> <main> // TODO Create a 404 page <Routes fallback=|| "Page not found.".into_view()> - <Route path=StaticSegment("") view=HomePage/> - <Route path=StaticSegment("about") view=AboutPage/> + <Route path=StaticSegment("") view=HomePage /> + <Route path=StaticSegment("about") view=AboutPage /> + <Route path=StaticSegment("comparison") view=ComparisonTestPage /> </Routes> </main> </Router> diff --git a/src/common/comparison/base.rs b/src/common/comparison/base.rs new file mode 100644 index 0000000..d8ea50c --- /dev/null +++ b/src/common/comparison/base.rs @@ -0,0 +1,91 @@ +use super::{ + Comparison, MapListComparison, NestedListComparison, NumberComparison, NumberListComparison, + TextComparison, TextListComparison, +}; +use crate::common::{ExpandableInfo, ValueType}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct ComparisonData { + /// Each comparison will have a unique ID. + pub id: String, + /// The member from the Student structure to match against. + pub member: String, + /// The comparison to perform. + pub comparison: ComparisonType, + /// The value to match against. + pub target_value: ValueType, + /// The display category for this comparison. Affects where this comparison + /// will be displayed in the provider side of the application. + pub category: String, + /// The text that will be displayed on the provider side. + pub display_text: String, +} + +impl ComparisonData { + pub fn compare(&self, student_data: &ExpandableInfo) -> Result<bool, String> { + let value = student_data.data.get(&self.member).unwrap(); + self.comparison.evaluate(value, &self.target_value) + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum ComparisonType { + Number(NumberComparison), + Text(TextComparison), + TextList(TextListComparison), + NumberList(NumberListComparison), + MapList(MapListComparison), + NestedList(NestedListComparison), +} + +impl Comparison for ComparisonType { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + match self { + ComparisonType::Number(n) => n.evaluate(comp_value, target_value), + ComparisonType::Text(t) => t.evaluate(comp_value, target_value), + ComparisonType::NumberList(l) => l.evaluate(comp_value, target_value), + ComparisonType::MapList(m) => m.evaluate(comp_value, target_value), + ComparisonType::TextList(tl) => tl.evaluate(comp_value, target_value), + ComparisonType::NestedList(nl) => nl.evaluate(comp_value, target_value), + } + } +} + +#[cfg(test)] +mod tests { + use crate::common::comparison::test_utils::create_student_data; + use super::*; + + #[test] + fn test_student_data() { + let student_data = create_student_data(); + + // Should succeed every time. + let text_comp_success = ComparisonData { + id: "first_name_success".to_string(), + member: "first_name".to_string(), + comparison: ComparisonType::Text(TextComparison::Matches), + target_value: ValueType::String(Some("John".to_string())), + category: "Basic Checks".to_string(), + display_text: "First Name is John".to_string(), + }; + + // Uses an incorrect target_value type. + let text_comp_fail = ComparisonData { + id: "first_name_fail".to_string(), + member: "first_name".to_string(), + comparison: ComparisonType::Text(TextComparison::Matches), + target_value: ValueType::Number(Some("Jane".to_string())), + category: "Basic Checks".to_string(), + display_text: "First Name is Jane".to_string(), + }; + + let mut result = text_comp_success.compare(&student_data); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), true); + + result = text_comp_fail.compare(&student_data); + assert!(result.is_err()); + } +} diff --git a/src/common/comparison/lists.rs b/src/common/comparison/lists.rs new file mode 100644 index 0000000..c335dd6 --- /dev/null +++ b/src/common/comparison/lists.rs @@ -0,0 +1,477 @@ +use super::{Comparison, NumberComparison}; +use crate::common::ValueType; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub enum NumberListComparison { + /// Sums the values in the list and checks them using the given [`NumberComparison`]. + /// Requires the list to contain only numbers. + Sum(Box<NumberComparison>), + /// Checks if the list contains a specific value. + Contains, + /// Checks if the list does not contain a specific value. + NotContains, +} + +impl Comparison for NumberListComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + match self { + NumberListComparison::Sum(num_comp) => { + // Requires the list to contain only numbers. Get this list or throw an error. + let student_list = match comp_value { + ValueType::List(Some(list)) => list, + _ => return Err("Expected a list".into()), + }; + + let sum = student_list.iter().try_fold(0.0, |acc, item| match item { + ValueType::Number(Some(num_str)) => { + let num = num_str.parse::<f64>().unwrap(); + Ok(acc + num) + } + _ => Err("Expected a number".into()), + }); + + // If the sum is not a number, return an error. + let sum = match sum { + Ok(sum) => sum, + Err(err) => return Err(err), + }; + + // Finally perform the comparison using this sum... we don't have a function for this yet. + match num_comp.evaluate(&ValueType::Number(Some(sum.to_string())), target_value) { + Ok(result) => Ok(result), + Err(err) => Err(err), + } + } + NumberListComparison::Contains => { + // Get list and check if it contains the target value. + // This will check the type and the value, in that order. If the type does not match, + // this will return false. + let list = match comp_value { + ValueType::List(Some(vec)) => vec, + _ => return Err("Expected a list".into()), + }; + + // Check if the list contains the target value. + Ok(list.contains(target_value)) + } + _ => Err("Not implemented".into()), + } + } +} + +/// Represents all available comparisons for a text list. All comparisons are comparable to a +/// single value. +/// +/// For example, using `TextListComparison::Contains` checks if a specific value is contained +/// in the list. +#[derive(Serialize, Deserialize, Debug)] +pub enum TextListComparison { + /// Checks if the list contains the target value. + Contains, + /// Checks if the list does not contain the target value. + NotContains, + /// Checks if the list is empty. + IsEmpty, + /// Checks if the list is not empty. + IsNotEmpty, +} + +impl Comparison for TextListComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + match self { + TextListComparison::Contains => { + let list_opt = match comp_value { + ValueType::List(list) => list, + _ => return Err("Expected a list".into()), + }; + + // If the list exists, check if it contains the target value. + // Otherwise, return false. Note that this is not a strongly typed comparison - if + // the list contains multiple different types, no error is thrown. + let check = match list_opt { + Some(list) => list.contains(target_value), + None => false, + }; + + Ok(check) + } + TextListComparison::NotContains => { + let list_opt = match comp_value { + ValueType::List(list) => list, + _ => return Err("Expected a list".into()), + }; + + // If the list exists, check if it does not contain the target value. + // Otherwise, return true. + let check = match list_opt { + Some(list) => !list.contains(target_value), + None => true, + }; + + Ok(check) + } + TextListComparison::IsEmpty => { + let list_opt = match comp_value { + ValueType::List(list) => list, + _ => return Err("Expected a list".into()), + }; + + // If the list exists, check if it is empty. + // Otherwise, return false. + let check = match list_opt { + Some(list) => list.is_empty(), + None => true, + }; + + Ok(check) + } + TextListComparison::IsNotEmpty => { + let list_opt = match comp_value { + ValueType::List(list) => list, + _ => return Err("Expected a list".into()), + }; + + // If the list exists, check if it is not empty. + // Otherwise, return true. + let check = match list_opt { + Some(list) => !list.is_empty(), + None => false, + }; + + Ok(check) + } + } + } +} + +/// For right now, nested lists should be flattened into a list of a single type before they +/// are compared. The plan is to create a more robust framework that eventually be able to +/// query specific lists and perform comparisons on them. +/// +/// For example: +/// ``` +/// NestedListComparison::FlattenToTextList(Box::new(TextListComparison::Contains)); +/// ``` +/// +/// This will flatten a nested list into a single list of `ValueType::String` enums and then +/// will check if the list contains the indicated target value. +#[derive(Serialize, Deserialize, Debug)] +pub enum NestedListComparison { + /// Flattens a nested list into a single text list, then performs the given comparison. + FlattenToTextList(Box<TextListComparison>), +} + +impl Comparison for NestedListComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + match self { + NestedListComparison::FlattenToTextList(t) => { + // Check the comp_value for a series of lists. We'll then iterate over each list and flatten it to a text list. + // The end type should be a ValueType::List(Option<Vec<T>>), where T is ValueType::String. + let list = match comp_value { + ValueType::List(Some(list)) => list, + _ => return Err("Expected a list of values".to_owned()), + }; + + let mut out: Vec<ValueType> = Vec::new(); + + for item in list { + // Every item should be another list. + let sub_list = match item { + ValueType::List(Some(sub_list)) => sub_list, + _ => return Err("Expected a list of lists".to_owned()), + }; + + for sub_item in sub_list { + match sub_item { + s @ ValueType::String(_) => out.push(s.clone()), + _ => return Err("Expected a list of strings".to_owned()), + } + } + } + + t.evaluate(&ValueType::List(Some(out)), target_value) + } + } + } +} + +/// Lists of maps must be flattened to a list of some type before they can be compared. +/// Flattening maps simply extracts the values from a specific key from all maps in the list. +/// This makes comparison much simpler to implement and understand. +/// +/// All flattening operations also contain a `String` key (the map's member to flatten) +/// and some `Comparison` enum that matches the type of the flattened values. Flattening will +/// fail if any single map in the list does not contain the specified key or if the value +/// is not of the expected type. +/// +/// For example, to flatten a list of maps by using the `name` key, we would use the following +/// structure: +/// ``` +/// MapListComparison::FlattenToTextList("name".to_owned(), Box::new(TextListComparison::Equal)); +/// ``` +#[derive(Serialize, Deserialize, Debug)] +pub enum MapListComparison { + /// Flattens the map list to a text list and then performs the given comparison. + FlattenToTextList(String, Box<TextListComparison>), + /// Flattens the map list to a number list and then performs the given comparison. + FlattenToNumberList(String, Box<NumberListComparison>), + /// Flattens the map list to a nested list and then performs the given comparison. + FlattenToNestedList(String, Box<NestedListComparison>), +} + +impl Comparison for MapListComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + // Define base list - this must be present otherwise we cannot proceed. + let list = match comp_value { + ValueType::List(Some(list)) => list, + ValueType::List(None) => &Vec::new(), + _ => return Err("Expected a list".to_owned()), + }; + + match self { + MapListComparison::FlattenToTextList(key, comp) => { + let mut out = Vec::new(); + + for item in list { + let map = match item { + ValueType::Map(Some(map)) => map, + _ => return Err("Expected a map".to_string()), + }; + + // Ignore any non-string values. + if let Some(text @ ValueType::String(_)) = map.get(key) { + out.push(text.clone()); + } + } + + let flattened = ValueType::List(Some(out)); + comp.evaluate(&flattened, target_value) + } + MapListComparison::FlattenToNumberList(key, comp) => { + let mut out = Vec::new(); + for item in list { + let map = match item { + ValueType::Map(Some(map)) => map, + _ => return Err("Expected a map".to_string()), + }; + + // Ignore any non-number values. + if let Some(number @ ValueType::Number(_)) = map.get(key) { + out.push(number.clone()); + } + } + + let flattened = ValueType::List(Some(out)); + comp.evaluate(&flattened, target_value) + } + MapListComparison::FlattenToNestedList(key, comp) => { + let mut out = Vec::new(); + for item in list { + let map = match item { + ValueType::Map(Some(map)) => map, + _ => return Err("Expected a map".to_string()), + }; + + // Ignore any non-list values. + if let Some(list @ ValueType::List(_)) = map.get(key) { + out.push(list.clone()); + } + } + + let flattened = ValueType::List(Some(out)); + comp.evaluate(&flattened, target_value) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::test_utils::{create_map_list_helper}; + use crate::common::{ComparisonType}; + use crate::common::comparison::test_utils::{create_empty_lists, create_number_list, create_text_list}; + + #[test] + fn map_list_flatten_to_text_list() { + let map_list = create_map_list_helper(); + + // Define our comparison, paying close attention to the target values. + let first_name_comp = ComparisonType::MapList(MapListComparison::FlattenToTextList( + "first_name".to_string(), + Box::new(TextListComparison::Contains), + )); + + let last_name_comp = ComparisonType::MapList(MapListComparison::FlattenToTextList( + "last_name".to_string(), + Box::new(TextListComparison::Contains), + )); + + let gender_comp = ComparisonType::MapList(MapListComparison::FlattenToTextList( + "gender".to_string(), + Box::new(TextListComparison::Contains), + )); + + let first_name_result = + first_name_comp.evaluate(&map_list, &ValueType::String(Some("Jane".to_string()))); + let last_name_result = + last_name_comp.evaluate(&map_list, &ValueType::String(Some("Fails".to_string()))); + let gender_result = + gender_comp.evaluate(&map_list, &ValueType::String(Some("Female".to_string()))); + + // We have one map that contains the target value (map_two.first_name) + assert_eq!(first_name_result.unwrap(), true); + assert_eq!(last_name_result.unwrap(), false); + assert_eq!(gender_result.unwrap(), true); + } + + #[test] + fn map_list_flatten_to_number_list() { + let map_list = create_map_list_helper(); + + let sum_comparison = ComparisonType::MapList(MapListComparison::FlattenToNumberList( + "sat_score".to_string(), + Box::new(NumberListComparison::Sum(Box::new(NumberComparison::Equal))), + )); + + let result = sum_comparison.evaluate(&map_list, &ValueType::Number(Some(2200.to_string()))); + let failed = sum_comparison.evaluate(&map_list, &ValueType::Number(Some(2201.to_string()))); + + assert_eq!(result.unwrap(), true); + assert_eq!(failed.unwrap(), false); + } + + #[test] + fn map_list_flatten_to_nested_list() { + let map_list = create_map_list_helper(); + + let comparison = ComparisonType::MapList(MapListComparison::FlattenToNestedList( + "grades_participated".to_string(), + Box::new(NestedListComparison::FlattenToTextList(Box::new( + TextListComparison::Contains, + ))), + )); + + let result = comparison.evaluate(&map_list, &ValueType::String(Some("9".to_string()))); + let failed = comparison.evaluate(&map_list, &ValueType::String(Some("13".to_string()))); + + assert_eq!(result.unwrap(), true); + assert_eq!(failed.unwrap(), false); + } + + #[test] + fn number_list_sum_comparison() { + let list = create_number_list(); + + let comparison = ComparisonType::NumberList(NumberListComparison::Sum(Box::new( + NumberComparison::Equal, + ))); + + let result = comparison.evaluate(&list, &ValueType::Number(Some(15.to_string()))); + + assert_eq!(result.unwrap(), true); + } + + #[test] + fn number_list_contains_all_types() { + let num_list = create_number_list(); + + let string_list = ValueType::List(Some(vec![ + ValueType::String(Some("a".to_string())), + ValueType::String(Some("b".to_string())), + ValueType::String(Some("c".to_string())), + ValueType::String(Some("d".to_string())), + ValueType::String(Some("e".to_string())), + ])); + + let mixed_list = ValueType::List(Some(vec![ + ValueType::Number(Some(1.to_string())), + ValueType::String(Some("b".to_string())), + ValueType::Number(Some(3.to_string())), + ValueType::String(Some("d".to_string())), + ValueType::Number(Some(5.to_string())), + ])); + + let comparison = ComparisonType::NumberList(NumberListComparison::Contains); + + let num_result = comparison.evaluate(&num_list, &ValueType::Number(Some(3.to_string()))); + let string_result = + comparison.evaluate(&string_list, &ValueType::String(Some("a".to_string()))); + let mixed_result = + comparison.evaluate(&mixed_list, &ValueType::String(Some("b".to_string()))); + + let incorrect_type = + comparison.evaluate(&string_list, &ValueType::Number(Some(1.to_string()))); + let incorrect_value = + comparison.evaluate(&string_list, &ValueType::String(Some("f".to_string()))); + let mixed_incorrect_type = + comparison.evaluate(&string_list, &ValueType::String(Some("1".to_string()))); + + assert_eq!(num_result.unwrap(), true); + assert_eq!(string_result.unwrap(), true); + assert_eq!(mixed_result.unwrap(), true); + assert_eq!(incorrect_type.unwrap(), false); + assert_eq!(incorrect_value.unwrap(), false); + assert_eq!(mixed_incorrect_type.unwrap(), false); + } + + #[test] + fn text_list_contains() { + let (none_list, empty_list) = create_empty_lists(); + let num_list = create_number_list(); + let text_list = create_text_list(); + + // Define comparisons here + let comparison = ComparisonType::TextList(TextListComparison::Contains); + let target_value = ValueType::String(Some("one".to_string())); + + let none_result = comparison.evaluate(&none_list, &target_value); + let empty_result = comparison.evaluate(&empty_list, &target_value); + let num_result = comparison.evaluate(&num_list, &target_value); + let text_list = comparison.evaluate(&text_list, &target_value); + + assert_eq!(none_result.unwrap(), false); + assert_eq!(empty_result.unwrap(), false); + assert_eq!(num_result.unwrap(), false); + assert_eq!(text_list.unwrap(), true); + } + + #[test] + fn text_list_empty() { + let none_list = ValueType::List(None); + let empty_list = ValueType::List(Some(vec![])); + let long_list = ValueType::List(Some(vec![ + ValueType::String(None), + ])); + + let empty_comp = ComparisonType::TextList(TextListComparison::IsEmpty); + let not_empty_comp = ComparisonType::TextList(TextListComparison::IsNotEmpty); + + // This comparison does not use a target value, as it should just check if the list + // either doesn't exist or is empty. + let mut result = empty_comp.evaluate(&empty_list, &ValueType::List(None)); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), true); + + result = empty_comp.evaluate(&none_list, &ValueType::List(None)); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), true); + + result = empty_comp.evaluate(&long_list, &ValueType::List(None)); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), false); + + result = not_empty_comp.evaluate(&empty_list, &ValueType::List(None)); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), false); + + result = not_empty_comp.evaluate(&none_list, &ValueType::List(None)); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), false); + + result = not_empty_comp.evaluate(&long_list, &ValueType::List(None)); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), true); + } +} diff --git a/src/common/comparison/mod.rs b/src/common/comparison/mod.rs new file mode 100644 index 0000000..1f7ad07 --- /dev/null +++ b/src/common/comparison/mod.rs @@ -0,0 +1,145 @@ +mod base; +mod lists; +mod number; +mod text; +mod traits; +mod utils; + +pub use self::{base::*, lists::*, number::*, text::*, traits::*}; + +#[cfg(test)] +mod test_utils { + use std::collections::HashMap; + use crate::common::{ExpandableInfo, ValueType}; + + pub fn create_student_data() -> ExpandableInfo { + let map = create_map_helper( + vec![ + "first_name".to_string(), + "last_name".to_string(), + "gender".to_string(), + "sat_score".to_string(), + "athletic_participation".to_string(), + "community_involvement".to_string(), + ], + vec![ + ValueType::String(Some("John".to_string())), + ValueType::String(Some("Doe".to_string())), + ValueType::String(Some("Male".to_string())), + ValueType::Number(Some(1190.to_string())), + ValueType::Map(Some(create_map_helper( + vec![ + "sport_name".to_string(), + "grades_participated".to_string(), + "special_achievement".to_string(), + ], + vec![ + ValueType::String(Some("Football".to_string())), + ValueType::List(Some(vec![ + ValueType::String(Some(9.to_string())), + ValueType::String(Some(10.to_string())), + ])), + ValueType::String(Some("N/A".to_string())), + ] + ))), + ValueType::Map(Some(create_map_helper( + vec![ + "activity_name".to_string(), + "total_hours".to_string(), + ], + vec![ + ValueType::String(Some("Raking".to_string())), + ValueType::Number(Some(30.to_string())), + ] + ))), + ] + ); + + let mut info = ExpandableInfo::new("test"); + info.data = map; + + info + } + + pub fn create_map_helper(keys: Vec<String>, values: Vec<ValueType>) -> HashMap<String, ValueType> { + keys + .iter() + .cloned() + .zip(values.iter().cloned()) + .collect::<HashMap<String, ValueType>>() + } + + pub fn create_map_list_helper() -> ValueType { + let map_one = create_map_helper( + vec![ + "first_name".to_string(), + "last_name".to_string(), + "gender".to_string(), + "sat_score".to_string(), + "grades_participated".to_string() + ], + vec![ + ValueType::String(Some("John".to_string())), + ValueType::String(Some("Doe".to_string())), + ValueType::String(Some("Male".to_string())), + ValueType::Number(Some(1200.to_string())), + ValueType::List(Some(vec![ + ValueType::String(Some("9".to_string())), + ValueType::String(Some("10".to_string())) + ])) + ], + ); + + let map_two = create_map_helper( + vec![ + "first_name".to_string(), + "last_name".to_string(), + "gender".to_string(), + "sat_score".to_string(), + "grades_participated".to_string() + ], + vec![ + ValueType::String(Some("Jane".to_string())), + ValueType::String(Some("Doe".to_string())), + ValueType::String(Some("Female".to_string())), + ValueType::Number(Some(1000.to_string())), + ValueType::List(Some(vec![ + ValueType::String(Some("11".to_string())), + ValueType::String(Some("12".to_string())) + ])) + ], + ); + + ValueType::List(Some(vec![ + ValueType::Map(Some(map_one)), + ValueType::Map(Some(map_two)), + ])) + } + + pub fn create_empty_lists() -> (ValueType, ValueType) { + let none_list = ValueType::List(None); + let empty_list = ValueType::List(Some(vec![])); + + (none_list, empty_list) + } + + pub fn create_number_list() -> ValueType { + ValueType::List(Some(vec![ + ValueType::Number(Some(1.to_string())), + ValueType::Number(Some(2.to_string())), + ValueType::Number(Some(3.to_string())), + ValueType::Number(Some(4.to_string())), + ValueType::Number(Some(5.to_string())), + ])) + } + + pub fn create_text_list() -> ValueType { + ValueType::List(Some(vec![ + ValueType::String(Some("one".to_string())), + ValueType::String(Some("two".to_string())), + ValueType::String(Some("three".to_string())), + ValueType::String(Some("four".to_string())), + ValueType::String(Some("five".to_string())), + ])) + } +} diff --git a/src/common/comparison/number.rs b/src/common/comparison/number.rs new file mode 100644 index 0000000..6a51880 --- /dev/null +++ b/src/common/comparison/number.rs @@ -0,0 +1,44 @@ +use super::Comparison; +use crate::common::ValueType; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub enum NumberComparison { + GreaterThan, + LessThan, + Equal, + NotEqual, + GreaterThanOrEqual, + LessThanOrEqual, +} + +impl Comparison for NumberComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + // The prerequisites for all number comparisons is that the target value is Some(number) and the + // comparison member points to Some(number) in the student_info. + let student_value = match comp_value { + ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(), + _ => { + return Err("Student value is not a number".into()); + } + }; + + let target = match target_value { + ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(), + _ => { + return Err("Target value is not a number".into()); + } + }; + + let matched = match self { + NumberComparison::GreaterThan => student_value > target, + NumberComparison::LessThan => student_value < target, + NumberComparison::Equal => student_value == target, + NumberComparison::NotEqual => student_value != target, + NumberComparison::GreaterThanOrEqual => student_value >= target, + NumberComparison::LessThanOrEqual => student_value <= target, + }; + + Ok(matched) + } +} diff --git a/src/common/comparison/text.rs b/src/common/comparison/text.rs new file mode 100644 index 0000000..4079de3 --- /dev/null +++ b/src/common/comparison/text.rs @@ -0,0 +1,36 @@ +use super::Comparison; +use crate::common::ValueType; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub enum TextComparison { + Matches, + NotMatches, + Contains, + NotContains, +} + +impl Comparison for TextComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + // All text comparisons require that the comp.member points to a Some(string) in the student_info, + // and that the target_value is also a Some(string). + let student_value = match comp_value { + ValueType::String(Some(value)) => value, + _ => return Err("Student value is not a string".into()), + }; + + let target_value = match target_value { + ValueType::String(Some(value)) => value, + _ => return Err("Target value is not a string".into()), + }; + + let matched = match self { + TextComparison::Matches => student_value == target_value, + TextComparison::NotMatches => student_value != target_value, + TextComparison::Contains => student_value.contains(target_value), + TextComparison::NotContains => !student_value.contains(target_value), + }; + + Ok(matched) + } +} diff --git a/src/common/comparison/traits.rs b/src/common/comparison/traits.rs new file mode 100644 index 0000000..69f0f7a --- /dev/null +++ b/src/common/comparison/traits.rs @@ -0,0 +1,5 @@ +use crate::common::ValueType; + +pub trait Comparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String>; +} diff --git a/src/common/comparison/utils.rs b/src/common/comparison/utils.rs new file mode 100644 index 0000000..244f78a --- /dev/null +++ b/src/common/comparison/utils.rs @@ -0,0 +1,28 @@ +use crate::common::ValueType; + +/// This function is a work in progress. It's designed to flatten a list of maps by using the +/// given key and verifier function, however it currently doesn't work and just needs to be +/// planned out a little more. +fn flatten_map_helper<F>( + list: &Vec<ValueType>, + key: &String, + verifier: F, +) -> Result<Vec<ValueType>, String> +where + F: Fn(&ValueType) -> Option<&ValueType>, +{ + let mut out = Vec::new(); + for item in list { + let map = match item { + ValueType::Map(Some(map)) => map, + _ => return Err("Expected a map".to_owned()), + }; + + // Get the key, and verify that the value is the correct type. If so, push it. + if let Some(value) = map.get(key).and_then(|v| verifier(v)) { + out.push(value.clone()); + } + } + + Ok(out) +} diff --git a/src/common/expandable_info.rs b/src/common/expandable_info.rs index 8f6c2b4..a209606 100644 --- a/src/common/expandable_info.rs +++ b/src/common/expandable_info.rs @@ -25,9 +25,9 @@ pub struct ExpandableInfo { } impl ExpandableInfo { - pub fn new(key: String) -> Self { + pub fn new(key: impl Into<String>) -> Self { Self { - subject: key, + subject: key.into(), data: HashMap::new(), } } diff --git a/src/common/mod.rs b/src/common/mod.rs index fd320cb..bc24a28 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,6 +1,7 @@ +mod comparison; mod expandable_info; mod form_schema; mod user; -pub mod value_type; +mod value_type; -pub use self::{expandable_info::*, form_schema::*, user::*, value_type::*}; +pub use self::{comparison::*, expandable_info::*, form_schema::*, user::*, value_type::*}; diff --git a/src/lib.rs b/src/lib.rs index 9958e9f..7ed69e6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; mod common; mod components; mod pages; +mod utils; #[cfg(feature = "hydrate")] #[wasm_bindgen::prelude::wasm_bindgen] diff --git a/src/pages/comparison_test.rs b/src/pages/comparison_test.rs new file mode 100644 index 0000000..34635a5 --- /dev/null +++ b/src/pages/comparison_test.rs @@ -0,0 +1,89 @@ +use super::home_page::get_submission; +use super::UnauthenticatedPage; +use crate::common::{ + ComparisonData, ComparisonType, ExpandableInfo, NumberComparison, UserClaims, ValueType, +}; +use crate::components::{ActionButton, Loading, Panel, Row}; +use leptos::leptos_dom::logging::console_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 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()), + }, + ); + + view! { + <AuthLoaded fallback=Loading> + <Authenticated unauthenticated=UnauthenticatedPage> + <Suspense fallback=Loading> + {move || { + server_resource + .get() + .map(|response| { + view! { + <Panel> + <Row> + <p>{result_msg}</p> + </Row> + <Row> + <ActionButton on:click=move |_| { + let comp = ComparisonData { + id: "".into(), + member: "math_sat".into(), + target_value: ValueType::Number(Some("800".into())), + comparison: ComparisonType::Number( + NumberComparison::GreaterThan, + ), + category: "".into(), + display_text: "".into(), + }; + let result = comp.compare(&response); + match result { + Ok(val) => { + result_msg + .set( + format!( + "member {} is greater than target value {}: {}", + comp.member, + comp.target_value, + val, + ), + ) + } + Err(msg) => result_msg.set(msg), + } + }>"Test Comparison"</ActionButton> + </Row> + </Panel> + } + }) + }} + </Suspense> + </Authenticated> + </AuthLoaded> + } +} diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 2cf3138..003df72 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -107,19 +107,19 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> { 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. - + = 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 @@ -137,7 +137,7 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> { let output = process.wait_with_output()?; let pdf_bytes = output.stdout; - + Ok(pdf_bytes) } @@ -182,8 +182,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() @@ -192,17 +190,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"> @@ -220,15 +217,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> @@ -236,8 +233,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> @@ -245,8 +242,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> @@ -254,8 +251,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> @@ -263,9 +260,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> @@ -278,7 +275,7 @@ pub fn HomePage() -> impl IntoView { .collect() disabled=elements_disabled data_member="town" - data_map = expandable_react.data + data_map=expandable_react.data /> </Row> <Row> @@ -311,13 +308,24 @@ pub fn HomePage() -> impl IntoView { <ChipsList data_member="athletic_requirements" data_map=expandable_react.data - items=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()) + items=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() disabled=elements_disabled label="Sports/Athletic Requirements" @@ -327,11 +335,13 @@ pub fn HomePage() -> impl IntoView { <ChipsList data_member="community_involvement" data_map=expandable_react.data - items=vec!( - "Lion's Club", "Knights of Columbus", - "Community Service > 20hrs" - ) - .into_iter().map(|s| s.to_owned()) + items=vec![ + "Lion's Club", + "Knights of Columbus", + "Community Service > 20hrs", + ] + .into_iter() + .map(|s| s.to_owned()) .collect() disabled=elements_disabled label="Required Community Involvement" @@ -339,14 +349,16 @@ pub fn HomePage() -> impl IntoView { </Row> <Row> <MultiEntry - data_map = expandable_react.data - data_member = "test" - name_member = "first_name" - schema = vec![ + 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"]) + input!( + Checkbox, "candy", "Favorite Candy:", ["Twizzlers", "Starburst"] + ), ] /> </Row> @@ -354,44 +366,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> - <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> + <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> + <p>{result_msg}</p> </Row> </Panel> - <div class="flex flex-col flex-1"/> + <div class="flex flex-col flex-1" /> </Row> } }) diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 5752a59..7fe2f6d 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,5 +1,6 @@ mod about_page; +mod comparison_test; mod home_page; mod unauthenticated_page; -pub use self::{about_page::*, home_page::*, unauthenticated_page::*}; +pub use self::{about_page::*, comparison_test::*, home_page::*, unauthenticated_page::*}; diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..c99d1ab --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,23 @@ +use crate::common::{ExpandableInfo, ValueType}; +use std::ops::ControlFlow; + +pub fn get_number_list(student_info: &ExpandableInfo, member: &String) -> Result<Vec<f64>, String> { + match student_info.data.get(member) { + Some(ValueType::List(Some(list))) => { + let temp = list.iter().try_fold(vec![], |mut list_acc, item| { + if let ValueType::Number(Some(number)) = item { + list_acc.push(number.parse::<f64>().unwrap()); + ControlFlow::Continue(list_acc) + } else { + ControlFlow::Break(list_acc) + } + }); + + match temp { + ControlFlow::Continue(list) => Ok(list), + ControlFlow::Break(_) => return Err("List contains non-number values".to_owned()), + } + } + _ => return Err("Couldn't find the list".to_owned()), + } +} From bfab0de564a4e70ab91cc1d3f8fbc632f45f30fc Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Thu, 20 Nov 2025 10:57:20 -0500 Subject: [PATCH 05/22] feat: implement a new function for ComparisonData --- src/common/comparison/base.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/common/comparison/base.rs b/src/common/comparison/base.rs index d8ea50c..d32178a 100644 --- a/src/common/comparison/base.rs +++ b/src/common/comparison/base.rs @@ -5,7 +5,7 @@ use super::{ use crate::common::{ExpandableInfo, ValueType}; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ComparisonData { /// Each comparison will have a unique ID. pub id: String, @@ -23,13 +23,31 @@ pub struct ComparisonData { } impl ComparisonData { + pub fn new( + id: impl Into<String>, + member: impl Into<String>, + comparison: ComparisonType, + target_value: ValueType, + category: impl Into<String>, + display_text: impl Into<String>, + ) -> Self { + Self { + id: id.into(), + member: member.into(), + comparison, + target_value, + category: category.into(), + display_text: display_text.into(), + } + } + pub fn compare(&self, student_data: &ExpandableInfo) -> Result<bool, String> { let value = student_data.data.get(&self.member).unwrap(); self.comparison.evaluate(value, &self.target_value) } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub enum ComparisonType { Number(NumberComparison), Text(TextComparison), From 3bd9a7c6d5bb0b1185004d690563a342ae765a29 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Thu, 20 Nov 2025 10:58:46 -0500 Subject: [PATCH 06/22] feat: implement new scholarship info form This form requires more work, and the ChipsList component must also be altered to accept a list of value/display text pairs. --- src/app.rs | 3 +- src/common/comparison/lists.rs | 8 +- src/common/comparison/number.rs | 2 +- src/common/comparison/text.rs | 2 +- src/pages/mod.rs | 4 +- src/pages/scholarship_info.rs | 288 ++++++++++++++++++++++++++++++++ src/pages/utils.rs | 19 +++ 7 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 src/pages/scholarship_info.rs create mode 100644 src/pages/utils.rs diff --git a/src/app.rs b/src/app.rs index 84079ef..5673926 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use leptos_router::{ }; use url::Url; -use crate::pages::{AboutPage, ComparisonTestPage, HomePage}; +use crate::pages::{AboutPage, ComparisonTestPage, HomePage, ScholarshipInfoPage}; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { @@ -68,6 +68,7 @@ pub fn App() -> impl IntoView { <Route path=StaticSegment("") view=HomePage /> <Route path=StaticSegment("about") view=AboutPage /> <Route path=StaticSegment("comparison") view=ComparisonTestPage /> + <Route path=StaticSegment("scholarship") view=ScholarshipInfoPage /> </Routes> </main> </Router> diff --git a/src/common/comparison/lists.rs b/src/common/comparison/lists.rs index c335dd6..ff6112c 100644 --- a/src/common/comparison/lists.rs +++ b/src/common/comparison/lists.rs @@ -2,7 +2,7 @@ use super::{Comparison, NumberComparison}; use crate::common::ValueType; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub enum NumberListComparison { /// Sums the values in the list and checks them using the given [`NumberComparison`]. /// Requires the list to contain only numbers. @@ -65,7 +65,7 @@ impl Comparison for NumberListComparison { /// /// For example, using `TextListComparison::Contains` checks if a specific value is contained /// in the list. -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub enum TextListComparison { /// Checks if the list contains the target value. Contains, @@ -156,7 +156,7 @@ impl Comparison for TextListComparison { /// /// This will flatten a nested list into a single list of `ValueType::String` enums and then /// will check if the list contains the indicated target value. -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub enum NestedListComparison { /// Flattens a nested list into a single text list, then performs the given comparison. FlattenToTextList(Box<TextListComparison>), @@ -210,7 +210,7 @@ impl Comparison for NestedListComparison { /// ``` /// MapListComparison::FlattenToTextList("name".to_owned(), Box::new(TextListComparison::Equal)); /// ``` -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub enum MapListComparison { /// Flattens the map list to a text list and then performs the given comparison. FlattenToTextList(String, Box<TextListComparison>), diff --git a/src/common/comparison/number.rs b/src/common/comparison/number.rs index 6a51880..3440e97 100644 --- a/src/common/comparison/number.rs +++ b/src/common/comparison/number.rs @@ -2,7 +2,7 @@ use super::Comparison; use crate::common::ValueType; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub enum NumberComparison { GreaterThan, LessThan, diff --git a/src/common/comparison/text.rs b/src/common/comparison/text.rs index 4079de3..23b4d30 100644 --- a/src/common/comparison/text.rs +++ b/src/common/comparison/text.rs @@ -2,7 +2,7 @@ use super::Comparison; use crate::common::ValueType; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub enum TextComparison { Matches, NotMatches, diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 7fe2f6d..b30030c 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -2,5 +2,7 @@ mod about_page; mod comparison_test; mod home_page; mod unauthenticated_page; +mod scholarship_info; +mod utils; -pub use self::{about_page::*, comparison_test::*, home_page::*, unauthenticated_page::*}; +pub use self::{about_page::*, comparison_test::*, home_page::*, unauthenticated_page::*, scholarship_info::*}; diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs new file mode 100644 index 0000000..6012b7e --- /dev/null +++ b/src/pages/scholarship_info.rs @@ -0,0 +1,288 @@ +#[cfg(feature = "ssr")] +use super::utils::server_utils::create_dynamo_client; + +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{ + error::ProvideErrorMetadata, + types::AttributeValue, +}; + +use leptos::logging::log; +use leptos::prelude::*; +use leptos_oidc::{AuthLoaded, Authenticated}; +use crate::common::{ComparisonType, ExpandableInfo, NumberComparison, ValueType}; +use crate::components::{ActionButton, ChipsList, Loading, OutlinedTextField, Panel, Row}; +use super::UnauthenticatedPage; +use traits::{AsReactive, ReactiveCapture}; +use crate::common::ComparisonData; + +#[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 super::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] +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)) + } + } +} + +#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")] +async fn create_test_comparisons() -> Result<(), ServerFnError> { + let client = create_dynamo_client().await; + + log!("Creating test comparisons"); + + let test_comp = ComparisonData::new( + "comp1", + "unweighted_gpa", + ComparisonType::Number(NumberComparison::GreaterThanOrEqual), + ValueType::Number(Some(3.2.to_string())), + "Testing", + "GPA > 3.2" + ); + + let _comp_list = vec![ + ComparisonData::new( + "comp1", + "unweighted_gpa", + ComparisonType::Number(NumberComparison::GreaterThanOrEqual), + ValueType::Number(Some(3.2.to_string())), + "Testing", + "GPA > 3.2" + ) + ]; + + match client + .put_item() + .table_name("leptos-comparison-test") + .set_item(Some(serde_dynamo::to_item(&test_comp)?)) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred"); + Err(ServerFnError::new(msg)) + } + } +} + +/// # 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-id"); + + let get_scholarship_action: Resource<ExpandableInfo> = Resource::new( + move || scholarship_id.clone(), + async |id| { + get_scholarship_info(id).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("test-scholarship-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> + {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() + })} + <Row> + <ActionButton + on:click=move |_| { + create_test_comps.dispatch(CreateTestComparisons {}); + } + >"Create test comparisons"</ActionButton> + </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() + } + } + }); + + 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> + <ChipsList + label="Scholarship Requirements" + data_member="requirements" + data_map=reactive_info.data + items=comparison_list + .into_iter() + .map(|comp| comp.id) + .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> + } +} diff --git a/src/pages/utils.rs b/src/pages/utils.rs new file mode 100644 index 0000000..b12d842 --- /dev/null +++ b/src/pages/utils.rs @@ -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) + } +} From 0243a592b4f7630a1af7b5a0610810cb9e64cc3d Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Thu, 20 Nov 2025 11:47:58 -0500 Subject: [PATCH 07/22] feat: set key-value pairs for comparsions chip list --- src/pages/scholarship_info.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index 6012b7e..410b6a7 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -262,10 +262,16 @@ fn ScholarshipForm( label="Scholarship Requirements" data_member="requirements" data_map=reactive_info.data - items=comparison_list - .into_iter() + values=comparison_list + .iter() + .cloned() .map(|comp| comp.id) .collect() + displayed_text=comparison_list + .iter() + .cloned() + .map(|comp| comp.display_text) + .collect() /> </Row> <Row> From 0ace17d28c8533f71650c44f6305e14feb09cb36 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Sun, 23 Nov 2025 16:21:52 -0500 Subject: [PATCH 08/22] feat: build scholarship info page --- src/common/comparison/base.rs | 3 +- src/common/form_schema.rs | 1 + src/components/multi_entry.rs | 3 +- src/components/panel.rs | 2 +- src/pages/api/comparisons.rs | 349 ++++++++++++++++++++++++++++++++++ src/pages/api/mod.rs | 4 + src/pages/api/scholarships.rs | 91 +++++++++ src/pages/comparison_test.rs | 152 +++++++++++---- src/pages/home_page.rs | 56 +----- src/pages/mod.rs | 1 + src/pages/scholarship_info.rs | 268 +++++++++++--------------- 11 files changed, 679 insertions(+), 251 deletions(-) create mode 100644 src/pages/api/comparisons.rs create mode 100644 src/pages/api/mod.rs create mode 100644 src/pages/api/scholarships.rs diff --git a/src/common/comparison/base.rs b/src/common/comparison/base.rs index d32178a..ec45f6c 100644 --- a/src/common/comparison/base.rs +++ b/src/common/comparison/base.rs @@ -42,7 +42,8 @@ impl ComparisonData { } pub fn compare(&self, student_data: &ExpandableInfo) -> Result<bool, String> { - let value = student_data.data.get(&self.member).unwrap(); + let value = student_data.data.get(&self.member) + .expect(format!("Couldn't find member {:?}, current student data: {:?}", self.member, student_data).as_str()); self.comparison.evaluate(value, &self.target_value) } } diff --git a/src/common/form_schema.rs b/src/common/form_schema.rs index 84e2816..e5ec97e 100644 --- a/src/common/form_schema.rs +++ b/src/common/form_schema.rs @@ -49,6 +49,7 @@ impl InputType { placeholder = placeholder.clone() data_member = member.clone() data_map = data_map + input_type = "number" /> }), InputType::Checkbox(member, label, options) => diff --git a/src/components/multi_entry.rs b/src/components/multi_entry.rs index a2e1015..78c8e44 100644 --- a/src/components/multi_entry.rs +++ b/src/components/multi_entry.rs @@ -73,7 +73,8 @@ pub fn MultiEntry( /// component's `data_member` input. #[prop()] data_map: RwSignal<HashMap<String, ValueType>>, - #[prop(into)] name_member: String, + /// Currently unused. + #[prop(optional, into)] name_member: String, /// The type and order of the inputs for each `Entry` component within this `MultiEntry` component. #[prop(optional)] schema: Vec<InputType>, diff --git a/src/components/panel.rs b/src/components/panel.rs index bfd17c9..07f025d 100644 --- a/src/components/panel.rs +++ b/src/components/panel.rs @@ -29,7 +29,7 @@ pub fn Panel( ) -> impl IntoView { view! { <div - class="flex flex-col gap-2 p-2 m-4 flex-1 + class="flex flex-col gap-2 p-2 m-4 flex-2 border-1 border-gray-600/10 rounded-md shadow-[0_0_6px_rgba(0,0,0,0.50)]" class=(["hidden"], hidden) > diff --git a/src/pages/api/comparisons.rs b/src/pages/api/comparisons.rs new file mode 100644 index 0000000..be8469c --- /dev/null +++ b/src/pages/api/comparisons.rs @@ -0,0 +1,349 @@ +#[cfg(feature = "ssr")] +use crate::pages::utils::server_utils::create_dynamo_client; + +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{ + error::ProvideErrorMetadata, +}; + +use leptos::leptos_dom::log; +use leptos::prelude::ServerFnError; +use leptos::server; +use crate::common::{ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison, TextComparison, TextListComparison, ValueType}; + +/// 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)) + } + } +} diff --git a/src/pages/api/mod.rs b/src/pages/api/mod.rs new file mode 100644 index 0000000..810edc2 --- /dev/null +++ b/src/pages/api/mod.rs @@ -0,0 +1,4 @@ +mod comparisons; +mod scholarships; + +pub use self::{comparisons::*, scholarships::*}; \ No newline at end of file diff --git a/src/pages/api/scholarships.rs b/src/pages/api/scholarships.rs new file mode 100644 index 0000000..edc7b4c --- /dev/null +++ b/src/pages/api/scholarships.rs @@ -0,0 +1,91 @@ +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{ + types::AttributeValue, + error::ProvideErrorMetadata, +}; + +use leptos::leptos_dom::log; +use leptos::prelude::ServerFnError; +use leptos::server; +use crate::common::ExpandableInfo; + +#[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)) + } + } +} diff --git a/src/pages/comparison_test.rs b/src/pages/comparison_test.rs index 34635a5..c08f9f4 100644 --- a/src/pages/comparison_test.rs +++ b/src/pages/comparison_test.rs @@ -1,12 +1,15 @@ +use leptos::either::Either; use super::home_page::get_submission; -use super::UnauthenticatedPage; +use super::{UnauthenticatedPage}; use crate::common::{ - ComparisonData, ComparisonType, ExpandableInfo, NumberComparison, UserClaims, ValueType, + ComparisonData, ExpandableInfo, UserClaims, ValueType, }; use crate::components::{ActionButton, Loading, Panel, Row}; use leptos::leptos_dom::logging::console_log; +use leptos::logging::log; use leptos::prelude::*; use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; +use super::api::{get_comparison_info, get_all_scholarship_info}; #[component] pub fn ComparisonTestPage() -> impl IntoView { @@ -23,7 +26,7 @@ pub fn ComparisonTestPage() -> impl IntoView { // 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( + let student_resource: Resource<ExpandableInfo> = Resource::new( move || user_claims.get().map(|claim| claim.claims.subject.clone()), async | opt_username @@ -35,52 +38,123 @@ pub fn ComparisonTestPage() -> impl IntoView { 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 || { - server_resource - .get() - .map(|response| { - view! { - <Panel> - <Row> - <p>{result_msg}</p> - </Row> - <Row> - <ActionButton on:click=move |_| { - let comp = ComparisonData { - id: "".into(), - member: "math_sat".into(), - target_value: ValueType::Number(Some("800".into())), - comparison: ComparisonType::Number( - NumberComparison::GreaterThan, - ), - category: "".into(), - display_text: "".into(), + 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 result = comp.compare(&response); - match result { - Ok(val) => { - result_msg - .set( - format!( - "member {} is greater than target value {}: {}", - comp.member, - comp.target_value, - val, - ), - ) + + 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; + } } - Err(msg) => result_msg.set(msg), } - }>"Test Comparison"</ActionButton> - </Row> - </Panel> - } + + 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> diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 24ef200..4c6ddb9 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -13,7 +13,7 @@ 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 leptos::leptos_dom::logging::console_log; @@ -305,61 +305,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" + data_member="community_involvement" 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"] - ), + input!(Text, "service_name", "Activity Name:", "Some service activity..."), + input!(Number, "service_hours", "Total Service Hours", "20") ] /> </Row> diff --git a/src/pages/mod.rs b/src/pages/mod.rs index b30030c..0173c74 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -4,5 +4,6 @@ mod home_page; mod unauthenticated_page; mod scholarship_info; mod utils; +mod api; pub use self::{about_page::*, comparison_test::*, home_page::*, unauthenticated_page::*, scholarship_info::*}; diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index 410b6a7..f598311 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -1,144 +1,13 @@ -#[cfg(feature = "ssr")] -use super::utils::server_utils::create_dynamo_client; - -#[cfg(feature = "ssr")] -use aws_sdk_dynamodb::{ - error::ProvideErrorMetadata, - types::AttributeValue, -}; - use leptos::logging::log; use leptos::prelude::*; use leptos_oidc::{AuthLoaded, Authenticated}; -use crate::common::{ComparisonType, ExpandableInfo, NumberComparison, ValueType}; -use crate::components::{ActionButton, ChipsList, Loading, OutlinedTextField, Panel, Row}; +use crate::common::ExpandableInfo; +use crate::components::{ActionButton, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row}; use super::UnauthenticatedPage; use traits::{AsReactive, ReactiveCapture}; use crate::common::ComparisonData; +use super::api::{get_comparison_info, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons}; -#[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 super::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] -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)) - } - } -} - -#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")] -async fn create_test_comparisons() -> Result<(), ServerFnError> { - let client = create_dynamo_client().await; - - log!("Creating test comparisons"); - - let test_comp = ComparisonData::new( - "comp1", - "unweighted_gpa", - ComparisonType::Number(NumberComparison::GreaterThanOrEqual), - ValueType::Number(Some(3.2.to_string())), - "Testing", - "GPA > 3.2" - ); - - let _comp_list = vec![ - ComparisonData::new( - "comp1", - "unweighted_gpa", - ComparisonType::Number(NumberComparison::GreaterThanOrEqual), - ValueType::Number(Some(3.2.to_string())), - "Testing", - "GPA > 3.2" - ) - ]; - - match client - .put_item() - .table_name("leptos-comparison-test") - .set_item(Some(serde_dynamo::to_item(&test_comp)?)) - .send() - .await - { - Ok(_) => Ok(()), - Err(err) => { - let msg = err.message().unwrap_or("An unknown error occurred"); - Err(ServerFnError::new(msg)) - } - } -} /// # Scholarship Info Page /// This page will handle creating or editing scholarship information given a specific @@ -150,18 +19,18 @@ async fn create_test_comparisons() -> Result<(), ServerFnError> { /// changed before a full release. #[component] pub fn ScholarshipInfoPage() -> impl IntoView { - let scholarship_id = String::from("test-scholarship-id"); + let scholarship_id = String::from("test-scholarship-id2"); let get_scholarship_action: Resource<ExpandableInfo> = Resource::new( move || scholarship_id.clone(), async |id| { - get_scholarship_info(id).await + 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("test-scholarship-id") + ExpandableInfo::new(id) }) } ); @@ -186,27 +55,33 @@ pub fn ScholarshipInfoPage() -> impl IntoView { <AuthLoaded fallback=Loading> <Authenticated unauthenticated=UnauthenticatedPage> <Suspense fallback=Loading> - {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() - })} <Row> - <ActionButton - on:click=move |_| { - create_test_comps.dispatch(CreateTestComparisons {}); - } - >"Create test comparisons"</ActionButton> + <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> @@ -241,6 +116,10 @@ fn ScholarshipForm( } }); + // 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> @@ -257,6 +136,81 @@ fn ScholarshipForm( 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" From 86086f33886e88180e0db7f988a31724748e373b Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Sun, 23 Nov 2025 16:55:26 -0500 Subject: [PATCH 09/22] feat: scholarship info page takes a scholarship id param --- src/app.rs | 13 +++++-------- src/common/form_params.rs | 7 +++++++ src/common/mod.rs | 3 ++- src/pages/scholarship_info.rs | 15 ++++++++++++--- 4 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 src/common/form_params.rs diff --git a/src/app.rs b/src/app.rs index 5673926..ba7f12c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,10 +1,7 @@ use leptos::prelude::*; use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; -use leptos_router::{ - components::{Route, Router, Routes}, - StaticSegment, -}; +use leptos_router::{components::{Route, Router, Routes}, path}; use url::Url; use crate::pages::{AboutPage, ComparisonTestPage, HomePage, ScholarshipInfoPage}; @@ -65,10 +62,10 @@ pub fn App() -> impl IntoView { <main> // TODO Create a 404 page <Routes fallback=|| "Page not found.".into_view()> - <Route path=StaticSegment("") view=HomePage /> - <Route path=StaticSegment("about") view=AboutPage /> - <Route path=StaticSegment("comparison") view=ComparisonTestPage /> - <Route path=StaticSegment("scholarship") view=ScholarshipInfoPage /> + <Route path=path!("") view=HomePage /> + <Route path=path!("about") view=AboutPage /> + <Route path=path!("comparison") view=ComparisonTestPage /> + <Route path=path!("scholarship/:id") view=ScholarshipInfoPage /> </Routes> </main> </Router> diff --git a/src/common/form_params.rs b/src/common/form_params.rs new file mode 100644 index 0000000..7a8368c --- /dev/null +++ b/src/common/form_params.rs @@ -0,0 +1,7 @@ +use leptos::Params; +use leptos_router::params::Params; + +#[derive(Params, PartialEq, Debug)] +pub struct ScholarshipFormParams { + pub id: Option<String>, +} diff --git a/src/common/mod.rs b/src/common/mod.rs index bc24a28..3dd4824 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -3,5 +3,6 @@ mod expandable_info; mod form_schema; mod user; mod value_type; +mod form_params; -pub use self::{comparison::*, expandable_info::*, form_schema::*, user::*, value_type::*}; +pub use self::{comparison::*, expandable_info::*, form_schema::*, user::*, value_type::*, form_params::*}; diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs index f598311..63bb3ab 100644 --- a/src/pages/scholarship_info.rs +++ b/src/pages/scholarship_info.rs @@ -1,7 +1,8 @@ use leptos::logging::log; use leptos::prelude::*; use leptos_oidc::{AuthLoaded, Authenticated}; -use crate::common::ExpandableInfo; +use leptos_router::hooks::use_params; +use crate::common::{ExpandableInfo, ScholarshipFormParams}; use crate::components::{ActionButton, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row}; use super::UnauthenticatedPage; use traits::{AsReactive, ReactiveCapture}; @@ -19,10 +20,18 @@ use super::api::{get_comparison_info, get_scholarship_info, CreateScholarshipInf /// changed before a full release. #[component] pub fn ScholarshipInfoPage() -> impl IntoView { - let scholarship_id = String::from("test-scholarship-id2"); + // 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(), + 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 From abfa7f0796fe016be58c3ee586c67bb884c1cdd8 Mon Sep 17 00:00:00 2001 From: meraghon <mdayton0521@gmail.com> Date: Sun, 23 Nov 2025 19:11:06 -0500 Subject: [PATCH 10/22] Create file --- src/pages/mod.rs | 3 ++- src/pages/provider_portal.rs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 src/pages/provider_portal.rs diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 5752a59..5e2f017 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,5 +1,6 @@ mod about_page; mod home_page; +mod provider_portal; mod unauthenticated_page; -pub use self::{about_page::*, home_page::*, unauthenticated_page::*}; +pub use self::{about_page::*, home_page::*, provider_portal::*, unauthenticated_page::*}; diff --git a/src/pages/provider_portal.rs b/src/pages/provider_portal.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/pages/provider_portal.rs @@ -0,0 +1 @@ + From 8ccdee381fc99c553c42e0439bbdc8cc2c295391 Mon Sep 17 00:00:00 2001 From: meraghon <mdayton0521@gmail.com> Date: Fri, 5 Dec 2025 09:46:19 -0500 Subject: [PATCH 11/22] Basic banner component --- src/components/banner.rs | 48 ++++++++++++++++++++++++++++++++++++++++ src/components/mod.rs | 5 +++-- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/components/banner.rs diff --git a/src/components/banner.rs b/src/components/banner.rs new file mode 100644 index 0000000..e4f0e2f --- /dev/null +++ b/src/components/banner.rs @@ -0,0 +1,48 @@ +use leptos::prelude::*; +use leptos_router::hooks::use_navigate; + +/// # Banner Component +/// +/// This component displays a red banner at the top of the page with a title and a logo. +/// Clicking the logo will redirect a user to specified page, preferably a home page. +/// +/// Example usage: +/// ``` +/// <Banner +/// title="Test Page" +/// logo="medal.png" +/// path="/" +/// /> +/// ``` +#[component] +pub fn Banner( + /// The title of the banner. + #[prop(into)] + title: String, + /// The file path or URL of a logo displayed at the top left of the banner. + #[prop(into)] + logo: String, + /// The route that the navigates user to a page (home page). + #[prop(into)] + path: String, +) -> impl IntoView { + let navigate = use_navigate(); + + let on_click = move |_| navigate(&path, Default::default()); + + view! { + <div class="mx-auto"> + <div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]"> + <div class="flex items-center gap-4 p-6"> + <div class="flex-shrink-0"> + <img src={logo.clone()} class="h-8 w-8 cursor-pointer" alt="icon" on:click=on_click/> + </div> + <div class="flex-1"> + <h2 class="text-white text-lg font-bold">{title.clone()}</h2> + </div> + </div> + </div> + </div> + + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index f0297f7..a66f141 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,4 +1,5 @@ mod action_button; +mod banner; mod checkbox; mod chips; mod dashboard_button; @@ -12,6 +13,6 @@ mod text_field; mod utils; pub use self::{ - action_button::*, checkbox::*, chips::*, dashboard_button::*, loading::*, multi_entry::*, - panel::*, radio::*, row::*, select::*, text_field::*, + action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*, + multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*, }; From 3e2f6e2feecab226db23317aa28205b2fa5338c0 Mon Sep 17 00:00:00 2001 From: meraghon <mdayton0521@gmail.com> Date: Fri, 5 Dec 2025 09:46:43 -0500 Subject: [PATCH 12/22] Recolored background of dashboard button --- src/components/dashboard_button.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard_button.rs b/src/components/dashboard_button.rs index 9310b33..f471e33 100644 --- a/src/components/dashboard_button.rs +++ b/src/components/dashboard_button.rs @@ -40,7 +40,7 @@ pub fn DashboardButton( view! { <button type="button" - class="dashboard-button flex items-start gap-3 rounded-lg border-grey-300 p-6 + class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6 hover:bg-gray-100 transition cursor-pointer w-full text-left shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]" on:click=on_click> From 1fd9d4975dc2b2d53cd6e7e8dad466d692a1ad58 Mon Sep 17 00:00:00 2001 From: meraghon <mdayton0521@gmail.com> Date: Fri, 5 Dec 2025 09:53:22 -0500 Subject: [PATCH 13/22] Resized logo --- src/components/banner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/banner.rs b/src/components/banner.rs index e4f0e2f..09ff830 100644 --- a/src/components/banner.rs +++ b/src/components/banner.rs @@ -35,7 +35,7 @@ pub fn Banner( <div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]"> <div class="flex items-center gap-4 p-6"> <div class="flex-shrink-0"> - <img src={logo.clone()} class="h-8 w-8 cursor-pointer" alt="icon" on:click=on_click/> + <img src={logo.clone()} class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click/> </div> <div class="flex-1"> <h2 class="text-white text-lg font-bold">{title.clone()}</h2> From e293f8beee67e0ce5f167ba193a24521fed99c9b Mon Sep 17 00:00:00 2001 From: meraghon <mdayton0521@gmail.com> Date: Fri, 5 Dec 2025 09:54:47 -0500 Subject: [PATCH 14/22] Added PHS logos --- public/PHS_Claw_Logo.png | Bin 0 -> 10741 bytes public/PHS_Stacked_Acronym.png | Bin 0 -> 4349 bytes public/R15_logo.png | Bin 0 -> 22350 bytes public/R15_logo_colorMatchWhite.png | Bin 0 -> 23024 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/PHS_Claw_Logo.png create mode 100644 public/PHS_Stacked_Acronym.png create mode 100644 public/R15_logo.png create mode 100644 public/R15_logo_colorMatchWhite.png diff --git a/public/PHS_Claw_Logo.png b/public/PHS_Claw_Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9bcb67b28864fb5bf7e23b977c3ec489b98de9ae GIT binary patch literal 10741 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 APyhe` literal 0 HcmV?d00001 diff --git a/public/PHS_Stacked_Acronym.png b/public/PHS_Stacked_Acronym.png new file mode 100644 index 0000000000000000000000000000000000000000..94b47ce01c848750f1cd1bbdd460f24b3874fd2b GIT binary patch literal 4349 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 literal 0 HcmV?d00001 diff --git a/public/R15_logo.png b/public/R15_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..802df65650adde20ed67a082d7df41a7ef9f50cd GIT binary patch literal 22350 zcmXt8WmKF^tlni|ad(&EP+W^G?v�TAbp#IF#b<TC6~Em&KvQihH5ByW8cv_uTn2 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 literal 0 HcmV?d00001 diff --git a/public/R15_logo_colorMatchWhite.png b/public/R15_logo_colorMatchWhite.png new file mode 100644 index 0000000000000000000000000000000000000000..6842325be9e8ff02372a122944f34df58784c25e GIT binary patch literal 23024 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 DP7FhG literal 0 HcmV?d00001 From 0490964dde35f24a9dce0e624188765454256d9a Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Fri, 5 Dec 2025 10:13:27 -0500 Subject: [PATCH 15/22] feat: add CB loaner page skeleton --- src/pages/loaner_page.rs | 131 +++++++++++++++++++++++++++++++++++++++ src/pages/mod.rs | 1 + 2 files changed, 132 insertions(+) create mode 100644 src/pages/loaner_page.rs diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs new file mode 100644 index 0000000..5253420 --- /dev/null +++ b/src/pages/loaner_page.rs @@ -0,0 +1,131 @@ +use chrono::{DateTime, Utc}; +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use leptos::logging::log; + +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; + +#[cfg(feature = "ssr")] +use serde_dynamo::{from_item, to_item}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct LoanerReturnOutput { + /// The first name of the student that is borrowing. + name: String, + /// The last name of the student that is borrowing. + last_name: String, + /// The type of loan that was given to the student. + loan: String, + /// The date that the student borrowed the loaner. + date: DateTime<Utc>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct LoanerBorrowInput { + /// 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 email address of the student that is borrowing. + email: String, + /// The collateral that the student is leaving. Usually a phone, earbuds, etc. + collateral: String, + /// The type of loan that was given to the student. May be a Chromebook, headphones, etc. + loan: String, + /// The date that the loaner was borrowed. + date: DateTime<Utc>, +} + +#[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("name, date") + .send() + .await { + Ok(output) => { + if let Some(items) = output.items { + Ok(items.into_iter().map(|item| from_item(item).unwrap()).collect()) + } else { + Ok(vec![]) + } + }, + Err(err) => { + let msg = err.message().unwrap_or("Unknown error"); + Err(ServerFnError::new(msg)) + }, + } +} + +#[server] +async fn create_borrow_entry(input: LoanerBorrowInput) -> Result<(), ServerFnError> { + let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); + let item = to_item(input)?; + + match dbclient + .put_item() + .table_name("loaner-info") + .set_item(Some(item)) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred."); + Err(ServerFnError::new(msg)) + } + } +} + +/// # Loaner Page +/// +/// This page is going to temporarily piggy-back 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 { + // Register server actions. + let create_entry_action = ServerAction::<CreateBorrowEntry>::new(); + + // Register server resources + let return_list_resource: Resource<Vec<LoanerReturnOutput>> = Resource::new( + move || (), + async |_| get_loaners_return_list().await.unwrap_or_else(|e| { + log!("There was an error getting the loaner list: {}", e); + vec![] + }) + ); + + // We'll create the view here. Just remember that we want to have two sections, where they + // preferably slide in when they're activated. + view! {} +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 5752a59..156d685 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,5 +1,6 @@ mod about_page; mod home_page; mod unauthenticated_page; +mod loaner_page; pub use self::{about_page::*, home_page::*, unauthenticated_page::*}; From 779e4721af24b8ca8c97e3db0ec3359159fbc419 Mon Sep 17 00:00:00 2001 From: meraghon <mdayton0521@gmail.com> Date: Fri, 5 Dec 2025 12:06:27 -0500 Subject: [PATCH 16/22] Changed path prop to a default prop --- src/components/banner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/banner.rs b/src/components/banner.rs index 09ff830..79b95ca 100644 --- a/src/components/banner.rs +++ b/src/components/banner.rs @@ -23,7 +23,7 @@ pub fn Banner( #[prop(into)] logo: String, /// The route that the navigates user to a page (home page). - #[prop(into)] + #[prop(into, default = String::from("/"))] path: String, ) -> impl IntoView { let navigate = use_navigate(); From 24fe9802b9a22ea338211216008057ca052ad2de Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Sun, 7 Dec 2025 12:15:22 -0500 Subject: [PATCH 17/22] feat: create framework for loaner app forms --- src/app.rs | 17 ++- src/common/expandable_info.rs | 4 +- src/pages/loaner_page.rs | 251 +++++++++++++++++++++++++++------- src/pages/mod.rs | 1 + 4 files changed, 216 insertions(+), 57 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1c7065b..29ee240 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,10 @@ use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; -use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge, LoginLink}; -use leptos_router::{ - StaticSegment, - components::{Route, Router, Routes}, -}; +use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; +use leptos_router::{components::{Route, Router, Routes}, path}; use url::Url; -use crate::pages::{AboutPage, HomePage, TestPage, UnauthenticatedPage}; +use crate::pages::{AboutPage, HomePage, LoanerPage, TestPage}; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { @@ -65,9 +62,11 @@ pub fn App() -> impl IntoView { <main> // TODO Create a 404 page <Routes fallback=|| "Page not found.".into_view()> - <Route path=StaticSegment("") view=HomePage/> - <Route path=StaticSegment("about") view=AboutPage/> - <Route path=StaticSegment("test_page") view=TestPage/> + <Route path=path!("") view=HomePage/> + <Route path=path!("about") view=AboutPage/> + <Route path=path!("test_page") view=TestPage/> + <Route path=path!("loaners") view=LoanerPage /> + <Route path=path!("loaners/:form_name") view=LoanerPage /> </Routes> </main> </Router> diff --git a/src/common/expandable_info.rs b/src/common/expandable_info.rs index 8f6c2b4..a209606 100644 --- a/src/common/expandable_info.rs +++ b/src/common/expandable_info.rs @@ -25,9 +25,9 @@ pub struct ExpandableInfo { } impl ExpandableInfo { - pub fn new(key: String) -> Self { + pub fn new(key: impl Into<String>) -> Self { Self { - subject: key, + subject: key.into(), data: HashMap::new(), } } diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 5253420..416e09a 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -1,40 +1,35 @@ -use chrono::{DateTime, Utc}; -use leptos::prelude::*; -use serde::{Deserialize, Serialize}; -use leptos::logging::log; - -#[cfg(feature = "ssr")] -use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata}; #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; -#[derive(Serialize, Deserialize, Debug, Clone)] -struct LoanerReturnOutput { - /// The first name of the student that is borrowing. - name: String, - /// The last name of the student that is borrowing. - last_name: String, - /// The type of loan that was given to the student. - loan: String, - /// The date that the student borrowed the loaner. - date: DateTime<Utc>, +use chrono::{DateTime, Utc}; +use leptos::either::EitherOf3; +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use leptos::logging::log; +use leptos_router::params::Params; +use leptos_router::hooks::{use_navigate, use_params, use_query}; +use leptos::Params; +use traits::{AsReactive, ReactiveCapture}; +use crate::common::{ExpandableInfo, ValueType}; +use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select}; +use std::collections::HashMap; + +#[derive(Params, PartialEq)] +struct LoanerParams { + form_name: Option<String> } #[derive(Serialize, Deserialize, Debug, Clone)] -struct LoanerBorrowInput { +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 email address of the student that is borrowing. - email: String, - /// The collateral that the student is leaving. Usually a phone, earbuds, etc. - collateral: String, - /// The type of loan that was given to the student. May be a Chromebook, headphones, etc. - loan: String, - /// The date that the loaner was borrowed. - date: DateTime<Utc>, } #[server] @@ -48,17 +43,25 @@ async fn get_loaners_return_list() -> Result<Vec<LoanerReturnOutput>, ServerFnEr match dbclient .scan() .table_name("loaner-info") - .projection_expression("name, date") + .projection_expression("subject, first_name, last_name") .send() .await { Ok(output) => { if let Some(items) = output.items { - Ok(items.into_iter().map(|item| from_item(item).unwrap()).collect()) + Ok(items.into_iter().map(|item| { + let expandable: ExpandableInfo = from_item(item).unwrap(); + 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(), + subject: expandable.subject + } + }).collect()) } else { Ok(vec![]) } }, Err(err) => { + log!("{:?}", err); let msg = err.message().unwrap_or("Unknown error"); Err(ServerFnError::new(msg)) }, @@ -66,21 +69,36 @@ async fn get_loaners_return_list() -> Result<Vec<LoanerReturnOutput>, ServerFnEr } #[server] -async fn create_borrow_entry(input: LoanerBorrowInput) -> Result<(), ServerFnError> { +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 item = to_item(input)?; - - match dbclient - .put_item() - .table_name("loaner-info") - .set_item(Some(item)) - .send() - .await - { - Ok(_) => Ok(()), - Err(err) => { - let msg = err.message().unwrap_or("An unknown error occurred."); - Err(ServerFnError::new(msg)) + + 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 write 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)); + } + }; + } } } } @@ -113,9 +131,138 @@ async fn create_borrow_entry(input: LoanerBorrowInput) -> Result<(), ServerFnErr /// 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("test"); + let temp_reactive = temp.as_reactive(); + let elements_disabled = RwSignal::new(false); + + view! { + <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 resources let return_list_resource: Resource<Vec<LoanerReturnOutput>> = Resource::new( move || (), @@ -124,8 +271,20 @@ pub fn LoanerPage() -> impl IntoView { vec![] }) ); - - // We'll create the view here. Just remember that we want to have two sections, where they - // preferably slide in when they're activated. - view! {} + + view! { + <Suspense fallback=move || "Loading...".into_view()> + <div class="flex flex-col gap-2"> + {move || return_list_resource.get() + .map(move |loaner_list| { + loaner_list.into_iter().map(move |student| { + view! { + <span>{student.first_name}" "{student.last_name}</span> + } + }).collect_view() + }) + } + </div> + </Suspense> + } } diff --git a/src/pages/mod.rs b/src/pages/mod.rs index a1e22e0..35bb3ce 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -7,4 +7,5 @@ mod loaner_page; pub use self::{ about_page::*, home_page::*, provider_portal::*, test_page::*, unauthenticated_page::*, + loaner_page::LoanerPage, }; From adc0d4ea0999393b9597ff74f15459ac392baf45 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Mon, 8 Dec 2025 16:13:52 -0500 Subject: [PATCH 18/22] feat: implement full loaner return form The only thing missing from this component would be sorting entries by their most recent date. I'm not super invested in sorting by this right now. --- src/pages/loaner_page.rs | 174 ++++++++++++++++++++++++++++++++++----- 1 file changed, 153 insertions(+), 21 deletions(-) diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 416e09a..9e224e0 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -1,21 +1,22 @@ #[cfg(feature = "ssr")] -use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata}; +use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; -use chrono::{DateTime, Utc}; -use leptos::either::EitherOf3; +use leptos::either::{Either, EitherOf3}; use leptos::prelude::*; use serde::{Deserialize, Serialize}; use leptos::logging::log; use leptos_router::params::Params; -use leptos_router::hooks::{use_navigate, use_params, use_query}; +use leptos_router::hooks::{use_navigate, use_params}; use leptos::Params; use traits::{AsReactive, ReactiveCapture}; use crate::common::{ExpandableInfo, ValueType}; use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select}; use std::collections::HashMap; +use chrono::{DateTime, FixedOffset, TimeZone, Utc}; +use leptos::html::Dialog; #[derive(Params, PartialEq)] struct LoanerParams { @@ -30,6 +31,8 @@ struct LoanerReturnOutput { 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] @@ -43,16 +46,18 @@ async fn get_loaners_return_list() -> Result<Vec<LoanerReturnOutput>, ServerFnEr match dbclient .scan() .table_name("loaner-info") - .projection_expression("subject, first_name, last_name") + .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()) @@ -68,11 +73,37 @@ async fn get_loaners_return_list() -> Result<Vec<LoanerReturnOutput>, ServerFnEr } } +#[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 @@ -88,7 +119,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> log!("{:?}", err); match err.code() { Some("ConditionalCheckFailedException") => { - // Retry database write until we have a good subject ID. + // 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 @@ -105,7 +136,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> /// # Loaner Page /// -/// This page is going to temporarily piggy-back on the general scholarship app, at least until +/// 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 @@ -187,11 +218,15 @@ pub fn LoanerBorrowForm() -> impl IntoView { let create_entry_action = ServerAction::<CreateBorrowEntry>::new(); let navigate = use_navigate(); - let temp = ExpandableInfo::new("test"); + 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:" @@ -263,27 +298,124 @@ pub fn LoanerBorrowForm() -> impl IntoView { #[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 || (), + 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"> - {move || return_list_resource.get() - .map(move |loaner_list| { - loaner_list.into_iter().map(move |student| { - view! { - <span>{student.first_name}" "{student.last_name}</span> - } - }).collect_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> } From 1d6d141128f42825a11bdf2e638f6e640011ba9d Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Mon, 8 Dec 2025 16:14:30 -0500 Subject: [PATCH 19/22] chore: remove some unused imports --- src/pages/loaner_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 9e224e0..08284dd 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -15,7 +15,7 @@ use traits::{AsReactive, ReactiveCapture}; use crate::common::{ExpandableInfo, ValueType}; use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select}; use std::collections::HashMap; -use chrono::{DateTime, FixedOffset, TimeZone, Utc}; +use chrono::{FixedOffset, TimeZone, Utc}; use leptos::html::Dialog; #[derive(Params, PartialEq)] From a01feb5e3410edd4296997fb8b872731ca8fb5b6 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Mon, 8 Dec 2025 16:14:45 -0500 Subject: [PATCH 20/22] chore: remove a second unused import --- src/pages/loaner_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 08284dd..9595913 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -15,7 +15,7 @@ use traits::{AsReactive, ReactiveCapture}; use crate::common::{ExpandableInfo, ValueType}; use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select}; use std::collections::HashMap; -use chrono::{FixedOffset, TimeZone, Utc}; +use chrono::{FixedOffset, TimeZone}; use leptos::html::Dialog; #[derive(Params, PartialEq)] From 15b8aee8448a50e4c7d7b01c7b82fb55dd4619e6 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Thu, 11 Dec 2025 13:49:35 -0500 Subject: [PATCH 21/22] feat: create docker dependencies for cloud deployment --- Dockerfile | 9 ++-- docker-compose.yml | 13 ++--- src/app.rs | 81 ++++++++++++++++++++----------- src/pages/unauthenticated_page.rs | 3 ++ 4 files changed, 68 insertions(+), 38 deletions(-) diff --git a/Dockerfile b/Dockerfile index c24d621..85ace70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -FROM rust:1.86 AS builder +FROM rust:1.90 AS builder # Install node for tailwind capability -RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 +RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash @@ -15,6 +15,7 @@ WORKDIR /app COPY . . # Setup fnm and build with leptos +# If this doesn't work, we should use nvm as this is a proven solution. RUN cargo binstall fnm -y \ && fnm --version \ && eval "$(fnm env)" \ @@ -26,7 +27,7 @@ RUN cargo binstall fnm -y \ # Start second phase of build - install ca-certs and copy all build outputs from previous step into a # Debian image in the /app directory. -FROM debian:bookworm-slim AS runtime +FROM debian:trixie-slim AS runtime WORKDIR /app RUN apt-get update -y \ && apt-get install -y --no-install-recommends openssl ca-certificates \ @@ -46,6 +47,8 @@ COPY --from=builder /app/Cargo.toml /app/ ENV RUST_LOG="info" ENV LEPTOS_SITE_ADDR="0.0.0.0:8080" ENV LEPTOS_SITE_ROOT="site" +# Staging URL for server. +ENV LP_SITE_ORIGIN="http://testing-url.com" EXPOSE 8080 # Don't forget about project_name diff --git a/docker-compose.yml b/docker-compose.yml index 02e8ee8..cc725fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,6 @@ - -services: - leptos-watch: - image: rust:1.86 +services: + scholarships-rs: + build: . + image: scholarships-rs-runner:latest ports: - - '3000:3000' - volumes: - - .:/app - command: cargo leptos watch + - '8080:8080' diff --git a/src/app.rs b/src/app.rs index 29ee240..9ee77e8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,8 @@ +use leptos::logging::log; use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_router::{components::{Route, Router, Routes}, path}; -use url::Url; - use crate::pages::{AboutPage, HomePage, LoanerPage, TestPage}; pub fn shell(options: LeptosOptions) -> impl IntoView { @@ -29,12 +28,53 @@ pub fn App() -> impl IntoView { // Provides context that manages stylesheets, titles, meta tags, etc. provide_meta_context(); + view! { + // injects a stylesheet into the document <head> + // id=leptos means cargo-leptos will hot-reload this stylesheet + <Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css"/> + + // sets the document title + <Title text="R15 Scholarship App DEV"/> + + // content for this welcome page + <Router> + <AppWithRoutes /> + </Router> + } +} + +fn use_origin() -> String { + #[cfg(target_arch = "wasm32")] { + use leptos::web_sys; + // Just get the current URL origin. + web_sys::window() + .unwrap() + .location() + .origin() + .unwrap_or("http://localhost:3000/".to_string()) + } + + #[cfg(not(target_arch = "wasm32"))] { + // Read the expected origin out of an environment variable. + std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string()) + } +} + +#[component] +pub fn AppWithRoutes() -> impl IntoView { + provide_meta_context(); + + let current_origin = use_origin(); + log!("Current origin: {}", current_origin); + // Authentication setup let parameters = AuthParameters { issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM".into(), client_id: "10jr2h3vtpu9n7gj46pvg5qo2q".into(), - redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(), - post_logout_redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(), + redirect_uri: format!("{}/", use_origin()), + // redirect_uri: Url::parse(format!("{}", current_origin).as_str()).unwrap().to_string(), + // redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(), + post_logout_redirect_uri: current_origin, scope: Some("openid%20profile%20email".into()), audience: None, challenge: Challenge::None, @@ -47,29 +87,16 @@ pub fn App() -> impl IntoView { let _ = Auth::init(parameters); view! { - // injects a stylesheet into the document <head> - // id=leptos means cargo-leptos will hot-reload this stylesheet - <Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css"/> - - // <AuthLoading><p>"Authentication is loading"</p></AuthLoading> - // <AuthErrorContext><AuthErrorView/></AuthErrorContext> - - // sets the document title - <Title text="R15 Scholarship App DEV"/> - - // content for this welcome page - <Router> - <main> - // TODO Create a 404 page - <Routes fallback=|| "Page not found.".into_view()> - <Route path=path!("") view=HomePage/> - <Route path=path!("about") view=AboutPage/> - <Route path=path!("test_page") view=TestPage/> - <Route path=path!("loaners") view=LoanerPage /> - <Route path=path!("loaners/:form_name") view=LoanerPage /> - </Routes> - </main> - </Router> + <main> + // TODO Create a 404 page + <Routes fallback=|| "Page not found.".into_view()> + <Route path=path!("") view=HomePage/> + <Route path=path!("about") view=AboutPage/> + <Route path=path!("test_page") view=TestPage/> + <Route path=path!("loaners") view=LoanerPage /> + <Route path=path!("loaners/:form_name") view=LoanerPage /> + </Routes> + </main> } } diff --git a/src/pages/unauthenticated_page.rs b/src/pages/unauthenticated_page.rs index b590de9..44f50fb 100644 --- a/src/pages/unauthenticated_page.rs +++ b/src/pages/unauthenticated_page.rs @@ -1,3 +1,4 @@ +use leptos::logging::log; use crate::components::ActionButton; use leptos::prelude::*; use leptos_oidc::AuthSignal; @@ -11,6 +12,8 @@ pub fn UnauthenticatedPage() -> impl IntoView { .map(|unauthenticated| unauthenticated.login_url()) }) }; + + Effect::new(move || log!("Generated login url: {:?}", login_url())); let navigate = leptos_router::hooks::use_navigate(); From 2d7188c5369496f9fbd64886a9e7165565fa1aa6 Mon Sep 17 00:00:00 2001 From: Lucas Vas <lvas463@gmail.com> Date: Fri, 12 Dec 2025 11:38:14 -0500 Subject: [PATCH 22/22] fix: add chrono dependency and fix typo --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app.rs | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4017ea9..67bcabe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2939,6 +2939,7 @@ dependencies = [ "aws-sdk-dynamodb", "axum", "base64 0.22.1", + "chrono", "console_error_panic_hook", "leptos", "leptos_axum", @@ -2950,7 +2951,6 @@ dependencies = [ "serde_dynamo", "tokio", "traits", - "url", "uuid", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 059da02..690e781 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,8 @@ serde = "1.0.219" serde_dynamo = { version = "4.2.14", features = ["aws-sdk-dynamodb+1"], optional = true } uuid = { version = "1.17.0", features = ["serde", "v4", "js"] } leptos_oidc = "0.9.0" -url = "2.5.4" base64 = "0.22.1" +chrono = "0.4.42" macros = { path = "./macros" } traits = { path = "./traits" } diff --git a/src/app.rs b/src/app.rs index c242bdd..7a15fb8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,6 @@ use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_router::{components::{Route, Router, Routes}, path}; use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, TestPage}; -use url::Url; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { @@ -104,7 +103,7 @@ pub fn AppWithRoutes() -> impl IntoView { <Route path=path!("") view=HomePage/> <Route path=path!("about") view=AboutPage/> <Route path=path!("test_page") view=TestPage/> - <Route path=path!("comparison") view=ComparisonTestPage / + <Route path=path!("comparison") view=ComparisonTestPage /> <Route path=path!("loaners") view=LoanerPage /> <Route path=path!("loaners/:form_name") view=LoanerPage /> </Routes>