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/Dockerfile b/Dockerfile index c24d621..85ace70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -FROM rust:1.86 AS builder +FROM rust:1.90 AS builder # Install node for tailwind capability -RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 +RUN apt-get update -y && apt-get install -y --no-install-recommends clang libc6 mold RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash @@ -15,6 +15,7 @@ WORKDIR /app COPY . . # Setup fnm and build with leptos +# If this doesn't work, we should use nvm as this is a proven solution. RUN cargo binstall fnm -y \ && fnm --version \ && eval "$(fnm env)" \ @@ -26,7 +27,7 @@ RUN cargo binstall fnm -y \ # Start second phase of build - install ca-certs and copy all build outputs from previous step into a # Debian image in the /app directory. -FROM debian:bookworm-slim AS runtime +FROM debian:trixie-slim AS runtime WORKDIR /app RUN apt-get update -y \ && apt-get install -y --no-install-recommends openssl ca-certificates \ @@ -46,6 +47,8 @@ COPY --from=builder /app/Cargo.toml /app/ ENV RUST_LOG="info" ENV LEPTOS_SITE_ADDR="0.0.0.0:8080" ENV LEPTOS_SITE_ROOT="site" +# Staging URL for server. +ENV LP_SITE_ORIGIN="http://testing-url.com" EXPOSE 8080 # Don't forget about project_name diff --git a/docker-compose.yml b/docker-compose.yml index 02e8ee8..cc725fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,6 @@ - -services: - leptos-watch: - image: rust:1.86 +services: + scholarships-rs: + build: . + image: scholarships-rs-runner:latest ports: - - '3000:3000' - volumes: - - .:/app - command: cargo leptos watch + - '8080:8080' diff --git a/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/public/PHS_Claw_Logo.png b/public/PHS_Claw_Logo.png new file mode 100644 index 0000000..9bcb67b Binary files /dev/null and b/public/PHS_Claw_Logo.png differ diff --git a/public/PHS_Stacked_Acronym.png b/public/PHS_Stacked_Acronym.png new file mode 100644 index 0000000..94b47ce Binary files /dev/null and b/public/PHS_Stacked_Acronym.png differ diff --git a/public/R15_logo.png b/public/R15_logo.png new file mode 100644 index 0000000..802df65 Binary files /dev/null and b/public/R15_logo.png differ diff --git a/public/R15_logo_colorMatchWhite.png b/public/R15_logo_colorMatchWhite.png new file mode 100644 index 0000000..6842325 Binary files /dev/null and b/public/R15_logo_colorMatchWhite.png differ diff --git a/src/app.rs b/src/app.rs index 1c7065b..abfdbf5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,27 +1,26 @@ +use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, TestPage}; +use leptos::logging::log; use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; -use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge, LoginLink}; +use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_router::{ - StaticSegment, components::{Route, Router, Routes}, + path, }; -use url::Url; - -use crate::pages::{AboutPage, HomePage, TestPage, UnauthenticatedPage}; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { - - + + - - + + - + } @@ -32,12 +31,55 @@ pub fn App() -> impl IntoView { // Provides context that manages stylesheets, titles, meta tags, etc. provide_meta_context(); + view! { + // injects a stylesheet into the document + // id=leptos means cargo-leptos will hot-reload this stylesheet + + + // sets the document title + + + // content for this welcome page + <Router> + <AppWithRoutes /> + </Router> + } +} + +fn use_origin() -> String { + #[cfg(target_arch = "wasm32")] + { + use leptos::web_sys; + // Just get the current URL origin. + web_sys::window() + .unwrap() + .location() + .origin() + .unwrap_or("http://localhost:3000/".to_string()) + } + + #[cfg(not(target_arch = "wasm32"))] + { + // Read the expected origin out of an environment variable. + std::env::var("LP_SITE_ORIGIN").unwrap_or("http://localhost:3000/".to_string()) + } +} + +#[component] +pub fn AppWithRoutes() -> impl IntoView { + provide_meta_context(); + + let current_origin = use_origin(); + log!("Current origin: {}", current_origin); + // Authentication setup let parameters = AuthParameters { issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM".into(), client_id: "10jr2h3vtpu9n7gj46pvg5qo2q".into(), - redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(), - post_logout_redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(), + redirect_uri: format!("{}/", use_origin()), + // redirect_uri: Url::parse(format!("{}", current_origin).as_str()).unwrap().to_string(), + // redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(), + post_logout_redirect_uri: current_origin, scope: Some("openid%20profile%20email".into()), audience: None, challenge: Challenge::None, @@ -52,25 +94,25 @@ pub fn App() -> impl IntoView { view! { // injects a stylesheet into the document <head> // id=leptos means cargo-leptos will hot-reload this stylesheet - <Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css"/> + <Stylesheet id="leptos" href="/pkg/scholarships-rs-wasm.css" /> // <AuthLoading><p>"Authentication is loading"</p></AuthLoading> // <AuthErrorContext><AuthErrorView/></AuthErrorContext> // sets the document title - <Title text="R15 Scholarship App DEV"/> + <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("test_page") view=TestPage/> - </Routes> - </main> - </Router> + <main> + // TODO Create a 404 page + <Routes fallback=|| "Page not found.".into_view()> + <Route path=path!("") view=HomePage/> + <Route path=path!("about") view=AboutPage/> + <Route path=path!("test_page") view=TestPage/> + <Route path=path!("comparison") view=ComparisonTestPage /> + <Route path=path!("loaners") view=LoanerPage /> + <Route path=path!("loaners/:form_name") view=LoanerPage /> + </Routes> + </main> } } diff --git a/src/common/comparison/base.rs b/src/common/comparison/base.rs new file mode 100644 index 0000000..aa6aea8 --- /dev/null +++ b/src/common/comparison/base.rs @@ -0,0 +1,115 @@ +use super::{ + Comparison, MapListComparison, NestedListComparison, NumberComparison, NumberListComparison, + TextComparison, TextListComparison, +}; +use crate::common::{ExpandableInfo, ValueType}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ComparisonData { + /// Each comparison will have a unique ID. + pub id: String, + /// The member from the Student structure to match against. + pub member: String, + /// The comparison to perform. + pub comparison: ComparisonType, + /// The value to match against. + pub target_value: ValueType, + /// The display category for this comparison. Affects where this comparison + /// will be displayed in the provider side of the application. + pub category: String, + /// The text that will be displayed on the provider side. + pub display_text: String, +} + +impl ComparisonData { + pub fn new( + id: impl Into<String>, + member: impl Into<String>, + comparison: ComparisonType, + target_value: ValueType, + category: impl Into<String>, + display_text: impl Into<String>, + ) -> Self { + Self { + id: id.into(), + member: member.into(), + comparison, + target_value, + category: category.into(), + display_text: display_text.into(), + } + } + + pub fn compare(&self, student_data: &ExpandableInfo) -> Result<bool, String> { + let value = student_data.data.get(&self.member).expect( + format!( + "Couldn't find member {:?}, current student data: {:?}", + self.member, student_data + ) + .as_str(), + ); + self.comparison.evaluate(value, &self.target_value) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum ComparisonType { + Number(NumberComparison), + Text(TextComparison), + TextList(TextListComparison), + NumberList(NumberListComparison), + MapList(MapListComparison), + NestedList(NestedListComparison), +} + +impl Comparison for ComparisonType { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + match self { + ComparisonType::Number(n) => n.evaluate(comp_value, target_value), + ComparisonType::Text(t) => t.evaluate(comp_value, target_value), + ComparisonType::NumberList(l) => l.evaluate(comp_value, target_value), + ComparisonType::MapList(m) => m.evaluate(comp_value, target_value), + ComparisonType::TextList(tl) => tl.evaluate(comp_value, target_value), + ComparisonType::NestedList(nl) => nl.evaluate(comp_value, target_value), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::comparison::test_utils::create_student_data; + + #[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..7dc3c53 --- /dev/null +++ b/src/common/comparison/lists.rs @@ -0,0 +1,477 @@ +use super::{Comparison, NumberComparison}; +use crate::common::ValueType; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum NumberListComparison { + /// Sums the values in the list and checks them using the given [`NumberComparison`]. + /// Requires the list to contain only numbers. + Sum(Box<NumberComparison>), + /// Checks if the list contains a specific value. + Contains, + /// Checks if the list does not contain a specific value. + NotContains, +} + +impl Comparison for NumberListComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + match self { + NumberListComparison::Sum(num_comp) => { + // Requires the list to contain only numbers. Get this list or throw an error. + let student_list = match comp_value { + ValueType::List(Some(list)) => list, + _ => return Err("Expected a list".into()), + }; + + let sum = student_list.iter().try_fold(0.0, |acc, item| match item { + ValueType::Number(Some(num_str)) => { + let num = num_str.parse::<f64>().unwrap(); + Ok(acc + num) + } + _ => Err("Expected a number".into()), + }); + + // If the sum is not a number, return an error. + let sum = match sum { + Ok(sum) => sum, + Err(err) => return Err(err), + }; + + // Finally perform the comparison using this sum... we don't have a function for this yet. + match num_comp.evaluate(&ValueType::Number(Some(sum.to_string())), target_value) { + Ok(result) => Ok(result), + Err(err) => Err(err), + } + } + NumberListComparison::Contains => { + // Get list and check if it contains the target value. + // This will check the type and the value, in that order. If the type does not match, + // this will return false. + let list = match comp_value { + ValueType::List(Some(vec)) => vec, + _ => return Err("Expected a list".into()), + }; + + // Check if the list contains the target value. + Ok(list.contains(target_value)) + } + _ => Err("Not implemented".into()), + } + } +} + +/// Represents all available comparisons for a text list. All comparisons are comparable to a +/// single value. +/// +/// For example, using `TextListComparison::Contains` checks if a specific value is contained +/// in the list. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum TextListComparison { + /// Checks if the list contains the target value. + Contains, + /// Checks if the list does not contain the target value. + NotContains, + /// Checks if the list is empty. + IsEmpty, + /// Checks if the list is not empty. + IsNotEmpty, +} + +impl Comparison for TextListComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + match self { + TextListComparison::Contains => { + let list_opt = match comp_value { + ValueType::List(list) => list, + _ => return Err("Expected a list".into()), + }; + + // If the list exists, check if it contains the target value. + // Otherwise, return false. Note that this is not a strongly typed comparison - if + // the list contains multiple different types, no error is thrown. + let check = match list_opt { + Some(list) => list.contains(target_value), + None => false, + }; + + Ok(check) + } + TextListComparison::NotContains => { + let list_opt = match comp_value { + ValueType::List(list) => list, + _ => return Err("Expected a list".into()), + }; + + // If the list exists, check if it does not contain the target value. + // Otherwise, return true. + let check = match list_opt { + Some(list) => !list.contains(target_value), + None => true, + }; + + Ok(check) + } + TextListComparison::IsEmpty => { + let list_opt = match comp_value { + ValueType::List(list) => list, + _ => return Err("Expected a list".into()), + }; + + // If the list exists, check if it is empty. + // Otherwise, return false. + let check = match list_opt { + Some(list) => list.is_empty(), + None => true, + }; + + Ok(check) + } + TextListComparison::IsNotEmpty => { + let list_opt = match comp_value { + ValueType::List(list) => list, + _ => return Err("Expected a list".into()), + }; + + // If the list exists, check if it is not empty. + // Otherwise, return true. + let check = match list_opt { + Some(list) => !list.is_empty(), + None => false, + }; + + Ok(check) + } + } + } +} + +/// For right now, nested lists should be flattened into a list of a single type before they +/// are compared. The plan is to create a more robust framework that eventually be able to +/// query specific lists and perform comparisons on them. +/// +/// For example: +/// ``` +/// NestedListComparison::FlattenToTextList(Box::new(TextListComparison::Contains)); +/// ``` +/// +/// This will flatten a nested list into a single list of `ValueType::String` enums and then +/// will check if the list contains the indicated target value. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum NestedListComparison { + /// Flattens a nested list into a single text list, then performs the given comparison. + FlattenToTextList(Box<TextListComparison>), +} + +impl Comparison for NestedListComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + match self { + NestedListComparison::FlattenToTextList(t) => { + // Check the comp_value for a series of lists. We'll then iterate over each list and flatten it to a text list. + // The end type should be a ValueType::List(Option<Vec<T>>), where T is ValueType::String. + let list = match comp_value { + ValueType::List(Some(list)) => list, + _ => return Err("Expected a list of values".to_owned()), + }; + + let mut out: Vec<ValueType> = Vec::new(); + + for item in list { + // Every item should be another list. + let sub_list = match item { + ValueType::List(Some(sub_list)) => sub_list, + _ => return Err("Expected a list of lists".to_owned()), + }; + + for sub_item in sub_list { + match sub_item { + s @ ValueType::String(_) => out.push(s.clone()), + _ => return Err("Expected a list of strings".to_owned()), + } + } + } + + t.evaluate(&ValueType::List(Some(out)), target_value) + } + } + } +} + +/// Lists of maps must be flattened to a list of some type before they can be compared. +/// Flattening maps simply extracts the values from a specific key from all maps in the list. +/// This makes comparison much simpler to implement and understand. +/// +/// All flattening operations also contain a `String` key (the map's member to flatten) +/// and some `Comparison` enum that matches the type of the flattened values. Flattening will +/// fail if any single map in the list does not contain the specified key or if the value +/// is not of the expected type. +/// +/// For example, to flatten a list of maps by using the `name` key, we would use the following +/// structure: +/// ``` +/// MapListComparison::FlattenToTextList("name".to_owned(), Box::new(TextListComparison::Equal)); +/// ``` +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum MapListComparison { + /// Flattens the map list to a text list and then performs the given comparison. + FlattenToTextList(String, Box<TextListComparison>), + /// Flattens the map list to a number list and then performs the given comparison. + FlattenToNumberList(String, Box<NumberListComparison>), + /// Flattens the map list to a nested list and then performs the given comparison. + FlattenToNestedList(String, Box<NestedListComparison>), +} + +impl Comparison for MapListComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + // Define base list - this must be present otherwise we cannot proceed. + let list = match comp_value { + ValueType::List(Some(list)) => list, + ValueType::List(None) => &Vec::new(), + _ => return Err("Expected a list".to_owned()), + }; + + match self { + MapListComparison::FlattenToTextList(key, comp) => { + let mut out = Vec::new(); + + for item in list { + let map = match item { + ValueType::Map(Some(map)) => map, + _ => return Err("Expected a map".to_string()), + }; + + // Ignore any non-string values. + if let Some(text @ ValueType::String(_)) = map.get(key) { + out.push(text.clone()); + } + } + + let flattened = ValueType::List(Some(out)); + comp.evaluate(&flattened, target_value) + } + MapListComparison::FlattenToNumberList(key, comp) => { + let mut out = Vec::new(); + for item in list { + let map = match item { + ValueType::Map(Some(map)) => map, + _ => return Err("Expected a map".to_string()), + }; + + // Ignore any non-number values. + if let Some(number @ ValueType::Number(_)) = map.get(key) { + out.push(number.clone()); + } + } + + let flattened = ValueType::List(Some(out)); + comp.evaluate(&flattened, target_value) + } + MapListComparison::FlattenToNestedList(key, comp) => { + let mut out = Vec::new(); + for item in list { + let map = match item { + ValueType::Map(Some(map)) => map, + _ => return Err("Expected a map".to_string()), + }; + + // Ignore any non-list values. + if let Some(list @ ValueType::List(_)) = map.get(key) { + out.push(list.clone()); + } + } + + let flattened = ValueType::List(Some(out)); + comp.evaluate(&flattened, target_value) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::super::test_utils::create_map_list_helper; + use super::*; + 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..bcf85b1 --- /dev/null +++ b/src/common/comparison/mod.rs @@ -0,0 +1,144 @@ +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 crate::common::{ExpandableInfo, ValueType}; + use std::collections::HashMap; + + pub fn create_student_data() -> ExpandableInfo { + let map = create_map_helper( + vec![ + "first_name".to_string(), + "last_name".to_string(), + "gender".to_string(), + "sat_score".to_string(), + "athletic_participation".to_string(), + "community_involvement".to_string(), + ], + vec![ + ValueType::String(Some("John".to_string())), + ValueType::String(Some("Doe".to_string())), + ValueType::String(Some("Male".to_string())), + ValueType::Number(Some(1190.to_string())), + ValueType::Map(Some(create_map_helper( + vec![ + "sport_name".to_string(), + "grades_participated".to_string(), + "special_achievement".to_string(), + ], + vec![ + ValueType::String(Some("Football".to_string())), + ValueType::List(Some(vec![ + ValueType::String(Some(9.to_string())), + ValueType::String(Some(10.to_string())), + ])), + ValueType::String(Some("N/A".to_string())), + ], + ))), + ValueType::Map(Some(create_map_helper( + vec!["activity_name".to_string(), "total_hours".to_string()], + vec![ + ValueType::String(Some("Raking".to_string())), + ValueType::Number(Some(30.to_string())), + ], + ))), + ], + ); + + let mut info = ExpandableInfo::new("test"); + info.data = map; + + info + } + + pub fn create_map_helper( + keys: Vec<String>, + values: Vec<ValueType>, + ) -> HashMap<String, ValueType> { + keys.iter() + .cloned() + .zip(values.iter().cloned()) + .collect::<HashMap<String, ValueType>>() + } + + pub fn create_map_list_helper() -> ValueType { + let map_one = create_map_helper( + vec![ + "first_name".to_string(), + "last_name".to_string(), + "gender".to_string(), + "sat_score".to_string(), + "grades_participated".to_string(), + ], + vec![ + ValueType::String(Some("John".to_string())), + ValueType::String(Some("Doe".to_string())), + ValueType::String(Some("Male".to_string())), + ValueType::Number(Some(1200.to_string())), + ValueType::List(Some(vec![ + ValueType::String(Some("9".to_string())), + ValueType::String(Some("10".to_string())), + ])), + ], + ); + + let map_two = create_map_helper( + vec![ + "first_name".to_string(), + "last_name".to_string(), + "gender".to_string(), + "sat_score".to_string(), + "grades_participated".to_string(), + ], + vec![ + ValueType::String(Some("Jane".to_string())), + ValueType::String(Some("Doe".to_string())), + ValueType::String(Some("Female".to_string())), + ValueType::Number(Some(1000.to_string())), + ValueType::List(Some(vec![ + ValueType::String(Some("11".to_string())), + ValueType::String(Some("12".to_string())), + ])), + ], + ); + + ValueType::List(Some(vec![ + ValueType::Map(Some(map_one)), + ValueType::Map(Some(map_two)), + ])) + } + + pub fn create_empty_lists() -> (ValueType, ValueType) { + let none_list = ValueType::List(None); + let empty_list = ValueType::List(Some(vec![])); + + (none_list, empty_list) + } + + pub fn create_number_list() -> ValueType { + ValueType::List(Some(vec![ + ValueType::Number(Some(1.to_string())), + ValueType::Number(Some(2.to_string())), + ValueType::Number(Some(3.to_string())), + ValueType::Number(Some(4.to_string())), + ValueType::Number(Some(5.to_string())), + ])) + } + + pub fn create_text_list() -> ValueType { + ValueType::List(Some(vec![ + ValueType::String(Some("one".to_string())), + ValueType::String(Some("two".to_string())), + ValueType::String(Some("three".to_string())), + ValueType::String(Some("four".to_string())), + ValueType::String(Some("five".to_string())), + ])) + } +} diff --git a/src/common/comparison/number.rs b/src/common/comparison/number.rs new file mode 100644 index 0000000..3440e97 --- /dev/null +++ b/src/common/comparison/number.rs @@ -0,0 +1,44 @@ +use super::Comparison; +use crate::common::ValueType; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum NumberComparison { + GreaterThan, + LessThan, + Equal, + NotEqual, + GreaterThanOrEqual, + LessThanOrEqual, +} + +impl Comparison for NumberComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + // The prerequisites for all number comparisons is that the target value is Some(number) and the + // comparison member points to Some(number) in the student_info. + let student_value = match comp_value { + ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(), + _ => { + return Err("Student value is not a number".into()); + } + }; + + let target = match target_value { + ValueType::Number(Some(num)) => num.parse::<i32>().unwrap(), + _ => { + return Err("Target value is not a number".into()); + } + }; + + let matched = match self { + NumberComparison::GreaterThan => student_value > target, + NumberComparison::LessThan => student_value < target, + NumberComparison::Equal => student_value == target, + NumberComparison::NotEqual => student_value != target, + NumberComparison::GreaterThanOrEqual => student_value >= target, + NumberComparison::LessThanOrEqual => student_value <= target, + }; + + Ok(matched) + } +} diff --git a/src/common/comparison/text.rs b/src/common/comparison/text.rs new file mode 100644 index 0000000..23b4d30 --- /dev/null +++ b/src/common/comparison/text.rs @@ -0,0 +1,36 @@ +use super::Comparison; +use crate::common::ValueType; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum TextComparison { + Matches, + NotMatches, + Contains, + NotContains, +} + +impl Comparison for TextComparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String> { + // All text comparisons require that the comp.member points to a Some(string) in the student_info, + // and that the target_value is also a Some(string). + let student_value = match comp_value { + ValueType::String(Some(value)) => value, + _ => return Err("Student value is not a string".into()), + }; + + let target_value = match target_value { + ValueType::String(Some(value)) => value, + _ => return Err("Target value is not a string".into()), + }; + + let matched = match self { + TextComparison::Matches => student_value == target_value, + TextComparison::NotMatches => student_value != target_value, + TextComparison::Contains => student_value.contains(target_value), + TextComparison::NotContains => !student_value.contains(target_value), + }; + + Ok(matched) + } +} diff --git a/src/common/comparison/traits.rs b/src/common/comparison/traits.rs new file mode 100644 index 0000000..69f0f7a --- /dev/null +++ b/src/common/comparison/traits.rs @@ -0,0 +1,5 @@ +use crate::common::ValueType; + +pub trait Comparison { + fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String>; +} diff --git a/src/common/comparison/utils.rs b/src/common/comparison/utils.rs new file mode 100644 index 0000000..244f78a --- /dev/null +++ b/src/common/comparison/utils.rs @@ -0,0 +1,28 @@ +use crate::common::ValueType; + +/// This function is a work in progress. It's designed to flatten a list of maps by using the +/// given key and verifier function, however it currently doesn't work and just needs to be +/// planned out a little more. +fn flatten_map_helper<F>( + list: &Vec<ValueType>, + key: &String, + verifier: F, +) -> Result<Vec<ValueType>, String> +where + F: Fn(&ValueType) -> Option<&ValueType>, +{ + let mut out = Vec::new(); + for item in list { + let map = match item { + ValueType::Map(Some(map)) => map, + _ => return Err("Expected a map".to_owned()), + }; + + // Get the key, and verify that the value is the correct type. If so, push it. + if let Some(value) = map.get(key).and_then(|v| verifier(v)) { + out.push(value.clone()); + } + } + + Ok(out) +} diff --git a/src/common/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/form_params.rs b/src/common/form_params.rs new file mode 100644 index 0000000..7a8368c --- /dev/null +++ b/src/common/form_params.rs @@ -0,0 +1,7 @@ +use leptos::Params; +use leptos_router::params::Params; + +#[derive(Params, PartialEq, Debug)] +pub struct ScholarshipFormParams { + pub id: Option<String>, +} diff --git a/src/common/form_schema.rs b/src/common/form_schema.rs index 84e2816..e5ec97e 100644 --- a/src/common/form_schema.rs +++ b/src/common/form_schema.rs @@ -49,6 +49,7 @@ impl InputType { placeholder = placeholder.clone() data_member = member.clone() data_map = data_map + input_type = "number" /> }), InputType::Checkbox(member, label, options) => diff --git a/src/common/mod.rs b/src/common/mod.rs index 2a3b734..ede36ba 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,8 +1,12 @@ -mod date; +mod comparison; mod date_info; mod expandable_info; +mod form_params; mod form_schema; mod user; -pub mod value_type; +mod value_type; -pub use self::{date_info::*, expandable_info::*, form_schema::*, user::*, value_type::*}; +pub use self::{ + comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*, user::*, + value_type::*, +}; diff --git a/src/components/banner.rs b/src/components/banner.rs new file mode 100644 index 0000000..79b95ca --- /dev/null +++ b/src/components/banner.rs @@ -0,0 +1,48 @@ +use leptos::prelude::*; +use leptos_router::hooks::use_navigate; + +/// # Banner Component +/// +/// This component displays a red banner at the top of the page with a title and a logo. +/// Clicking the logo will redirect a user to specified page, preferably a home page. +/// +/// Example usage: +/// ``` +/// <Banner +/// title="Test Page" +/// logo="medal.png" +/// path="/" +/// /> +/// ``` +#[component] +pub fn Banner( + /// The title of the banner. + #[prop(into)] + title: String, + /// The file path or URL of a logo displayed at the top left of the banner. + #[prop(into)] + logo: String, + /// The route that the navigates user to a page (home page). + #[prop(into, default = String::from("/"))] + path: String, +) -> impl IntoView { + let navigate = use_navigate(); + + let on_click = move |_| navigate(&path, Default::default()); + + view! { + <div class="mx-auto"> + <div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]"> + <div class="flex items-center gap-4 p-6"> + <div class="flex-shrink-0"> + <img src={logo.clone()} class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click/> + </div> + <div class="flex-1"> + <h2 class="text-white text-lg font-bold">{title.clone()}</h2> + </div> + </div> + </div> + </div> + + } +} diff --git a/src/components/dashboard_button.rs b/src/components/dashboard_button.rs index 9310b33..f471e33 100644 --- a/src/components/dashboard_button.rs +++ b/src/components/dashboard_button.rs @@ -40,7 +40,7 @@ pub fn DashboardButton( view! { <button type="button" - class="dashboard-button flex items-start gap-3 rounded-lg border-grey-300 p-6 + class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6 hover:bg-gray-100 transition cursor-pointer w-full text-left shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]" on:click=on_click> diff --git a/src/components/mod.rs b/src/components/mod.rs index 58f0fc5..eace015 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,4 +1,5 @@ mod action_button; +mod banner; mod checkbox; mod chips; mod dashboard_button; @@ -13,6 +14,6 @@ mod text_field; mod utils; pub use self::{ - action_button::*, checkbox::*, chips::*, dashboard_button::*, date::*, loading::*, + action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, date::*, loading::*, multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*, }; diff --git a/src/components/multi_entry.rs b/src/components/multi_entry.rs index a2e1015..8d7aca3 100644 --- a/src/components/multi_entry.rs +++ b/src/components/multi_entry.rs @@ -73,7 +73,9 @@ pub fn MultiEntry( /// component's `data_member` input. #[prop()] data_map: RwSignal<HashMap<String, ValueType>>, - #[prop(into)] name_member: String, + /// Currently unused. + #[prop(optional, into)] + name_member: String, /// The type and order of the inputs for each `Entry` component within this `MultiEntry` component. #[prop(optional)] schema: Vec<InputType>, diff --git a/src/components/panel.rs b/src/components/panel.rs index bfd17c9..07f025d 100644 --- a/src/components/panel.rs +++ b/src/components/panel.rs @@ -29,7 +29,7 @@ pub fn Panel( ) -> impl IntoView { view! { <div - class="flex flex-col gap-2 p-2 m-4 flex-1 + class="flex flex-col gap-2 p-2 m-4 flex-2 border-1 border-gray-600/10 rounded-md shadow-[0_0_6px_rgba(0,0,0,0.50)]" class=(["hidden"], hidden) > diff --git a/src/lib.rs b/src/lib.rs index 9958e9f..7ed69e6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; mod common; mod components; mod pages; +mod utils; #[cfg(feature = "hydrate")] #[wasm_bindgen::prelude::wasm_bindgen] diff --git a/src/pages/api/comparisons.rs b/src/pages/api/comparisons.rs new file mode 100644 index 0000000..b343b24 --- /dev/null +++ b/src/pages/api/comparisons.rs @@ -0,0 +1,350 @@ +#[cfg(feature = "ssr")] +use crate::pages::utils::server_utils::create_dynamo_client; + +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::error::ProvideErrorMetadata; + +use crate::common::{ + ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison, + TextComparison, TextListComparison, ValueType, +}; +use leptos::leptos_dom::log; +use leptos::prelude::ServerFnError; +use leptos::server; + +/// Helper function to create all sports comparisons. Only used to create initial +/// comparison data, and should be removed upon completion of a comparison editor page. +fn create_sports_comparisons() -> Vec<ComparisonData> { + vec![ + ComparisonData::new( + "sports_football", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Football".to_string())), + "Sports Participation", + "Football", + ), + ComparisonData::new( + "sports_soccer", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Soccer".to_string())), + "Sports Participation", + "Soccer", + ), + ComparisonData::new( + "sports_cheerleading", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Cheerleading".to_string())), + "Sports Participation", + "Cheerleading", + ), + ComparisonData::new( + "sports_field_hockey", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Field Hockey".to_string())), + "Sports Participation", + "Field Hockey", + ), + ComparisonData::new( + "sports_swimming", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Swimming".to_string())), + "Sports Participation", + "Swimming", + ), + ComparisonData::new( + "sports_golf", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Golf".to_string())), + "Sports Participation", + "Golf", + ), + ComparisonData::new( + "sports_basketball", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Basketball".to_string())), + "Sports Participation", + "Basketball", + ), + ComparisonData::new( + "sports_track", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Track".to_string())), + "Sports Participation", + "Track", + ), + ComparisonData::new( + "sports_gymnastics", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Gymnastics".to_string())), + "Sports Participation", + "Gymnastics", + ), + ComparisonData::new( + "sports_ice_hockey", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Ice Hockey".to_string())), + "Sports Participation", + "Ice Hockey", + ), + ComparisonData::new( + "sports_ski", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Ski".to_string())), + "Sports Participation", + "Ski", + ), + ComparisonData::new( + "sports_wrestling", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Wrestling".to_string())), + "Sports Participation", + "Wrestling", + ), + ComparisonData::new( + "sports_lacrosse", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Lacrosse".to_string())), + "Sports Participation", + "Lacrosse", + ), + ComparisonData::new( + "sports_softball", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Softball".to_string())), + "Sports Participation", + "Softball", + ), + ComparisonData::new( + "sports_tennis", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Tennis".to_string())), + "Sports Participation", + "Tennis", + ), + ] +} + +#[server(CreateTestComparisons, endpoint = "/comparisons/create-test")] +pub async fn create_test_comparisons() -> Result<(), ServerFnError> { + let client = create_dynamo_client().await; + + log!("Creating test comparisons"); + + let test_comp = ComparisonData::new( + "math_sat_comp", + "math_sat", + ComparisonType::Number(NumberComparison::GreaterThanOrEqual), + ValueType::Number(Some(650.to_string())), + "Academic Information", + "Math SAT Score > 650", + ); + + let service_hours_25 = ComparisonData::new( + "map_sum_comp", + "community_involvement", + ComparisonType::MapList(MapListComparison::FlattenToNumberList( + "service_hours".to_string(), + Box::new(NumberListComparison::Sum(Box::new( + NumberComparison::GreaterThanOrEqual, + ))), + )), + ValueType::Number(Some(25.to_string())), + "Community Involvement", + "25 service hours", + ); + + let service_hours_20 = ComparisonData::new( + "service_hours_20", + "community_involvement", + ComparisonType::MapList(MapListComparison::FlattenToNumberList( + "service_hours".to_string(), + Box::new(NumberListComparison::Sum(Box::new( + NumberComparison::GreaterThanOrEqual, + ))), + )), + ValueType::Number(Some(20.to_string())), + "Community Involvement", + "20 service hours", + ); + + let service_hours_30 = ComparisonData::new( + "service_hours_30", + "community_involvement", + ComparisonType::MapList(MapListComparison::FlattenToNumberList( + "service_hours".to_string(), + Box::new(NumberListComparison::Sum(Box::new( + NumberComparison::GreaterThanOrEqual, + ))), + )), + ValueType::Number(Some(30.to_string())), + "Community Involvement", + "30 service hours", + ); + + let residency_southbury = ComparisonData::new( + "residency_southbury", + "town", + ComparisonType::Text(TextComparison::Matches), + ValueType::String(Some("Southbury".to_string())), + "Residency", + "Southbury", + ); + + let residency_middlebury = ComparisonData::new( + "residency_middlebury", + "town", + ComparisonType::Text(TextComparison::Matches), + ValueType::String(Some("Middlebury".to_string())), + "Residency", + "Middlebury", + ); + + let attended_bas = ComparisonData::new( + "attended_bas", + "attended_bas", + ComparisonType::Text(TextComparison::Matches), + ValueType::String(Some("Yes".to_string())), + "Miscellaneous", + "Attended BAS", + ); + + let midd_south = ComparisonData::new( + "midd_south", + "middsouth_church", + ComparisonType::Text(TextComparison::Matches), + ValueType::String(Some("Yes".to_string())), + "Miscellaneous", + "Member of Midd-South Church", + ); + + let family_military = ComparisonData::new( + "family_military", + "family_military_service", + ComparisonType::Text(TextComparison::Matches), + ValueType::String(Some("Yes".to_string())), + "Miscellaneous", + "Family with Military Service", + ); + + let mut sports_comps = create_sports_comparisons(); + let mut major_comps = Vec::<ComparisonData>::new(); + + let mut comp_list = vec![ + test_comp, + service_hours_20, + service_hours_25, + service_hours_30, + residency_middlebury, + residency_southbury, + attended_bas, + midd_south, + family_military, + ]; + + comp_list.append(&mut sports_comps); + comp_list.append(&mut major_comps); + + for comparison in comp_list { + if let Err(err) = client + .put_item() + .table_name("leptos-comparison-test") + .set_item(Some(serde_dynamo::to_item(&comparison)?)) + .send() + .await + { + let msg = err.message().unwrap_or("An unknown error occurred"); + return Err(ServerFnError::new(msg)); + } + } + + Ok(()) +} + +#[server] +pub async fn get_comparison_info() -> Result<Vec<ComparisonData>, ServerFnError> { + let client = create_dynamo_client().await; + + log!("Getting all comparisons from the database"); + + // Query the database for all comparisons. The client is only going to use the + // id and display text, but we'll return the whole thing. + match client + .scan() + .table_name("leptos-comparison-test") + .send() + .await + { + Ok(output) => { + if let Some(items) = output.items { + log!("Found comparisons from API: {:?}", items); + Ok(serde_dynamo::from_items(items)?) + } else { + Ok(vec![]) + } + } + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred"); + Err(ServerFnError::new(msg)) + } + } +} diff --git a/src/pages/api/mod.rs b/src/pages/api/mod.rs new file mode 100644 index 0000000..e13ff2b --- /dev/null +++ b/src/pages/api/mod.rs @@ -0,0 +1,4 @@ +mod comparisons; +mod scholarships; + +pub use self::{comparisons::*, scholarships::*}; diff --git a/src/pages/api/scholarships.rs b/src/pages/api/scholarships.rs new file mode 100644 index 0000000..4ec5301 --- /dev/null +++ b/src/pages/api/scholarships.rs @@ -0,0 +1,91 @@ +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue}; + +use crate::common::ExpandableInfo; +use leptos::leptos_dom::log; +use leptos::prelude::ServerFnError; +use leptos::server; + +#[cfg(feature = "ssr")] +use crate::pages::utils::server_utils::create_dynamo_client; + +#[server(GetScholarshipInfo, endpoint = "/scholarship/info/get")] +pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFnError> { + let client = create_dynamo_client().await; + + // Perform the operation - we just want to return all data that's contained in this entry, + // or just return an empty ExpandableInfo struct. + log!("Getting scholarship info using id {:?}", id); + match client + .get_item() + .table_name("leptos-scholarship-test") + .key("subject", AttributeValue::S(id.clone())) + .send() + .await + { + Ok(output) => { + if let Some(item) = output.item { + log!("Found output from API: {:?}", item); + Ok(serde_dynamo::from_item(item)?) + } else { + log!("Couldn't find any values, returning default struct."); + Ok(ExpandableInfo::new(id.clone())) + } + } + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred"); + Err(ServerFnError::new(msg)) + } + } +} + +#[server(CreateScholarshipInfo, endpoint = "/scholarship/info/create")] +pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerFnError> { + use crate::pages::utils::server_utils::create_dynamo_client; + use aws_sdk_dynamodb::error::ProvideErrorMetadata; + + let client = create_dynamo_client().await; + + log!( + "Creating or updating scholarship with ID {:?}", + info.subject + ); + + match client + .put_item() + .table_name("leptos-scholarship-test") + .set_item(Some(serde_dynamo::to_item(&info)?)) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred"); + Err(ServerFnError::new(msg)) + } + } +} + +#[server(GetAllScholarshipInfo, endpoint = "/scholarship/info/get-all")] +pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnError> { + let client = create_dynamo_client().await; + log!("Getting all scholarship info"); + match client + .scan() + .table_name("leptos-scholarship-test") + .send() + .await + { + Ok(output) => { + if let Some(items) = output.items { + Ok(serde_dynamo::from_items(items)?) + } else { + Ok(Vec::new()) + } + } + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred"); + Err(ServerFnError::new(msg)) + } + } +} diff --git a/src/pages/comparison_test.rs b/src/pages/comparison_test.rs new file mode 100644 index 0000000..4ef31cb --- /dev/null +++ b/src/pages/comparison_test.rs @@ -0,0 +1,159 @@ +use super::UnauthenticatedPage; +use super::api::{get_all_scholarship_info, get_comparison_info}; +use super::home_page::get_submission; +use crate::common::{ComparisonData, ExpandableInfo, UserClaims, ValueType}; +use crate::components::{ActionButton, Loading, Panel, Row}; +use leptos::either::Either; +use leptos::leptos_dom::logging::console_log; +use leptos::logging::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 student_resource: Resource<ExpandableInfo> = Resource::new( + move || user_claims.get().map(|claim| claim.claims.subject.clone()), + async |opt_username| match opt_username { + Some(subject) => get_submission(subject.clone()).await.unwrap_or_else(|e| { + console_log(e.to_string().as_str()); + ExpandableInfo::new(subject) + }), + None => ExpandableInfo::new("".to_owned()), + }, + ); + + let scholarship_resource: Resource<Vec<ExpandableInfo>> = Resource::new( + move || Option::<String>::None, + async |_| { + get_all_scholarship_info().await.unwrap_or_else(|e| { + log!("An error occurred: {:?}", e); + Vec::new() + }) + }, + ); + + let comparison_resource: Resource<Vec<ComparisonData>> = Resource::new( + move || Option::<String>::None, + async |_| { + get_comparison_info().await.unwrap_or_else(|e| { + log!("An error occurred: {:?}", e); + Vec::new() + }) + }, + ); + + view! { + <AuthLoaded fallback=Loading> + <Authenticated unauthenticated=UnauthenticatedPage> + <Suspense fallback=Loading> + {move || { + let student_info = student_resource.get(); + let comparison_info = comparison_resource.get(); + let scholarship_info = scholarship_resource.get(); + + match (student_info, comparison_info, scholarship_info) { + (Some(student), Some(comparisons), Some(scholarships)) => Either::Right(view! { + <Panel> + <Row> + <p>{result_msg}</p> + </Row> + <Row> + <ActionButton on:click=move |_| { + // Iterate over all scholarships, get the comparison IDs, + // get ComparisonData from the IDs, then perform the + // comparisons and return a boolean. Alternatively, return + // a list of comparisons that complete and a list that don't. + let comp_member = "requirements".to_string(); + let comparison_results = scholarships.iter().map(|scholarship| { + let list = match scholarship.data.get(&comp_member) { + Some(ValueType::List(Some(list))) => list, + _ => { + log!("Cannot find requirements, assuming none."); + return Ok(Some(scholarship)) // students will always qualify when there are no requirements + } + }; + + let mut final_result = Ok(true); + + // The list should exist, and it should contain a series of strings. + for val in list { + let comp_opt = match val { + ValueType::String(Some(comp_id)) => { + // Find the comparison + comparisons.iter().find(|comp| comp.id == *comp_id) + } + _ => None + }; + + let comparison_outcome = match comp_opt { + Some(comp) => { + // Perform the comparison + comp.compare(&student) + } + _ => Err("".to_string()) + }; + + match comparison_outcome { + Ok(true) => {}, + Ok(false) => final_result = Ok(false), + Err(e) => { + final_result = Err(e); + break; + } + } + } + + match final_result { + Ok(true) => Ok(Some(scholarship)), + Ok(false) => Ok(None), + Err(e) => Err(e) + } + }).collect::<Vec<Result<Option<&ExpandableInfo>, String>>>(); + + // Build a display string + let mut strings = Vec::new(); + strings.push("Scholarship results: \n".to_string()); + for result in comparison_results { + match result { + Ok(Some(scholarship_info)) => { + // Get the scholarship name. + let name_member = "name".to_string(); + let scl_name = scholarship_info.data.get(&name_member).unwrap() + .as_string().unwrap().unwrap(); + strings.push(scl_name); + }, + Ok(None) => {}, + Err(msg) => strings.push(msg) + } + } + + let display_text = strings.join("\n").to_string(); + + result_msg.set(display_text); + }>"Test Comparison"</ActionButton> + </Row> + </Panel> + }), + _ => Either::Left(view! { + <p>"Failed API requests."</p> + }) + } + }} + </Suspense> + </Authenticated> + </AuthLoaded> + } +} diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index d04cae9..c3ca850 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::io::Write; +#[cfg(feature = "ssr")] +use std::process::{Command, Stdio}; 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 crate::pages::UnauthenticatedPage; +use base64::Engine; use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; +use leptos::task::spawn_local; +use leptos::wasm_bindgen::JsCast; +use leptos::web_sys::HtmlAnchorElement; use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; -use std::collections::HashMap; use traits::{AsReactive, ReactiveCapture}; /// # Get Student Info @@ -90,14 +97,47 @@ pub async fn create_sample_submission(student_info: ExpandableInfo) -> Result<() } } -#[server(LogExpandableInfo, endpoint = "/log-expandable")] -pub async fn log_expandable(info: ExpandableInfo) -> Result<(), ServerFnError> { - console_log(format!("Was given info: {:?}", info).as_str()); - // Manually specify the type here since we're not able to infer the type later. - let item: HashMap<String, AttributeValue> = to_item(info)?; - console_log(format!("Converted into a DynamoDB item: {:?}", item).as_str()); +#[server(CreateExamplePdf, endpoint = "/example-pdf")] +pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> { + // Create typst template. This will be replaced with getting the template from S3 in the future. + // The path I'm thinking, for now, is that we generate a series of "members" from the available + // keys that a student contains, prefixed with "student_". This means that to get the member + // "unweighted_gpa" we'll use the preprocess variable "student_unweighted_gpa". + let template = r#" + = Testing Typst + This form will create a new student application. The PDF that's generated (like this one) + is a template that will be used later. We want to preprocess these templates to insert the + correct values from the API later on. - Ok(()) + = Second Header + This area is going to do some math because I think that's fun. We'll typeset a simple sum: + $ sum_(i=0)^(10) + (n_i) $ + + = Third Test + This is just going to create a bulleted list inside a numbered list: + + Numbers + + Number again + - Not a number. + "#; + + // Create and run the typst command. + let mut command = Command::new("typst"); + command + .arg("compile") + .arg("-") // Use stdin as input + .arg("-") // Write output to stdout + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + + let mut process = command.spawn()?; + + process.stdin.take().unwrap().write(template.as_bytes())?; + + let output = process.wait_with_output()?; + let pdf_bytes = output.stdout; + + Ok(pdf_bytes) } /// The main home page component. Contains a simple contact form. @@ -128,7 +168,6 @@ pub fn HomePage() -> impl IntoView { }, ); let submit_action = ServerAction::<CreateSampleSubmission>::new(); - let log_action = ServerAction::<LogExpandableInfo>::new(); view! { <AuthLoaded fallback=Loading> @@ -142,8 +181,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 +189,16 @@ pub fn HomePage() -> impl IntoView { if let Some(result) = submit_action.value().get() { match result { Ok(_) => "Request sent successfully.".to_owned(), - Err(e) => format!("Failed to send request: {:?}", e) + Err(e) => format!("Failed to send request: {:?}", e), } } else { "".to_owned() } } }); - view! { <Row> - <div class="flex flex-col flex-1"/> + <div class="flex flex-col flex-1" /> <Panel> <Row> <h1 class="text-3xl font-bold"> @@ -180,15 +216,15 @@ pub fn HomePage() -> impl IntoView { label="First Name:" placeholder="John" disabled=elements_disabled - data_member = "first_name" - data_map = expandable_react.data + data_member="first_name" + data_map=expandable_react.data /> <OutlinedTextField label="Last Name:" placeholder="Smith" disabled=elements_disabled - data_member = "last_name" - data_map = expandable_react.data + data_member="last_name" + data_map=expandable_react.data /> </Row> <Row> @@ -196,8 +232,8 @@ pub fn HomePage() -> impl IntoView { label="Contact Email:" placeholder="student@region15.org" disabled=elements_disabled - data_member = "contact_email" - data_map = expandable_react.data + data_member="contact_email" + data_map=expandable_react.data /> </Row> <Row> @@ -205,8 +241,8 @@ pub fn HomePage() -> impl IntoView { label="Phone Number:" placeholder="123-456-7890" disabled=elements_disabled - data_member = "phone_number" - data_map = expandable_react.data + data_member="phone_number" + data_map=expandable_react.data /> </Row> <Row> @@ -214,8 +250,8 @@ pub fn HomePage() -> impl IntoView { label="Street Address:" placeholder="123 Fake Street" disabled=elements_disabled - data_member = "address" - data_map = expandable_react.data + data_member="address" + data_map=expandable_react.data /> </Row> <Row> @@ -223,9 +259,9 @@ pub fn HomePage() -> impl IntoView { label="Highest Math SAT Score:" placeholder="600" disabled=elements_disabled - data_member = "math_sat" - data_map = expandable_react.data - input_type = "number" + data_member="math_sat" + data_map=expandable_react.data + input_type="number" /> </Row> <Row> @@ -267,59 +303,13 @@ pub fn HomePage() -> impl IntoView { data_map=expandable_react.data /> </Row> - <Row> - <ChipsList - data_member="athletic_requirements" - data_map=expandable_react.data - displayed_text=vec!( - "Football", "Soccer", "Cross Country", "Cheerleading", - "Swimming", "Wrestling", "Ski", "Basketball", - "Lacrosse", "Softball", "Indoor/Outdoor Track", - "Golf", "Tennis", "Volleyball" - ) - .into_iter().map(|s| s.to_owned()) - .collect() - values=vec!( - "football", "soccer", "cross_country", "cheerleading", - "swimming", "wrestling", "ski", "basketball", - "lacrosse", "softball", "track", - "golf", "tennis", "volleyball" - ) - .into_iter().map(|s| s.to_owned()) - .collect() - disabled=elements_disabled - label="Sports/Athletic Requirements" - /> - </Row> - <Row> - <ChipsList - data_member="community_involvement" - data_map=expandable_react.data - displayed_text=vec!( - "Lion's Club", "Knights of Columbus", - "Community Service > 20hrs" - ) - .into_iter().map(|s| s.to_owned()) - .collect() - values=vec!( - "lions_club", "koc", "serv_20h" - ) - .into_iter().map(|s| s.to_owned()) - .collect() - disabled=elements_disabled - label="Required Community Involvement" - /> - </Row> <Row> <MultiEntry - data_map = expandable_react.data - data_member = "test" - name_member = "first_name" - schema = vec![ - input!(Text, "first_name", "First Name:", "John"), - input!(Text, "last_name", "Last Name:", "Smith"), - input!(Select, "gender", "Gender:", ["Male", "Female"]), - input!(Checkbox, "candy", "Favorite Candy:", ["Twizzlers", "Starburst"]) + data_map=expandable_react.data + data_member="community_involvement" + schema=vec![ + input!(Text, "service_name", "Activity Name:", "Some service activity..."), + input!(Number, "service_hours", "Total Service Hours", "20") ] /> </Row> @@ -327,21 +317,49 @@ pub fn HomePage() -> impl IntoView { <ActionButton on:click=move |_| { let captured_map = expandable_react.capture(); - console_log(format!("Map values: {:?}", captured_map).as_str()); - submit_action.dispatch(CreateSampleSubmission { - student_info: captured_map, - }); + console_log( + format!("Map values: {:?}", captured_map).as_str(), + ); + submit_action + .dispatch(CreateSampleSubmission { + student_info: captured_map, + }); } disabled=elements_disabled - >"Submit"</ActionButton> + > + "Submit" + </ActionButton> </Row> <Row> - <p> - {result_msg} - </p> + <ActionButton on:click=move |_| { + console_log("Attempting to get PDF from server endpoint"); + spawn_local(async move { + let result = create_example_pdf().await; + if let Ok(bytes) = result { + let base64 = base64::engine::general_purpose::STANDARD + .encode(bytes); + let data_url = format!( + "data:application/pdf;base64,{}", + base64, + ); + console_log("Found document, opening in new tab..."); + let link = document() + .create_element("a") + .unwrap() + .dyn_into::<HtmlAnchorElement>() + .unwrap(); + link.set_href(&*data_url); + link.set_target("_blank"); + link.click(); + } + }); + }>"Get PDF"</ActionButton> + </Row> + <Row> + <p>{result_msg}</p> </Row> </Panel> - <div class="flex flex-col flex-1"/> + <div class="flex flex-col flex-1" /> </Row> } }) diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs new file mode 100644 index 0000000..b4acf04 --- /dev/null +++ b/src/pages/loaner_page.rs @@ -0,0 +1,455 @@ +#[cfg(feature = "ssr")] +use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; + +#[cfg(feature = "ssr")] +use serde_dynamo::{from_item, to_item}; + +use crate::common::{ExpandableInfo, ValueType}; +use crate::components::{ + ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select, +}; +use chrono::{FixedOffset, TimeZone}; +use leptos::Params; +use leptos::either::{Either, EitherOf3}; +use leptos::html::Dialog; +use leptos::logging::log; +use leptos::prelude::*; +use leptos_router::hooks::{use_navigate, use_params}; +use leptos_router::params::Params; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use traits::{AsReactive, ReactiveCapture}; + +#[derive(Params, PartialEq)] +struct LoanerParams { + form_name: Option<String>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct LoanerReturnOutput { + /// The subject ID of the student entry. + subject: String, + /// The first name of the student that is borrowing. + first_name: String, + /// The last name of the student that is borrowing. + last_name: String, + /// The date that the student borrowed the loaner. + date: String, +} + +#[server] +async fn get_loaners_return_list() -> Result<Vec<LoanerReturnOutput>, ServerFnError> { + // We want to query the database for all loaners, but we only want the name and + // date of borrowing. This will be cheaper than querying the entire table. + let dbclient = + Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); + + log!("Getting loaners from database."); + + match dbclient + .scan() + .table_name("loaner-info") + .projection_expression("subject, first_name, last_name, date_taken") + .send() + .await + { + Ok(output) => { + if let Some(items) = output.items { + Ok(items + .into_iter() + .map(|item| { + let expandable: ExpandableInfo = from_item(item).unwrap(); + // This set of function calls is ridiculous, we need to make a better way to do this. + LoanerReturnOutput { + first_name: expandable + .data + .get("first_name") + .unwrap() + .as_string() + .unwrap_or_default() + .unwrap_or_default(), + last_name: expandable + .data + .get("last_name") + .unwrap() + .as_string() + .unwrap_or_default() + .unwrap_or_default(), + date: expandable + .data + .get("date_taken") + .unwrap() + .as_string() + .unwrap_or_default() + .unwrap_or_default(), + subject: expandable.subject, + } + }) + .collect()) + } else { + Ok(vec![]) + } + } + Err(err) => { + log!("{:?}", err); + let msg = err.message().unwrap_or("Unknown error"); + Err(ServerFnError::new(msg)) + } + } +} + +#[server] +async fn check_in_loaner(student: LoanerReturnOutput) -> Result<(), ServerFnError> { + let dbclient = + Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); + + match dbclient + .delete_item() + .table_name("loaner-info") + .key("subject", AttributeValue::S(student.subject)) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) => { + log!("{:?}", err); + let msg = err.message().unwrap_or("Unknown error"); + Err(ServerFnError::new(msg)) + } + } +} + +#[server] +async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> { + let mut input = input; + let dbclient = + Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); + + let current_time = chrono::Utc::now().format("%H:%M, %m/%d/%Y").to_string(); + log!("Creating entry with timestamp {}", current_time); + + // Add the timestamp to the data map as a string and then push it to the database. + input.data.insert( + "date_taken".to_string(), + ValueType::String(Some(current_time.clone())), + ); + + loop { + let item: serde_dynamo::Item = to_item(&input)?; + match dbclient + .put_item() + .table_name("loaner-info") + .set_item(Some(HashMap::from(item.clone()))) + .condition_expression("attribute_not_exists(subject)") + .send() + .await + { + Ok(_) => return Ok(()), + Err(err) => { + log!("{:?}", err); + match err.code() { + Some("ConditionalCheckFailedException") => { + // Retry database writes until we have a good subject ID. + log!("Failed conditional check, forcing new subject ID."); + input.subject = uuid::Uuid::new_v4().to_string(); + continue; + } + _ => { + let msg = err.message().unwrap_or("An unknown error occurred."); + return Err(ServerFnError::new(msg)); + } + }; + } + } + } +} + +/// # Loaner Page +/// +/// This page is going to temporarily piggyback on the general scholarship app, at least until +/// we spin off the components used here into a new standalone crate. +/// +/// The loaner app handles management of Chromebook loaners to students. It was originally hosted +/// using Google Apps Script, but has recently been broken by some unknown API error. I've chosen +/// to rewrite this into a single new application, which we will host in AWS with a real database. +/// +/// It should use all the same components as the scholarship loaner, maybe with a few extra +/// alterations. The page will only use a single database table, and it will only have a few very +/// basic APIs. +/// +/// # Design +/// +/// The page should have two sections - one for logging a loaner that's being taken out (borrowing) +/// and another for a loaner that's being brought back (returning). As of right now, we have two +/// separate buttons that handle these functions, and they slide open a panel at the bottom of the +/// screen. The borrowing menu has a single, simple form, and the returning side has a list of +/// buttons that allow students to choose their name and remove it from the list. +/// +/// # New features +/// +/// I believe that with a new workflow and an overhauled server with real data management, we should +/// create a true admin page for this, simply to be able to survey and override/remove information +/// from the lists. +#[component] +pub fn LoanerPage() -> impl IntoView { + // Check URL params. Depending on what string is visible here, we'll display a different form. + let params = use_params::<LoanerParams>(); + let form_passed = move || { + params + .read() + .as_ref() + .ok() + .and_then(|params| params.form_name.clone()) + .unwrap_or_default() + }; + + let form_view = move || match form_passed().as_str() { + "borrowing" => EitherOf3::A(LoanerBorrowForm), + "returning" => EitherOf3::B(LoanerReturnForm), + _ => EitherOf3::C("Choose something from the left.".into_view()), + }; + + // We'll create the page here - the sidebar contains all the buttons, while the form + // container either shows forms or shows placeholder text. + view! { + <Banner + title="Chromebook Loaners" + logo="/PHS_Stacked_Acronym.png" + path="/loaners" + /> + <div class="flex m-5"> + <div class="flex flex-1 flex-row gap-3"> + <div id="sidebar" class="flex flex-1 flex-col gap-2"> + <DashboardButton + title="Borrowing" + description="Select to take out a loaner." + icon="/Edit.png" + path="/loaners/borrowing" + /> + <DashboardButton + title="Returning" + description="Select to return a loaner." + icon="/Person_Black.png" + path="/loaners/returning" + /> + </div> + <div class="flex flex-2"> + <Panel>{form_view}</Panel> + </div> + </div> + </div> + } +} + +#[component] +pub fn LoanerBorrowForm() -> impl IntoView { + // Register server actions. + let create_entry_action = ServerAction::<CreateBorrowEntry>::new(); + let navigate = use_navigate(); + + let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string()); + let temp_reactive = temp.as_reactive(); + let elements_disabled = RwSignal::new(false); + + view! { + <div class="flex flex-col justify-center gap-2 mb-1 py-3 px-2"> + <h2 class="text-2xl font-bold">"Borrower Information"</h2> + <p class="text-lg">"Please fill out the form below."</p> + </div> + <Row> + <OutlinedTextField + label="First Name:" + placeholder="John" + disabled=elements_disabled + data_member="first_name" + data_map=temp_reactive.data + /> + <OutlinedTextField + label="Last Name:" + placeholder="Smith" + disabled=elements_disabled + data_member="last_name" + data_map=temp_reactive.data + /> + </Row> + <Row> + <OutlinedTextField + label="Region 15 Email:" + placeholder="example@region15.org" + disabled=elements_disabled + data_member="email" + data_map=temp_reactive.data + /> + </Row> + <Row> + <Select + label="Collateral:" + value_list=vec![ + "Phone".to_string(), + "Earbuds/Headphones".to_string(), + "Keys".to_string(), + "Wallet".to_string(), + "Laptop".to_string(), + ] + disabled=elements_disabled + data_member="collateral" + data_map=temp_reactive.data + /> + </Row> + <Row> + <Select + label="Loan Taken:" + value_list=vec![ + "Chromebook".to_string(), + "Charger".to_string(), + "Headphones".to_string(), + ] + disabled=elements_disabled + data_member="loan" + data_map=temp_reactive.data + /> + </Row> + <Row> + <ActionButton + on:click=move |_| { + let captured = temp_reactive.capture(); + log!("Loaner record taken."); + log!("{:?}", captured); + create_entry_action.dispatch(CreateBorrowEntry { + input: captured + }); + navigate("/loaners", Default::default()); + } + >"Submit"</ActionButton> + </Row> + } +} + +#[component] +pub fn LoanerReturnForm() -> impl IntoView { + // Register server actions + let check_in_action = ServerAction::<CheckInLoaner>::new(); + + // Register server resources + let return_list_refresh = RwSignal::new(0); + let return_list_resource: Resource<Vec<LoanerReturnOutput>> = Resource::new( + move || return_list_refresh.get(), + async |_| { + get_loaners_return_list().await.unwrap_or_else(|e| { + log!("There was an error getting the loaner list: {}", e); + vec![] + }) + }, + ); + + let dialog_ref = NodeRef::<Dialog>::new(); + let chosen_student = RwSignal::new(None::<LoanerReturnOutput>); + let dialog_disabled = RwSignal::new(false); + + // Create an effect to run after check-in - refreshes the list with another scan request. + // This could be beneficial if we ever decided to make multiple loaner kiosks (think SAT days) + Effect::new(move || { + if let Some(Ok(_)) = check_in_action.value().get() { + // Check the local loaner list and remove the indicated student from it. + return_list_refresh.update(|n| *n += 1); + if let Some(dialog) = dialog_ref.get() { + dialog.close(); + } + } + }); + + // We can't use a Memo in our custom components for the disabled signal. + Effect::new(move || { + if check_in_action.pending().get() { + dialog_disabled.set(true); + } else { + dialog_disabled.set(false); + } + }); + + // Handles checking in a student. + let dialog_check_in = move |_| { + chosen_student.with(|opt_student| { + if let Some(student) = opt_student.as_ref() { + check_in_action.dispatch(CheckInLoaner { + student: student.clone(), + }); + } + }) + }; + + // Handles closing the dialog menu. + let close_dialog = move |_| { + if let Some(dialog) = dialog_ref.get() { + dialog.close(); + } + }; + + view! { + <dialog + class="p-3 m-auto rounded-md shadow-lg/33 backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter" + node_ref=dialog_ref> + <h2 class="text-2xl font-bold">"Confirmation"</h2> + <h4 class="text-xl mb-3"> + {move || chosen_student.with(|opt_student| { + if let Some(student) = opt_student.as_ref() { + format!("Are you sure want to check in {} {}?", student.first_name, student.last_name) + } else { + "There is no student selected.".to_string() + } + })} + </h4> + <ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton> + <ActionButton on:click=dialog_check_in disabled=dialog_disabled>"Check In"</ActionButton> + </dialog> + <div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2"> + <h2 class="text-2xl font-bold">"Student List"</h2> + <p class="text-lg">"Please select your name from the list."</p> + </div> + <Suspense fallback=move || "Loading...".into_view()> + <div class="flex flex-col gap-2 m-2"> + {move || { + match return_list_resource.get() { + Some(loaner_list) => { + let list_view = loaner_list.iter().map(|student| { + let on_click = { + let student_click = student.clone(); + move |_| { + log!("Running on_click using student {:?}", student_click); + if let Some(dialog) = dialog_ref.get() { + chosen_student.set(Some(student_click.clone())); + dialog.show_modal().expect("Couldn't show modal dialog."); + } + } + }; + + let naive_time = chrono::NaiveDateTime::parse_from_str(&student.date, "%H:%M, %m/%d/%Y") + .unwrap(); + let time_zone = FixedOffset::west_opt(5 * 3600).unwrap(); + let student_date = time_zone.from_utc_datetime(&naive_time) + .format("%l:%M %p, %m/%d/%Y") + .to_string(); + + log!("Found current date {:?}", student_date); + + view! { + <div class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33" on:click=on_click> + <div class="flex-1"> + {student.first_name.clone()}" "{student.last_name.clone()} + </div> + <div class="flex-none"> + {student_date.clone()} + </div> + </div> + } + }).collect_view(); + + Either::Left(list_view) + }, + None => Either::Right("No loaners found.".into_view()) + } + }} + </div> + </Suspense> + } +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 87d6b16..cd9f5f5 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,6 +1,15 @@ mod about_page; +mod api; +mod comparison_test; mod home_page; +mod loaner_page; +mod provider_portal; +mod scholarship_info; mod test_page; mod unauthenticated_page; +mod utils; -pub use self::{about_page::*, home_page::*, test_page::*, unauthenticated_page::*}; +pub use self::{ + about_page::*, comparison_test::*, home_page::*, loaner_page::LoanerPage, provider_portal::*, + test_page::*, unauthenticated_page::*, +}; diff --git a/src/pages/provider_portal.rs b/src/pages/provider_portal.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/pages/provider_portal.rs @@ -0,0 +1 @@ + diff --git a/src/pages/scholarship_info.rs b/src/pages/scholarship_info.rs new file mode 100644 index 0000000..420dd9f --- /dev/null +++ b/src/pages/scholarship_info.rs @@ -0,0 +1,261 @@ +use super::UnauthenticatedPage; +use super::api::{ + CreateScholarshipInfo, CreateTestComparisons, get_comparison_info, get_scholarship_info, +}; +use crate::common::ComparisonData; +use crate::common::{ExpandableInfo, ScholarshipFormParams}; +use crate::components::{ + ActionButton, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row, +}; +use leptos::logging::log; +use leptos::prelude::*; +use leptos_oidc::{AuthLoaded, Authenticated}; +use leptos_router::hooks::use_params; +use traits::{AsReactive, ReactiveCapture}; + +/// # Scholarship Info Page +/// This page will handle creating or editing scholarship information given a specific +/// scholarship ID and scholarship provider subject number. The form itself will only +/// track a single scholarship (for now) and so selection of what scholarship will be edited +/// must be handled *before* navigation to this page. +/// +/// In its current testing form, we'll be using a single fixed ID. This absolutely *MUST* be +/// changed before a full release. +#[component] +pub fn ScholarshipInfoPage() -> impl IntoView { + // let scholarship_id = String::from("test-scholarship-id2"); + let url_params = use_params::<ScholarshipFormParams>(); + let scholarship_id = move || { + url_params + .read() + .as_ref() + .ok() + .and_then(|params| params.id.clone()) + .unwrap_or_default() + }; + + let get_scholarship_action: Resource<ExpandableInfo> = Resource::new( + move || scholarship_id().clone(), + async |id| { + get_scholarship_info(id.clone()) + .await + // Note that this really should notify us of an error and then redirect or ask + // the user to try again. + .unwrap_or_else(|e| { + log!("Failed to get scholarship info: {:?}", e); + log!("Using default ExpandableInfo"); + ExpandableInfo::new(id) + }) + }, + ); + + let get_comparison_info: Resource<Vec<ComparisonData>> = Resource::new( + move || Option::<String>::None, // There's no input to this function. + async |_| { + get_comparison_info().await.unwrap_or_else(|e| { + log!("Failed to get comparison info: {:?}", e); + Vec::new() + }) + }, + ); + + let create_scholarship_action = ServerAction::<CreateScholarshipInfo>::new(); + let create_test_comps = ServerAction::<CreateTestComparisons>::new(); + + // Remember that we only want to show the page after we've got authentication information + // and if there's data for us to use. + view! { + <AuthLoaded fallback=Loading> + <Authenticated unauthenticated=UnauthenticatedPage> + <Suspense fallback=Loading> + <Row> + <div class="flex flex-col flex-1" /> + {move || Suspend::new(async move { + let response = get_scholarship_action.get(); + let comparisons = get_comparison_info.get(); + + response + .zip(comparisons) + .map(|(response, comparison_list)| view! { + <ScholarshipForm + response=response + comparison_list=comparison_list + submit_action=create_scholarship_action + /> + }) + .collect_view() + })} + <div class="hidden"> + <Row> + <ActionButton + on:click=move |_| { + create_test_comps.dispatch(CreateTestComparisons {}); + } + >"Create test comparisons"</ActionButton> + </Row> + </div> + <div class="flex flex-col flex-1" /> + </Row> + </Suspense> + </Authenticated> + </AuthLoaded> + } +} + +#[component] +fn ScholarshipForm( + response: ExpandableInfo, + comparison_list: Vec<ComparisonData>, + submit_action: ServerAction<CreateScholarshipInfo>, +) -> impl IntoView { + let reactive_info = response.as_reactive(); + let elements_disabled = RwSignal::new(false); + let result_msg = Signal::derive(move || { + if submit_action.pending().get() { + elements_disabled.set(true); + "Sending request...".to_string() + } else { + elements_disabled.set(false); + if let Some(result) = submit_action.value().get() { + match result { + Ok(()) => "Request sent successfully".to_string(), + Err(err) => { + format!("An error occurred: {:?}", err) + } + } + } else { + "".to_string() + } + } + }); + + // We'll collect the scholarship's name, num_awards, amount_per_award, total_awards, + // fafsa_required, award_to, transcript_required, recipient_selection, essay_requirement (and prompt) + // award_night_remarks + + view! { + <Panel> + <Row> + <h1 class="text-3xl font-bold"> + "Scholarship Info Form (test)" + </h1> + </Row> + <Row> + <OutlinedTextField + label="Scholarship Name" + placeholder="Example Scholarship Name" + disabled=elements_disabled + data_member="name" + data_map=reactive_info.data + /> + </Row> + <Row> + <OutlinedTextField + label="Amount per award: (if multiple, enter the highest value)" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="amount_per_award" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <OutlinedTextField + label="Total number of awards:" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="num_awards" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <OutlinedTextField + label="Total amount of all awards:" + placeholder="Enter an amount..." + disabled=elements_disabled + data_member="total_awards" + data_map=reactive_info.data + input_type="number" + /> + </Row> + <Row> + <RadioList + data_member="fafsa_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require student financial information?" + /> + </Row> + <Row> + <RadioList + data_member="transcript_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require a student transcript?" + /> + </Row> + <Row> + <RadioList + data_member="award_to" + data_map=reactive_info.data + items=vec!["School".to_string(), "Student".to_string()] + disabled=elements_disabled + label="Will the award be made to the school or the student?" + /> + </Row> + <Row> + <RadioList + data_member="essay_required" + data_map=reactive_info.data + items=vec!["Yes".to_string(), "No".to_string()] + disabled=elements_disabled + label="Do you require a student essay?" + /> + </Row> + <Row> + <RadioList + data_member="essay_prompt" + data_map=reactive_info.data + items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect() + disabled=elements_disabled + label="If so, select a prompt from the list below." + /> + </Row> + <Row> + <ChipsList + label="Scholarship Requirements" + data_member="requirements" + data_map=reactive_info.data + values=comparison_list + .iter() + .cloned() + .map(|comp| comp.id) + .collect() + displayed_text=comparison_list + .iter() + .cloned() + .map(|comp| comp.display_text) + .collect() + /> + </Row> + <Row> + <ActionButton + disabled=elements_disabled + on:click=move |_| { + let captured = reactive_info.capture(); + log!("Map values: {:?}", captured); + submit_action.dispatch(CreateScholarshipInfo { + info: captured + }); + } + >"Submit"</ActionButton> + </Row> + <Row> + <p>{result_msg}</p> + </Row> + </Panel> + } +} diff --git a/src/pages/unauthenticated_page.rs b/src/pages/unauthenticated_page.rs index b590de9..7de72e3 100644 --- a/src/pages/unauthenticated_page.rs +++ b/src/pages/unauthenticated_page.rs @@ -1,4 +1,5 @@ use crate::components::ActionButton; +use leptos::logging::log; use leptos::prelude::*; use leptos_oidc::AuthSignal; @@ -12,6 +13,8 @@ pub fn UnauthenticatedPage() -> impl IntoView { }) }; + Effect::new(move || log!("Generated login url: {:?}", login_url())); + let navigate = leptos_router::hooks::use_navigate(); view! ( diff --git a/src/pages/utils.rs b/src/pages/utils.rs new file mode 100644 index 0000000..ac3f5a1 --- /dev/null +++ b/src/pages/utils.rs @@ -0,0 +1,19 @@ +/// # Server Utilities +/// +/// These utility functions can only be used from the server. They are created to use server-side +/// dependencies like the AWS SDK. Use of these functions will crash the front end, as these +/// dependencies cannot be compiled for WASM (nor should they be). +#[cfg(feature = "ssr")] +pub mod server_utils { + use aws_config::SdkConfig; + + /// Creates an [`SdkConfig`] struct for use with AWS SDK structs. + pub async fn create_aws_config() -> SdkConfig { + aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await + } + + /// Creates a DynamoDB client. + pub async fn create_dynamo_client() -> aws_sdk_dynamodb::Client { + aws_sdk_dynamodb::Client::new(&create_aws_config().await) + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..c99d1ab --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,23 @@ +use crate::common::{ExpandableInfo, ValueType}; +use std::ops::ControlFlow; + +pub fn get_number_list(student_info: &ExpandableInfo, member: &String) -> Result<Vec<f64>, String> { + match student_info.data.get(member) { + Some(ValueType::List(Some(list))) => { + let temp = list.iter().try_fold(vec![], |mut list_acc, item| { + if let ValueType::Number(Some(number)) = item { + list_acc.push(number.parse::<f64>().unwrap()); + ControlFlow::Continue(list_acc) + } else { + ControlFlow::Break(list_acc) + } + }); + + match temp { + ControlFlow::Continue(list) => Ok(list), + ControlFlow::Break(_) => return Err("List contains non-number values".to_owned()), + } + } + _ => return Err("Couldn't find the list".to_owned()), + } +}