Provider Dashboard #34
No files matched your search
Generated
+1
-2
@@ -2938,6 +2938,7 @@ dependencies = [
|
|||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-dynamodb",
|
"aws-sdk-dynamodb",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"leptos",
|
"leptos",
|
||||||
@@ -2946,12 +2947,10 @@ dependencies = [
|
|||||||
"leptos_oidc",
|
"leptos_oidc",
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
"macros",
|
"macros",
|
||||||
"reactive_stores",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_dynamo",
|
"serde_dynamo",
|
||||||
"tokio",
|
"tokio",
|
||||||
"traits",
|
"traits",
|
||||||
"url",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|||||||
+2
-3
@@ -21,12 +21,11 @@ serde = "1.0.219"
|
|||||||
serde_dynamo = { version = "4.2.14", features = ["aws-sdk-dynamodb+1"], optional = true }
|
serde_dynamo = { version = "4.2.14", features = ["aws-sdk-dynamodb+1"], optional = true }
|
||||||
uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
|
uuid = { version = "1.17.0", features = ["serde", "v4", "js"] }
|
||||||
leptos_oidc = "0.9.0"
|
leptos_oidc = "0.9.0"
|
||||||
url = "2.5.4"
|
base64 = "0.22.1"
|
||||||
|
chrono = "0.4.42"
|
||||||
|
|
||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
traits = { path = "./traits" }
|
traits = { path = "./traits" }
|
||||||
chrono = { version = "0.4.42", features = ["serde"] }
|
|
||||||
reactive_stores = "0.2.5"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
hydrate = [
|
hydrate = [
|
||||||
|
|||||||
+6
-3
@@ -1,7 +1,7 @@
|
|||||||
FROM rust:1.86 AS builder
|
FROM rust:1.90 AS builder
|
||||||
|
|
||||||
# Install node for tailwind capability
|
# 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 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 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
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
# Setup fnm and build with leptos
|
# 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 \
|
RUN cargo binstall fnm -y \
|
||||||
&& fnm --version \
|
&& fnm --version \
|
||||||
&& eval "$(fnm env)" \
|
&& 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
|
# Start second phase of build - install ca-certs and copy all build outputs from previous step into a
|
||||||
# Debian image in the /app directory.
|
# Debian image in the /app directory.
|
||||||
FROM debian:bookworm-slim AS runtime
|
FROM debian:trixie-slim AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update -y \
|
RUN apt-get update -y \
|
||||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
&& 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 RUST_LOG="info"
|
||||||
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
|
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
|
||||||
ENV LEPTOS_SITE_ROOT="site"
|
ENV LEPTOS_SITE_ROOT="site"
|
||||||
|
# Staging URL for server.
|
||||||
|
ENV LP_SITE_ORIGIN="http://testing-url.com"
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Don't forget about project_name
|
# Don't forget about project_name
|
||||||
|
|||||||
+5
-8
@@ -1,9 +1,6 @@
|
|||||||
|
services:
|
||||||
services:
|
scholarships-rs:
|
||||||
leptos-watch:
|
build: .
|
||||||
image: rust:1.86
|
image: scholarships-rs-runner:latest
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '8080:8080'
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
command: cargo leptos watch
|
|
||||||
Binary file not shown.
@@ -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
|
||||||
+56
-14
@@ -1,13 +1,12 @@
|
|||||||
|
use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, ProviderPortal, TestPage};
|
||||||
|
use leptos::leptos_dom::log;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
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::{
|
use leptos_router::{
|
||||||
StaticSegment,
|
|
||||||
components::{Route, Router, Routes},
|
components::{Route, Router, Routes},
|
||||||
|
path,
|
||||||
};
|
};
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::pages::{AboutPage, HomePage, ProviderPortal, TestPage, UnauthenticatedPage};
|
|
||||||
|
|
||||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
@@ -32,12 +31,55 @@ pub fn App() -> impl IntoView {
|
|||||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||||
provide_meta_context();
|
provide_meta_context();
|
||||||
|
|
||||||
|
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"/>
|
||||||
|
|
||||||
|
// sets the document title
|
||||||
|
<Title text="R15 Scholarship App DEV"/>
|
||||||
|
|
||||||
|
// 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
|
// Authentication setup
|
||||||
let parameters = AuthParameters {
|
let parameters = AuthParameters {
|
||||||
issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM".into(),
|
issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM".into(),
|
||||||
client_id: "10jr2h3vtpu9n7gj46pvg5qo2q".into(),
|
client_id: "10jr2h3vtpu9n7gj46pvg5qo2q".into(),
|
||||||
redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(),
|
redirect_uri: format!("{}/", use_origin()),
|
||||||
post_logout_redirect_uri: Url::parse("http://localhost:3000").unwrap().to_string(),
|
// 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()),
|
scope: Some("openid%20profile%20email".into()),
|
||||||
audience: None,
|
audience: None,
|
||||||
challenge: Challenge::None,
|
challenge: Challenge::None,
|
||||||
@@ -60,18 +102,18 @@ pub fn App() -> impl IntoView {
|
|||||||
// sets the document title
|
// sets the document title
|
||||||
<Title text="R15 Scholarship App DEV" />
|
<Title text="R15 Scholarship App DEV" />
|
||||||
|
|
||||||
// content for this welcome page
|
|
||||||
<Router>
|
|
||||||
<main>
|
<main>
|
||||||
// TODO Create a 404 page
|
// TODO Create a 404 page
|
||||||
<Routes fallback=|| "Page not found.".into_view()>
|
<Routes fallback=|| "Page not found.".into_view()>
|
||||||
<Route path=StaticSegment("") view=HomePage/>
|
<Route path=path!("") view=HomePage/>
|
||||||
<Route path=StaticSegment("about") view=AboutPage/>
|
<Route path=path!("about") view=AboutPage/>
|
||||||
<Route path=StaticSegment("test_page") view=TestPage/>
|
<Route path=path!("test_page") view=TestPage/>
|
||||||
<Route path=StaticSegment("providers") view=ProviderPortal/>
|
<Route path=path!("comparison") view=ComparisonTestPage />
|
||||||
|
<Route path=path!("providers") view=ProviderPortal />
|
||||||
|
<Route path=path!("loaners") view=LoanerPage />
|
||||||
|
<Route path=path!("loaners/:form_name") view=LoanerPage />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</Router>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use crate::common::ValueType;
|
||||||
|
|
||||||
|
pub trait Comparison {
|
||||||
|
fn evaluate(&self, comp_value: &ValueType, target_value: &ValueType) -> Result<bool, String>;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -25,9 +25,9 @@ pub struct ExpandableInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ExpandableInfo {
|
impl ExpandableInfo {
|
||||||
pub fn new(key: String) -> Self {
|
pub fn new(key: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
subject: key,
|
subject: key.into(),
|
||||||
data: HashMap::new(),
|
data: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use leptos::Params;
|
||||||
|
use leptos_router::params::Params;
|
||||||
|
|
||||||
|
#[derive(Params, PartialEq, Debug)]
|
||||||
|
pub struct ScholarshipFormParams {
|
||||||
|
pub id: Option<String>,
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ impl InputType {
|
|||||||
placeholder = placeholder.clone()
|
placeholder = placeholder.clone()
|
||||||
data_member = member.clone()
|
data_member = member.clone()
|
||||||
data_map = data_map
|
data_map = data_map
|
||||||
|
input_type = "number"
|
||||||
/>
|
/>
|
||||||
}),
|
}),
|
||||||
InputType::Checkbox(member, label, options) =>
|
InputType::Checkbox(member, label, options) =>
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,10 @@
|
|||||||
|
mod comparison;
|
||||||
mod expandable_info;
|
mod expandable_info;
|
||||||
|
mod form_params;
|
||||||
mod form_schema;
|
mod form_schema;
|
||||||
mod user;
|
mod user;
|
||||||
pub mod value_type;
|
mod value_type;
|
||||||
|
|
||||||
pub use self::{expandable_info::*, form_schema::*, user::*, value_type::*};
|
pub use self::{
|
||||||
|
comparison::*, expandable_info::*, form_params::*, form_schema::*, user::*, value_type::*,
|
||||||
|
};
|
||||||
@@ -73,7 +73,9 @@ pub fn MultiEntry(
|
|||||||
/// component's `data_member` input.
|
/// component's `data_member` input.
|
||||||
#[prop()]
|
#[prop()]
|
||||||
data_map: RwSignal<HashMap<String, ValueType>>,
|
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.
|
/// The type and order of the inputs for each `Entry` component within this `MultiEntry` component.
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
schema: Vec<InputType>,
|
schema: Vec<InputType>,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ pub fn Panel(
|
|||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div
|
<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
|
border-1 border-gray-600/10
|
||||||
rounded-md shadow-[0_0_6px_rgba(0,0,0,0.50)]"
|
rounded-md shadow-[0_0_6px_rgba(0,0,0,0.50)]"
|
||||||
class=(["hidden"], hidden) >
|
class=(["hidden"], hidden) >
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod app;
|
|||||||
mod common;
|
mod common;
|
||||||
mod components;
|
mod components;
|
||||||
mod pages;
|
mod pages;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
mod comparisons;
|
||||||
|
mod scholarships;
|
||||||
|
|
||||||
|
pub use self::{comparisons::*, scholarships::*};
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
-70
@@ -4,18 +4,25 @@ use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValu
|
|||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use serde_dynamo::{from_item, to_item};
|
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::common::{ExpandableInfo, UserClaims};
|
||||||
use crate::pages::UnauthenticatedPage;
|
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
|
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
|
||||||
Select, ChipsList
|
Select,
|
||||||
};
|
};
|
||||||
use crate::input;
|
use crate::input;
|
||||||
|
use crate::pages::UnauthenticatedPage;
|
||||||
|
use base64::Engine;
|
||||||
use leptos::leptos_dom::logging::console_log;
|
use leptos::leptos_dom::logging::console_log;
|
||||||
use leptos::prelude::*;
|
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 leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
|
||||||
use std::collections::HashMap;
|
|
||||||
use traits::{AsReactive, ReactiveCapture};
|
use traits::{AsReactive, ReactiveCapture};
|
||||||
|
|
||||||
/// # Get Student Info
|
/// # Get Student Info
|
||||||
@@ -90,14 +97,47 @@ pub async fn create_sample_submission(student_info: ExpandableInfo) -> Result<()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(LogExpandableInfo, endpoint = "/log-expandable")]
|
#[server(CreateExamplePdf, endpoint = "/example-pdf")]
|
||||||
pub async fn log_expandable(info: ExpandableInfo) -> Result<(), ServerFnError> {
|
pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> {
|
||||||
console_log(format!("Was given info: {:?}", info).as_str());
|
// Create typst template. This will be replaced with getting the template from S3 in the future.
|
||||||
// Manually specify the type here since we're not able to infer the type later.
|
// The path I'm thinking, for now, is that we generate a series of "members" from the available
|
||||||
let item: HashMap<String, AttributeValue> = to_item(info)?;
|
// keys that a student contains, prefixed with "student_". This means that to get the member
|
||||||
console_log(format!("Converted into a DynamoDB item: {:?}", item).as_str());
|
// "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.
|
/// 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 submit_action = ServerAction::<CreateSampleSubmission>::new();
|
||||||
let log_action = ServerAction::<LogExpandableInfo>::new();
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<AuthLoaded fallback=Loading>
|
<AuthLoaded fallback=Loading>
|
||||||
@@ -142,8 +181,6 @@ pub fn HomePage() -> impl IntoView {
|
|||||||
let expandable_react = submission.as_reactive();
|
let expandable_react = submission.as_reactive();
|
||||||
let elements_disabled = RwSignal::new(false);
|
let elements_disabled = RwSignal::new(false);
|
||||||
let result_msg = Signal::derive(move || {
|
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() {
|
if submit_action.pending().get() {
|
||||||
elements_disabled.set(true);
|
elements_disabled.set(true);
|
||||||
"Sending request...".to_owned()
|
"Sending request...".to_owned()
|
||||||
@@ -152,14 +189,13 @@ pub fn HomePage() -> impl IntoView {
|
|||||||
if let Some(result) = submit_action.value().get() {
|
if let Some(result) = submit_action.value().get() {
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => "Request sent successfully.".to_owned(),
|
Ok(_) => "Request sent successfully.".to_owned(),
|
||||||
Err(e) => format!("Failed to send request: {:?}", e)
|
Err(e) => format!("Failed to send request: {:?}", e),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
"".to_owned()
|
"".to_owned()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Row>
|
<Row>
|
||||||
<div class="flex flex-col flex-1" />
|
<div class="flex flex-col flex-1" />
|
||||||
@@ -267,59 +303,13 @@ pub fn HomePage() -> impl IntoView {
|
|||||||
data_map=expandable_react.data
|
data_map=expandable_react.data
|
||||||
/>
|
/>
|
||||||
</Row>
|
</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>
|
<Row>
|
||||||
<MultiEntry
|
<MultiEntry
|
||||||
data_map=expandable_react.data
|
data_map=expandable_react.data
|
||||||
data_member = "test"
|
data_member="community_involvement"
|
||||||
name_member = "first_name"
|
|
||||||
schema=vec![
|
schema=vec![
|
||||||
input!(Text, "first_name", "First Name:", "John"),
|
input!(Text, "service_name", "Activity Name:", "Some service activity..."),
|
||||||
input!(Text, "last_name", "Last Name:", "Smith"),
|
input!(Number, "service_hours", "Total Service Hours", "20")
|
||||||
input!(Select, "gender", "Gender:", ["Male", "Female"]),
|
|
||||||
input!(Checkbox, "candy", "Favorite Candy:", ["Twizzlers", "Starburst"])
|
|
||||||
]
|
]
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -327,18 +317,46 @@ pub fn HomePage() -> impl IntoView {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
let captured_map = expandable_react.capture();
|
let captured_map = expandable_react.capture();
|
||||||
console_log(format!("Map values: {:?}", captured_map).as_str());
|
console_log(
|
||||||
submit_action.dispatch(CreateSampleSubmission {
|
format!("Map values: {:?}", captured_map).as_str(),
|
||||||
|
);
|
||||||
|
submit_action
|
||||||
|
.dispatch(CreateSampleSubmission {
|
||||||
student_info: captured_map,
|
student_info: captured_map,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
disabled=elements_disabled
|
disabled=elements_disabled
|
||||||
>"Submit"</ActionButton>
|
>
|
||||||
|
"Submit"
|
||||||
|
</ActionButton>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<p>
|
<ActionButton on:click=move |_| {
|
||||||
{result_msg}
|
console_log("Attempting to get PDF from server endpoint");
|
||||||
</p>
|
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>
|
</Row>
|
||||||
</Panel>
|
</Panel>
|
||||||
<div class="flex flex-col flex-1" />
|
<div class="flex flex-col flex-1" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -1,9 +1,15 @@
|
|||||||
mod about_page;
|
mod about_page;
|
||||||
|
mod api;
|
||||||
|
mod comparison_test;
|
||||||
mod home_page;
|
mod home_page;
|
||||||
|
mod loaner_page;
|
||||||
mod provider_portal;
|
mod provider_portal;
|
||||||
|
mod scholarship_info;
|
||||||
mod test_page;
|
mod test_page;
|
||||||
mod unauthenticated_page;
|
mod unauthenticated_page;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
pub use self::{
|
pub use self::{
|
||||||
about_page::*, home_page::*, provider_portal::*, test_page::*, unauthenticated_page::*,
|
about_page::*, comparison_test::*, home_page::*, loaner_page::LoanerPage, provider_portal::*,
|
||||||
|
test_page::*, unauthenticated_page::*,
|
||||||
};
|
};
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::components::ActionButton;
|
use crate::components::ActionButton;
|
||||||
|
use leptos::logging::log;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_oidc::AuthSignal;
|
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();
|
let navigate = leptos_router::hooks::use_navigate();
|
||||||
|
|
||||||
view! (
|
view! (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user
This should be changed to the route
/providers. It will be used as a subroute in future updates (i.e./providers/scholarshipsto edit scholarships)