diff --git a/Cargo.lock b/Cargo.lock index aa67db7..67bcabe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2938,6 +2938,7 @@ dependencies = [ "aws-config", "aws-sdk-dynamodb", "axum", + "base64 0.22.1", "chrono", "console_error_panic_hook", "leptos", @@ -2946,12 +2947,10 @@ dependencies = [ "leptos_oidc", "leptos_router", "macros", - "reactive_stores", "serde", "serde_dynamo", "tokio", "traits", - "url", "uuid", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index ed0fae9..690e781 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,12 +21,11 @@ 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" } -chrono = { version = "0.4.42", features = ["serde"] } -reactive_stores = "0.2.5" [features] hydrate = [ diff --git a/docs/schema_notes.pdf b/docs/schema_notes.pdf new file mode 100644 index 0000000..881240e Binary files /dev/null and b/docs/schema_notes.pdf differ 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 9ee77e8..7a15fb8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,21 +3,21 @@ use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_router::{components::{Route, Router, Routes}, path}; -use crate::pages::{AboutPage, HomePage, LoanerPage, TestPage}; +use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, TestPage}; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { - - + + - - + + - + } @@ -82,17 +82,28 @@ pub fn AppWithRoutes() -> 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 + + <main> // TODO Create a 404 page <Routes fallback=|| "Page not found.".into_view()> <Route path=path!("") view=HomePage/> <Route path=path!("about") view=AboutPage/> <Route path=path!("test_page") view=TestPage/> + <Route path=path!("comparison") view=ComparisonTestPage /> <Route path=path!("loaners") view=LoanerPage /> <Route path=path!("loaners/:form_name") view=LoanerPage /> </Routes> diff --git a/src/common/comparison/base.rs b/src/common/comparison/base.rs new file mode 100644 index 0000000..ec45f6c --- /dev/null +++ b/src/common/comparison/base.rs @@ -0,0 +1,110 @@ +use super::{ + Comparison, MapListComparison, NestedListComparison, NumberComparison, NumberListComparison, + TextComparison, TextListComparison, +}; +use crate::common::{ExpandableInfo, ValueType}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +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 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) + .expect(format!("Couldn't find member {:?}, current student data: {:?}", self.member, student_data).as_str()); + self.comparison.evaluate(value, &self.target_value) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +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..ff6112c --- /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, Clone)] +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, Clone)] +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, Clone)] +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, Clone)] +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..3440e97 --- /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, Clone)] +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..23b4d30 --- /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, Clone)] +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/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/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/common/mod.rs b/src/common/mod.rs index fd320cb..3dd4824 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,6 +1,8 @@ +mod comparison; mod expandable_info; mod form_schema; mod user; -pub mod value_type; +mod value_type; +mod form_params; -pub use self::{expandable_info::*, form_schema::*, user::*, value_type::*}; +pub use self::{comparison::*, expandable_info::*, form_schema::*, user::*, value_type::*, form_params::*}; 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/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/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 new file mode 100644 index 0000000..c08f9f4 --- /dev/null +++ b/src/pages/comparison_test.rs @@ -0,0 +1,163 @@ +use leptos::either::Either; +use super::home_page::get_submission; +use super::{UnauthenticatedPage}; +use crate::common::{ + 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 { + let result_msg = RwSignal::new("Found submission, waiting for input.".to_string()); + let auth = use_context::<AuthSignal>().expect("Couldn't find user information."); + let user_claims = Signal::derive(move || { + auth.with(|auth| { + auth.authenticated().and_then(|data| { + data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"]) + }) + }) + }); + + // Note that the value passed in MUST be equatable. + // We get/unwrap the value repeatedly until we get a simple string value, then clone it so that + // we don't lose access to it in the future, should we need it again. + let student_resource: Resource<ExpandableInfo> = Resource::new( + move || user_claims.get().map(|claim| claim.claims.subject.clone()), + async + | opt_username + | match opt_username { + Some(subject) => get_submission(subject.clone()).await.unwrap_or_else(|e| { + console_log(e.to_string().as_str()); + ExpandableInfo::new(subject) + }), + None => ExpandableInfo::new("".to_owned()), + }, + ); + + let scholarship_resource: Resource<Vec<ExpandableInfo>> = Resource::new( + move || Option::<String>::None, + async |_| { + get_all_scholarship_info().await.unwrap_or_else(|e| { + log!("An error occurred: {:?}", e); + Vec::new() + }) + } + ); + + let comparison_resource: Resource<Vec<ComparisonData>> = Resource::new( + move || Option::<String>::None, + async |_| { + get_comparison_info().await.unwrap_or_else(|e| { + log!("An error occurred: {:?}", e); + Vec::new() + }) + } + ); + + view! { + <AuthLoaded fallback=Loading> + <Authenticated unauthenticated=UnauthenticatedPage> + <Suspense fallback=Loading> + {move || { + let student_info = student_resource.get(); + let comparison_info = comparison_resource.get(); + let scholarship_info = scholarship_resource.get(); + + match (student_info, comparison_info, scholarship_info) { + (Some(student), Some(comparisons), Some(scholarships)) => Either::Right(view! { + <Panel> + <Row> + <p>{result_msg}</p> + </Row> + <Row> + <ActionButton on:click=move |_| { + // Iterate over all scholarships, get the comparison IDs, + // get ComparisonData from the IDs, then perform the + // comparisons and return a boolean. Alternatively, return + // a list of comparisons that complete and a list that don't. + let comp_member = "requirements".to_string(); + let comparison_results = scholarships.iter().map(|scholarship| { + let list = match scholarship.data.get(&comp_member) { + Some(ValueType::List(Some(list))) => list, + _ => { + log!("Cannot find requirements, assuming none."); + return Ok(Some(scholarship)) // students will always qualify when there are no requirements + } + }; + + let mut final_result = Ok(true); + + // The list should exist, and it should contain a series of strings. + for val in list { + let comp_opt = match val { + ValueType::String(Some(comp_id)) => { + // Find the comparison + comparisons.iter().find(|comp| comp.id == *comp_id) + } + _ => None + }; + + let comparison_outcome = match comp_opt { + Some(comp) => { + // Perform the comparison + comp.compare(&student) + } + _ => Err("".to_string()) + }; + + match comparison_outcome { + Ok(true) => {}, + Ok(false) => final_result = Ok(false), + Err(e) => { + final_result = Err(e); + break; + } + } + } + + match final_result { + Ok(true) => Ok(Some(scholarship)), + Ok(false) => Ok(None), + Err(e) => Err(e) + } + }).collect::<Vec<Result<Option<&ExpandableInfo>, String>>>(); + + // Build a display string + let mut strings = Vec::new(); + strings.push("Scholarship results: \n".to_string()); + for result in comparison_results { + match result { + Ok(Some(scholarship_info)) => { + // Get the scholarship name. + let name_member = "name".to_string(); + let scl_name = scholarship_info.data.get(&name_member).unwrap() + .as_string().unwrap().unwrap(); + strings.push(scl_name); + }, + Ok(None) => {}, + Err(msg) => strings.push(msg) + } + } + + let display_text = strings.join("\n").to_string(); + + result_msg.set(display_text); + }>"Test Comparison"</ActionButton> + </Row> + </Panel> + }), + _ => Either::Left(view! { + <p>"Failed API requests."</p> + }) + } + }} + </Suspense> + </Authenticated> + </AuthLoaded> + } +} diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index d04cae9..4c6ddb9 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -4,18 +4,25 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; +#[cfg(feature = "ssr")] +use std::process::{Command, Stdio}; +#[cfg(feature = "ssr")] +use std::io::Write; 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; 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::wasm_bindgen::JsCast; use traits::{AsReactive, ReactiveCapture}; /// # Get Student Info @@ -90,14 +97,49 @@ pub async fn create_sample_submission(student_info: ExpandableInfo) -> Result<() } } -#[server(LogExpandableInfo, endpoint = "/log-expandable")] -pub async fn log_expandable(info: ExpandableInfo) -> Result<(), ServerFnError> { - console_log(format!("Was given info: {:?}", info).as_str()); - // Manually specify the type here since we're not able to infer the type later. - let item: HashMap<String, AttributeValue> = to_item(info)?; - console_log(format!("Converted into a DynamoDB item: {:?}", item).as_str()); +#[server(CreateExamplePdf, endpoint = "/example-pdf")] +pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> { + // Create typst template. This will be replaced with getting the template from S3 in the future. + // The path I'm thinking, for now, is that we generate a series of "members" from the available + // keys that a student contains, prefixed with "student_". This means that to get the member + // "unweighted_gpa" we'll use the preprocess variable "student_unweighted_gpa". + let template = r#" + = Testing Typst + This form will create a new student application. The PDF that's generated (like this one) + is a template that will be used later. We want to preprocess these templates to insert the + correct values from the API later on. - Ok(()) + = Second Header + This area is going to do some math because I think that's fun. We'll typeset a simple sum: + $ sum_(i=0)^(10) + (n_i) $ + + = Third Test + This is just going to create a bulleted list inside a numbered list: + + Numbers + + Number again + - Not a number. + "#; + + // Create and run the typst command. + let mut command = Command::new("typst"); + command + .arg("compile") + .arg("-") // Use stdin as input + .arg("-") // Write output to stdout + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + + let mut process = command.spawn()?; + + process.stdin + .take().unwrap() + .write(template.as_bytes())?; + + let output = process.wait_with_output()?; + let pdf_bytes = output.stdout; + + Ok(pdf_bytes) } /// The main home page component. Contains a simple contact form. @@ -128,7 +170,6 @@ pub fn HomePage() -> impl IntoView { }, ); let submit_action = ServerAction::<CreateSampleSubmission>::new(); - let log_action = ServerAction::<LogExpandableInfo>::new(); view! { <AuthLoaded fallback=Loading> @@ -142,8 +183,6 @@ pub fn HomePage() -> impl IntoView { let expandable_react = submission.as_reactive(); let elements_disabled = RwSignal::new(false); let result_msg = Signal::derive(move || { - // Eventually, this function will show and hide a loading symbol - // and a success checkmark. if submit_action.pending().get() { elements_disabled.set(true); "Sending request...".to_owned() @@ -152,17 +191,16 @@ pub fn HomePage() -> impl IntoView { if let Some(result) = submit_action.value().get() { match result { Ok(_) => "Request sent successfully.".to_owned(), - Err(e) => format!("Failed to send request: {:?}", e) + Err(e) => format!("Failed to send request: {:?}", e), } } else { "".to_owned() } } }); - view! { <Row> - <div class="flex flex-col flex-1"/> + <div class="flex flex-col flex-1" /> <Panel> <Row> <h1 class="text-3xl font-bold"> @@ -180,15 +218,15 @@ pub fn HomePage() -> impl IntoView { label="First Name:" placeholder="John" disabled=elements_disabled - data_member = "first_name" - data_map = expandable_react.data + data_member="first_name" + data_map=expandable_react.data /> <OutlinedTextField label="Last Name:" placeholder="Smith" disabled=elements_disabled - data_member = "last_name" - data_map = expandable_react.data + data_member="last_name" + data_map=expandable_react.data /> </Row> <Row> @@ -196,8 +234,8 @@ pub fn HomePage() -> impl IntoView { label="Contact Email:" placeholder="student@region15.org" disabled=elements_disabled - data_member = "contact_email" - data_map = expandable_react.data + data_member="contact_email" + data_map=expandable_react.data /> </Row> <Row> @@ -205,8 +243,8 @@ pub fn HomePage() -> impl IntoView { label="Phone Number:" placeholder="123-456-7890" disabled=elements_disabled - data_member = "phone_number" - data_map = expandable_react.data + data_member="phone_number" + data_map=expandable_react.data /> </Row> <Row> @@ -214,8 +252,8 @@ pub fn HomePage() -> impl IntoView { label="Street Address:" placeholder="123 Fake Street" disabled=elements_disabled - data_member = "address" - data_map = expandable_react.data + data_member="address" + data_map=expandable_react.data /> </Row> <Row> @@ -223,9 +261,9 @@ pub fn HomePage() -> impl IntoView { label="Highest Math SAT Score:" placeholder="600" disabled=elements_disabled - data_member = "math_sat" - data_map = expandable_react.data - input_type = "number" + data_member="math_sat" + data_map=expandable_react.data + input_type="number" /> </Row> <Row> @@ -267,59 +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" - schema = vec![ - input!(Text, "first_name", "First Name:", "John"), - input!(Text, "last_name", "Last Name:", "Smith"), - input!(Select, "gender", "Gender:", ["Male", "Female"]), - input!(Checkbox, "candy", "Favorite Candy:", ["Twizzlers", "Starburst"]) + data_map=expandable_react.data + data_member="community_involvement" + schema=vec![ + input!(Text, "service_name", "Activity Name:", "Some service activity..."), + input!(Number, "service_hours", "Total Service Hours", "20") ] /> </Row> @@ -327,21 +319,49 @@ pub fn HomePage() -> impl IntoView { <ActionButton on:click=move |_| { let captured_map = expandable_react.capture(); - console_log(format!("Map values: {:?}", captured_map).as_str()); - submit_action.dispatch(CreateSampleSubmission { - student_info: captured_map, - }); + console_log( + format!("Map values: {:?}", captured_map).as_str(), + ); + submit_action + .dispatch(CreateSampleSubmission { + student_info: captured_map, + }); } disabled=elements_disabled - >"Submit"</ActionButton> + > + "Submit" + </ActionButton> </Row> <Row> - <p> - {result_msg} - </p> + <ActionButton on:click=move |_| { + console_log("Attempting to get PDF from server endpoint"); + spawn_local(async move { + let result = create_example_pdf().await; + if let Ok(bytes) = result { + let base64 = base64::engine::general_purpose::STANDARD + .encode(bytes); + let data_url = format!( + "data:application/pdf;base64,{}", + base64, + ); + console_log("Found document, opening in new tab..."); + let link = document() + .create_element("a") + .unwrap() + .dyn_into::<HtmlAnchorElement>() + .unwrap(); + link.set_href(&*data_url); + link.set_target("_blank"); + link.click(); + } + }); + }>"Get PDF"</ActionButton> + </Row> + <Row> + <p>{result_msg}</p> </Row> </Panel> - <div class="flex flex-col flex-1"/> + <div class="flex flex-col flex-1" /> </Row> } }) diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 35bb3ce..6c06aaa 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,11 +1,15 @@ mod about_page; +mod comparison_test; mod home_page; mod provider_portal; mod test_page; mod unauthenticated_page; +mod scholarship_info; +mod utils; +mod api; mod loaner_page; pub use self::{ about_page::*, home_page::*, provider_portal::*, test_page::*, unauthenticated_page::*, - loaner_page::LoanerPage, + loaner_page::LoanerPage, comparison_test::*, }; diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs new file mode 100644 index 0000000..63bb3ab --- /dev/null +++ b/src/pages/scholarship_info.rs @@ -0,0 +1,257 @@ +use leptos::logging::log; +use leptos::prelude::*; +use leptos_oidc::{AuthLoaded, Authenticated}; +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}; +use crate::common::ComparisonData; +use super::api::{get_comparison_info, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons}; + + +/// # Scholarship Info Page +/// This page will handle creating or editing scholarship information given a specific +/// scholarship ID and scholarship provider subject number. The form itself will only +/// track a single scholarship (for now) and so selection of what scholarship will be edited +/// must be handled *before* navigation to this page. +/// +/// In its current testing form, we'll be using a single fixed ID. This absolutely *MUST* be +/// changed before a full release. +#[component] +pub fn ScholarshipInfoPage() -> impl IntoView { + // let scholarship_id = String::from("test-scholarship-id2"); + let url_params = use_params::<ScholarshipFormParams>(); + let scholarship_id = move || { + url_params.read() + .as_ref() + .ok() + .and_then(|params| params.id.clone()) + .unwrap_or_default() + }; + + let get_scholarship_action: Resource<ExpandableInfo> = Resource::new( + move || scholarship_id().clone(), + async |id| { + get_scholarship_info(id.clone()).await + // Note that this really should notify us of an error and then redirect or ask + // the user to try again. + .unwrap_or_else(|e| { + log!("Failed to get scholarship info: {:?}", e); + log!("Using default ExpandableInfo"); + ExpandableInfo::new(id) + }) + } + ); + + let get_comparison_info: Resource<Vec<ComparisonData>> = Resource::new( + move || Option::<String>::None, // There's no input to this function. + async |_| { + get_comparison_info().await + .unwrap_or_else(|e| { + log!("Failed to get comparison info: {:?}", e); + Vec::new() + }) + } + ); + + let create_scholarship_action = ServerAction::<CreateScholarshipInfo>::new(); + let create_test_comps = ServerAction::<CreateTestComparisons>::new(); + + // Remember that we only want to show the page after we've got authentication information + // and if there's data for us to use. + view! { + <AuthLoaded fallback=Loading> + <Authenticated unauthenticated=UnauthenticatedPage> + <Suspense fallback=Loading> + <Row> + <div class="flex flex-col flex-1" /> + {move || Suspend::new(async move { + let response = get_scholarship_action.get(); + let comparisons = get_comparison_info.get(); + + response + .zip(comparisons) + .map(|(response, comparison_list)| view! { + <ScholarshipForm + response=response + comparison_list=comparison_list + submit_action=create_scholarship_action + /> + }) + .collect_view() + })} + <div class="hidden"> + <Row> + <ActionButton + on:click=move |_| { + create_test_comps.dispatch(CreateTestComparisons {}); + } + >"Create test comparisons"</ActionButton> + </Row> + </div> + <div class="flex flex-col flex-1" /> + </Row> + </Suspense> + </Authenticated> + </AuthLoaded> + } +} + +#[component] +fn ScholarshipForm( + response: ExpandableInfo, + comparison_list: Vec<ComparisonData>, + submit_action: ServerAction<CreateScholarshipInfo> +) -> impl IntoView { + let reactive_info = response.as_reactive(); + let elements_disabled = RwSignal::new(false); + let result_msg = Signal::derive(move || { + if submit_action.pending().get() { + elements_disabled.set(true); + "Sending request...".to_string() + } else { + elements_disabled.set(false); + if let Some(result) = submit_action.value().get() { + match result { + Ok(()) => "Request sent successfully".to_string(), + Err(err) => { + format!("An error occurred: {:?}", err) + } + } + } else { + "".to_string() + } + } + }); + + // We'll collect the scholarship's name, num_awards, amount_per_award, total_awards, + // fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt) + // award_night_remarks + + view! { + <Panel> + <Row> + <h1 class="text-3xl font-bold"> + "Scholarship Info Form (test)" + </h1> + </Row> + <Row> + <OutlinedTextField + label="Scholarship Name" + placeholder="Example Scholarship Name" + disabled=elements_disabled + data_member="name" + data_map=reactive_info.data + /> + </Row> + <Row> + <OutlinedTextField + label="Amount per award: (if multiple, enter the highest value)" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="amount_per_award" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <OutlinedTextField + label="Total number of awards:" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="num_awards" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <OutlinedTextField + label="Total amount of all awards:" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="total_awards" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <RadioList + data_member="fafsa_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require student financial information?" + /> + </Row> + <Row> + <RadioList + data_member="transcript_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require a student transcript?" + /> + </Row> + <Row> + <RadioList + data_member="award_to" + data_map=reactive_info.data + items=vec!["School".to_string(), "Student".to_string()] + disabled=elements_disabled + label="Will the award be made to the school or the student?" + /> + </Row> + <Row> + <RadioList + data_member="essay_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require a student essay?" + /> + </Row> + <Row> + <RadioList + data_member="essay_prompt" + data_map=reactive_info.data + items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect() + disabled=elements_disabled + label="If so, select a prompt from the list below." + /> + </Row> + <Row> + <ChipsList + label="Scholarship Requirements" + data_member="requirements" + data_map=reactive_info.data + values=comparison_list + .iter() + .cloned() + .map(|comp| comp.id) + .collect() + displayed_text=comparison_list + .iter() + .cloned() + .map(|comp| comp.display_text) + .collect() + /> + </Row> + <Row> + <ActionButton + disabled=elements_disabled + on:click=move |_| { + let captured = reactive_info.capture(); + log!("Map values: {:?}", captured); + submit_action.dispatch(CreateScholarshipInfo { + info: captured + }); + } + >"Submit"</ActionButton> + </Row> + <Row> + <p>{result_msg}</p> + </Row> + </Panel> + } +} 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) + } +} 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()), + } +}