From 97ca4a64e94355074eab51d7080e0c58fd34bd74 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Thu, 23 Oct 2025 14:00:59 -0400 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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 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 10/10] 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>