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 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()), + } +}