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
+
+
// TODO Create a 404 page
+
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,
+ member: impl Into,
+ comparison: ComparisonType,
+ target_value: ValueType,
+ category: impl Into,
+ display_text: impl Into,
+ ) -> 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 {
+ 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 {
+ 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),
+ /// 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 {
+ 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::().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 {
+ 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),
+}
+
+impl Comparison for NestedListComparison {
+ fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result {
+ 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>), 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 = 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),
+ /// Flattens the map list to a number list and then performs the given comparison.
+ FlattenToNumberList(String, Box),
+ /// Flattens the map list to a nested list and then performs the given comparison.
+ FlattenToNestedList(String, Box),
+}
+
+impl Comparison for MapListComparison {
+ fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result {
+ // 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, values: Vec) -> HashMap {
+ keys
+ .iter()
+ .cloned()
+ .zip(values.iter().cloned())
+ .collect::>()
+ }
+
+ 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 {
+ // 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::().unwrap(),
+ _ => {
+ return Err("Student value is not a number".into());
+ }
+ };
+
+ let target = match target_value {
+ ValueType::Number(Some(num)) => num.parse::().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 {
+ // 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;
+}
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(
+ list: &Vec,
+ key: &String,
+ verifier: F,
+) -> Result, 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,
+}
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>,
- #[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,
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! {
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
{
+ 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::::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, 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 {
+ 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, 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::().expect("Couldn't find user information.");
+ let user_claims = Signal::derive(move || {
+ auth.with(|auth| {
+ auth.authenticated().and_then(|data| {
+ data.decoded_access_token::(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 = 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> = Resource::new(
+ move || Option::::None,
+ async |_| {
+ get_all_scholarship_info().await.unwrap_or_else(|e| {
+ log!("An error occurred: {:?}", e);
+ Vec::new()
+ })
+ }
+ );
+
+ let comparison_resource: Resource> = Resource::new(
+ move || Option::::None,
+ async |_| {
+ get_comparison_info().await.unwrap_or_else(|e| {
+ log!("An error occurred: {:?}", e);
+ Vec::new()
+ })
+ }
+ );
+
+ view! {
+
+
+
+ {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! {
+
+
+ {result_msg}
+
+
+ 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::, 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"
+
+
+ }),
+ _ => Either::Left(view! {
+ "Failed API requests."
+ })
+ }
+ }}
+
+
+
+ }
+}
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 = 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, 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::::new();
- let log_action = ServerAction::::new();
view! {
@@ -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! {
-
+
@@ -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
/>
@@ -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
/>
@@ -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
/>
@@ -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
/>
@@ -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"
/>
@@ -267,59 +305,13 @@ pub fn HomePage() -> impl IntoView {
data_map=expandable_react.data
/>
-
-
-
-
- 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"
- />
-
@@ -327,21 +319,49 @@ pub fn HomePage() -> impl IntoView {
"Submit"
+ >
+ "Submit"
+
-
- {result_msg}
-
+ ()
+ .unwrap();
+ link.set_href(&*data_url);
+ link.set_target("_blank");
+ link.click();
+ }
+ });
+ }>"Get PDF"
+
+
+ {result_msg}
-
+
}
})
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::();
+ let scholarship_id = move || {
+ url_params.read()
+ .as_ref()
+ .ok()
+ .and_then(|params| params.id.clone())
+ .unwrap_or_default()
+ };
+
+ let get_scholarship_action: Resource = 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> = Resource::new(
+ move || Option::::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::::new();
+ let create_test_comps = ServerAction::::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! {
+
+
+
+
+
+ {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! {
+
+ })
+ .collect_view()
+ })}
+
+
+ "Create test comparisons"
+
+
+
+
+
+
+
+ }
+}
+
+#[component]
+fn ScholarshipForm(
+ response: ExpandableInfo,
+ comparison_list: Vec,
+ submit_action: ServerAction
+) -> 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! {
+
+
+
+ "Scholarship Info Form (test)"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "Submit"
+
+
+ {result_msg}
+
+
+ }
+}
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, 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::().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()),
+ }
+}