diff --git a/src/app.rs b/src/app.rs index 15af1a2..bc50b33 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ -use crate::pages::{ProviderPortal, ScholarshipInfoPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm, AuthCallbackPage, ApplicantsPageFallback}; +use crate::pages::{ProviderPortal, ScholarshipInfoPage, ProviderContactPage, LoanerShell, LoanerFallback, LoanerBorrowForm, LoanerReturnForm, AuthCallbackPage, ApplicantsPageFallback, AdminShell, AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminUtilsPage}; +use crate::pages::student; use leptos::prelude::*; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context}; use leptos_oidc::AuthSignal; @@ -61,8 +62,10 @@ pub fn AppWithRoutes() -> impl IntoView { // TODO Create a 404 page + // + @@ -100,6 +103,53 @@ fn LoanerRoutes() -> impl MatchNestedRoutes + Clone { .into_any_nested_route() } +#[component(transparent)] +fn AdminRoutes() -> impl MatchNestedRoutes + Clone { + view! { + + + + + + + } + .into_inner() + .into_any_nested_route() +} + +#[component(transparent)] +fn StudentRoutes() -> impl MatchNestedRoutes + Clone { + view! { + + + + + + + + + + + + + + } + .into_inner() + .into_any_nested_route() +} + #[component] pub fn AuthErrorView() -> impl IntoView { let auth = use_context::().expect("AuthError: RwSignal was not found"); diff --git a/src/common/comparison/base.rs b/src/common/comparison/base.rs index afca34b..6ba5c86 100644 --- a/src/common/comparison/base.rs +++ b/src/common/comparison/base.rs @@ -2,7 +2,8 @@ use super::{ Comparison, MapListComparison, NestedListComparison, NumberComparison, NumberListComparison, TextComparison, TextListComparison, }; -use crate::common::{ExpandableInfo, ValueType}; +use crate::common::ValueType; +use std::collections::HashMap; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone)] @@ -42,15 +43,13 @@ impl ComparisonData { } } - pub fn compare(&self, student_data: &ExpandableInfo) -> Result { - let value = student_data.data.get(&self.member).expect( - format!( - "Couldn't find member {:?}, current student data: {:?}", - self.member, student_data + pub fn compare(&self, student_data: &HashMap) -> Result { + student_data.get(&self.member) + .ok_or_else(|| + format!("Couldn't find member {:?}, current student data: {:?}", self.member, student_data) ) - .as_str(), - ); - self.comparison.evaluate(value, &self.target_value) + .and_then(|value| self.comparison.evaluate(value, &self.target_value)) + .or_else(|err| Err(err.to_string())) } } @@ -106,11 +105,11 @@ mod tests { display_text: "First Name is Jane".to_string(), }; - let mut result = text_comp_success.compare(&student_data); + let mut result = text_comp_success.compare(&student_data.data); assert!(result.is_ok()); assert_eq!(result.unwrap(), true); - result = text_comp_fail.compare(&student_data); + result = text_comp_fail.compare(&student_data.data); assert!(result.is_err()); } } diff --git a/src/common/comparison/number.rs b/src/common/comparison/number.rs index 3440e97..9a16752 100644 --- a/src/common/comparison/number.rs +++ b/src/common/comparison/number.rs @@ -17,14 +17,16 @@ impl Comparison for NumberComparison { // The prerequisites for all number comparisons is that the target value is Some(number) and the // comparison member points to Some(number) in the student_info. let student_value = match comp_value { - ValueType::Number(Some(num)) => num.parse::().unwrap(), + ValueType::Number(Some(num)) => num.parse::().unwrap(), _ => { return Err("Student value is not a number".into()); } }; + leptos::logging::log!("Parsing value: {:?}", target_value); + let target = match target_value { - ValueType::Number(Some(num)) => num.parse::().unwrap(), + ValueType::Number(Some(num)) => num.parse::().unwrap(), _ => { return Err("Target value is not a number".into()); } diff --git a/src/common/form_schema.rs b/src/common/form_schema.rs index 4825f60..f7bca1e 100644 --- a/src/common/form_schema.rs +++ b/src/common/form_schema.rs @@ -26,7 +26,14 @@ pub enum InputType { } impl InputType { - pub fn into_view(self, data_map: RwSignal>) -> impl IntoView { + /// Converts an `InputType` into an `impl IntoView`. A name must be specified in the case that + /// the component is a checkbox/radio list, which requires a unique name to function properly. + /// Many times, the name will be the same as the data member. + pub fn into_view( + self, + data_map: RwSignal>, + name: String, + ) -> impl IntoView { view! { {match self { InputType::Text(member, label, placeholder, required) => @@ -56,6 +63,7 @@ impl InputType { view! { , +} + +impl TabInfo { + pub fn new( + text: impl Into, + path: impl Into, + sub_paths: Option> + ) -> Self { + Self { + text: text.into(), + path: path.into(), + sub_paths: sub_paths.unwrap_or_default() + } + } +} + +#[derive(Debug, Clone)] +pub struct SubTabInfo { + pub text: String, + pub path: String, +} + +impl SubTabInfo { + pub fn new(text: impl Into, path: impl Into) -> Self { + Self { + text: text.into(), + path: path.into() + } + } +} diff --git a/src/components/banner.rs b/src/components/banner.rs index 8a26ec3..088e15d 100644 --- a/src/components/banner.rs +++ b/src/components/banner.rs @@ -22,7 +22,7 @@ pub fn Banner( title: String, /// The file path or URL of a logo displayed at the top left of the banner. #[prop(into)] - logo: String, + logo: Signal, /// The route that the navigates user to a page (home page). #[prop(into, optional)] path: Signal, @@ -39,36 +39,26 @@ pub fn Banner( }; view! { -
-
-
-
- icon -
-
-

{title.clone()}

-
+
+
+
+ icon
-
- - - - "Back to Dashboard" - - +
+

{title.clone()}

+
} } diff --git a/src/components/lists/multi/checkbox.rs b/src/components/lists/multi/checkbox.rs index 252402d..a5de034 100644 --- a/src/components/lists/multi/checkbox.rs +++ b/src/components/lists/multi/checkbox.rs @@ -103,6 +103,10 @@ pub fn Checkbox( /// ``` #[component] pub fn CheckboxList( + /// The name that this list should use. This doesn't need to be specified all the time, but is + /// for use in elements that may treat this list as a sub-input (components like the [`MultiEntry`] + /// do this) + #[prop(optional, into)] name: Signal, /// The data member that this list should edit/display from the `data_map`. #[prop(into)] data_member: Signal, /// A [`Signal`] allowing access to a data store. @@ -160,7 +164,7 @@ pub fn CheckboxList( on_change=on_change // The actual selected values are tracked by this element, not by the checkboxes themselves. value=item - name=data_member + name=move || format!("{}_{}", name.get(), data_member.get()) disabled=disabled /> } diff --git a/src/components/lists/multi/mod.rs b/src/components/lists/multi/mod.rs index c3fcbbc..717cb89 100644 --- a/src/components/lists/multi/mod.rs +++ b/src/components/lists/multi/mod.rs @@ -1,6 +1,8 @@ mod utils; mod checkbox; mod chips; +mod select_nav_list; pub use checkbox::*; -pub use chips::*; \ No newline at end of file +pub use chips::*; +pub use select_nav_list::*; \ No newline at end of file diff --git a/src/components/lists/multi/select_nav_list.rs b/src/components/lists/multi/select_nav_list.rs new file mode 100644 index 0000000..3ce9677 --- /dev/null +++ b/src/components/lists/multi/select_nav_list.rs @@ -0,0 +1,179 @@ +use leptos::logging::log; +use leptos::prelude::*; +use leptos_router::components::A; +use crate::components::utils; + +#[derive(Debug, Clone)] +pub struct NavListInfo { + pub text: Vec, + pub id: String, + pub slug: String, +} + +#[component] +fn NavListItem( + #[prop(into)] name: Signal, + #[prop(into)] value: Signal, + #[prop(into)] text: Signal>, + #[prop(into)] slug: Signal, + #[prop(into)] selected: Signal, + #[prop(into)] on_change: Callback<()>, +) -> impl IntoView { + let linked_info = Signal::derive(move || + text.get() + .get(0) + .cloned() + .unwrap_or_default() + ); + let other_info = Signal::derive(move || + text.get() + .iter() + .cloned() + .skip(1) + .collect::>() + ); + + view! { +
+ + + {linked_info} + + +

{info}

+
+
+ } +} + +#[component] +fn NavListCheckbox( + #[prop(into)] selected: Signal, + #[prop(into)] on_change: Callback<()>, + #[prop(into)] name: Signal, + #[prop(into)] value: Signal, +) -> impl IntoView { + let id = Memo::new(move |_| utils::create_unique_id(&name.get(), &value.get())); + + view! { + + } +} + +/// # Selectable Nav-List Component +/// +/// This component takes a `Vec` to show a list. The list contains links that append +/// the `NavListItem::slug` to the current path, and each item is selectable. The list of selected +/// items is usable in this component's parent. +#[component] +pub fn SelectableNavList( + #[prop(into)] selected: RwSignal>, + #[prop(into)] items: Signal>, + #[prop(into)] headers: Signal>, + #[prop(into)] name: Signal, +) -> impl IntoView { + let all_selected = RwSignal::new(false); + let all_selected_change = move || { + if all_selected.get() { + log!("Unchecking all values."); + all_selected.set(false); + selected.set(Vec::new()); + } else { + log!("Checking all values."); + all_selected.set(true); + selected.set( + items + .get() + .iter() + .map(|info| info.id.clone()) + .collect::>() + ); + } + }; + + view! { +
+ // Header block - contains evenly spaced headers and the "select all" checkbox. + // Include a checkbox here that's got the same style as the items. This maintains spacing. + // All headers *must* be evenly spaced, otherwise the list starts getting confusing. +
+ + +
{header_text}
+
+
+ // Items block - contains constructed items. These are selectable. + + } + } + /> +
+ } +} diff --git a/src/components/lists/single/radio.rs b/src/components/lists/single/radio.rs index e5a61b7..e28c0f0 100644 --- a/src/components/lists/single/radio.rs +++ b/src/components/lists/single/radio.rs @@ -151,6 +151,7 @@ pub fn RadioList( #[prop(optional, into)] label: String, #[prop(optional, into)] required: Signal, #[prop(optional, into)] other_prompt: Signal, + #[prop(optional, into)] name: Signal ) -> impl IntoView { //#region Value Logic @@ -262,7 +263,7 @@ pub fn RadioList( on_change=on_change // The actual selected values are tracked by this element, not by the checkboxes themselves. value=item - name=data_member + name=move || format!("{}_{}", name.get(), data_member.get()) disabled=disabled /> } diff --git a/src/components/lists/single/select.rs b/src/components/lists/single/select.rs index b20cddc..cd08b81 100644 --- a/src/components/lists/single/select.rs +++ b/src/components/lists/single/select.rs @@ -3,7 +3,6 @@ use leptos::ev::{Event, Targeted}; use leptos::prelude::*; use leptos::web_sys::HtmlSelectElement; use std::collections::HashMap; -use leptos::logging::{log, warn}; use crate::components::{use_validation_context, InputState, ValidationState}; fn validate(required: bool, value: String) -> ValidationState { @@ -20,7 +19,7 @@ fn validate(required: bool, value: String) -> ValidationState { /// by the `value` prop of the component. #[component] pub fn Select( - #[prop()] value_list: Vec, + #[prop(into)] value_list: Signal>, #[prop(into)] data_member: Signal, #[prop()] data_map: RwSignal>, #[prop(optional, into)] label: String, @@ -28,28 +27,19 @@ pub fn Select( #[prop(optional, into)] required: Signal ) -> impl IntoView { //#region Value Logic - - let default_raw_value = data_map.get_untracked() - .get(&data_member.get_untracked()) - .cloned() - .unwrap_or_default() - .as_string().unwrap_or_default().unwrap_or_default(); - - let raw_value = RwSignal::new(default_raw_value); - - Effect::new(move || { - data_map.update(|map| { - map.insert(data_member.get(), ValueType::String(Some(raw_value.get()))); - }); + + let value = Memo::new(move |_| { + data_map.with(|map| { + map.get(&data_member.get_untracked()) + .and_then(|v| v.as_string().ok().flatten()) + .unwrap_or_default() + }) }); //#endregion //#region Validation Logic - let error = RwSignal::new( - validate(required.get_untracked(), raw_value.get_untracked()) - ); - warn!("Current error state: {:?}", error.get_untracked()); + let error = Memo::new(move |_| validate(required.get(), value.get())); let dirty = RwSignal::new(false); let show_errors = Signal::derive(move || dirty.get() && matches!(error.get(), ValidationState::Invalid(_))); @@ -68,19 +58,12 @@ pub fn Select( //#endregion //#region Event Logic - + let on_change = move |e: Targeted| { let value = e.target().value(); - log!("Found value {}", value); - raw_value.set(value.clone()); - error.set(validate(required.get(), raw_value.get())); dirty.set(true); data_map.update(|map| { - if let Some(val) = map.get_mut(&data_member.get()) { - *val = ValueType::String(Some(value.clone())); - } else { - map.insert(data_member.get(), ValueType::String(Some(value))); - } + map.insert(data_member.get(), ValueType::String(Some(value))); }); }; @@ -96,27 +79,21 @@ pub fn Select( transition-border duration-150 border-red-700 bg-transparent disabled:border-gray-600 disabled:pointer-events-none disabled:bg-gray-600/33" + prop:value=move || value.get() on:change:target=on_change disabled=disabled > // This closure handles the display of the options. - - {move || { - value_list - .iter() - .map(move |value| { - let display = value.to_owned(); - view! { } - }) - .collect::>() - }} + + // We can reasonably assume that the options in the list will be distinct. + {val.clone()} } + />
diff --git a/src/components/loading.rs b/src/components/loading.rs index ee11ded..8c8a18c 100644 --- a/src/components/loading.rs +++ b/src/components/loading.rs @@ -3,7 +3,7 @@ use leptos::prelude::*; #[component] pub fn Loading() -> impl IntoView { view! { -
+
} diff --git a/src/components/login/admin.rs b/src/components/login/admin.rs new file mode 100644 index 0000000..0510f31 --- /dev/null +++ b/src/components/login/admin.rs @@ -0,0 +1,16 @@ +use leptos::prelude::*; +use crate::components::login::setup::provide_auth_context; + +#[component] +pub fn AdminLoginContext( + children: Children +) -> impl IntoView { + provide_auth_context( + crate::utils::use_origin(), + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM", + "56c2bqvl021rv8d5mq36blt7jv", + "/providers/callback" + ); + + view! { {children()} } +} \ No newline at end of file diff --git a/src/components/login/mod.rs b/src/components/login/mod.rs index fae50eb..c9911f2 100644 --- a/src/components/login/mod.rs +++ b/src/components/login/mod.rs @@ -1,5 +1,7 @@ +pub(crate) mod setup; mod provider; pub use provider::*; mod student; pub use student::*; -pub(crate) mod setup; \ No newline at end of file +mod admin; +pub use admin::*; \ No newline at end of file diff --git a/src/components/login/student.rs b/src/components/login/student.rs index 14becf8..d5c7c54 100644 --- a/src/components/login/student.rs +++ b/src/components/login/student.rs @@ -1,5 +1,4 @@ use leptos::prelude::*; -use leptos_router::components::Outlet; use super::setup::provide_auth_context; ///# Student Login Context Component @@ -16,7 +15,9 @@ use super::setup::provide_auth_context; /// /// } #[component] -pub fn StudentLoginContext() -> impl IntoView { +pub fn StudentLoginContext( + children: Children, +) -> impl IntoView { provide_auth_context( crate::utils::use_origin(), "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Lfjuy5zaM", @@ -24,5 +25,5 @@ pub fn StudentLoginContext() -> impl IntoView { "/students/callback" ); - view! { } + view! { {children()} } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 27d6b69..89dc30c 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -13,9 +13,10 @@ mod lists; mod toasts; mod header; pub mod login; +mod tabs; pub use self::{ action_button::*, banner::*, dashboard_button::*, lists::*, loading::*, multi_entry::*, panel::*, row::*, text_field::*, - validated_form::*, toasts::*, header::*, date::* + validated_form::*, toasts::*, header::*, date::*, tabs::* }; diff --git a/src/components/multi_entry.rs b/src/components/multi_entry.rs index c67b706..ef91df3 100644 --- a/src/components/multi_entry.rs +++ b/src/components/multi_entry.rs @@ -1,8 +1,9 @@ use crate::common::{InputType, ValueType}; use crate::components::ActionButton; -use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; use std::collections::HashMap; +use leptos::logging::log; +use leptos_icons::Icon; use uuid::Uuid; // region Helper Functions @@ -12,6 +13,14 @@ fn get_uuid_from_map(map: &HashMap) -> Option { map.get("uuid")?.as_string().unwrap_or(None) } +/// Does the same as [`get_uuid_from_map`], but starts from a [`ValueType`] enum. +fn get_uuid(vt: ValueType) -> Option { + vt.as_map().ok().flatten().unwrap_or_default() + .get("uuid")? + .as_string() + .ok().flatten() +} + /// Updates an Entry within the given `Vec`. fn update_entry_list(list: &mut Vec, data: &HashMap) { if let Some(uuid) = get_uuid_from_map(&data) { @@ -66,81 +75,103 @@ fn new_entry() -> ValueType { /// `first_name` and `last_name` keys within that `Entry`. #[component] pub fn MultiEntry( + #[prop(into)] label: Signal, + #[prop(optional, into)] description: Signal, /// The data member that contains the list of entries within the `data_map`. #[prop(into)] - data_member: String, + data_member: Signal, /// The data map that contains all the information. Data is found within this map using the /// component's `data_member` input. #[prop()] data_map: RwSignal>, /// The type and order of the inputs for each `Entry` component within this `MultiEntry` component. - #[prop(optional)] - schema: Vec, + #[prop(optional, into)] + schema: Signal>, ) -> impl IntoView { // Make the values within the list reactive. Each of these values should be a ValueType::Map, // so we can pass these directly to the application. - let data_reactive = RwSignal::new( - data_map - .get_untracked() - .get(&data_member) - .unwrap_or(&ValueType::List(None)) - .as_list() - .unwrap_or(Some(Vec::::new())) - .unwrap_or(Vec::new()), - ); - - // This effect updates the data_map with any new values from the data_reactive list, every time - // the data_reactive list is changed. This allows changes from within the Entry component to - // bubble up to the data_map effectively. - Effect::new(move |_| { - let entry_list = data_reactive.get(); - data_map.update(|map| { - map.insert(data_member.clone(), ValueType::List(Some(entry_list))); - }); + let data_list = Memo::new(move |_| { + data_map.with(|map| { + map.get(&data_member.get()) + .and_then(|v| v.as_list().ok().flatten()) + .unwrap_or_default() + }) }); + + // This effect updates the data_map with any new values from the data_list, every time + // the data_list is changed. This allows changes from within the Entry component to + // bubble up to the data_map effectively. + let update_parent_list = move |list| { + log!("Updating parent list using child list {:?}", list); + data_map.update(|map| { + map.insert(data_member.get(), ValueType::List(Some(list))); + }); + }; - let add_entry = move |_| data_reactive.update(|list| list.push(new_entry())); + let add_entry = move |_| data_map.update(|map| { + log!("Adding new entry."); + let mut list = data_list.get_untracked(); + + list.push(new_entry()); + map.insert(data_member.get(), ValueType::List(Some(list))); + }); view! {
+ {label} + + {description} + +
+
// Render the entries - - } + "You haven't added any entries yet."
} } - /> + > + + } + } + /> +
- "Add entry" - "Print Info" + "Add entry"
} } @@ -176,20 +207,35 @@ fn Entry( /// The data map that contains all information. Data is found within this map using the `member` /// props. This map should be unique to this entry; in other words, it should be contained and /// passed down from within the entry list in the parent `MultiEntry` component. - #[prop()] - data_map: RwSignal>, + #[prop()] data_map: RwSignal>, /// The schema of this entry. Should contain all information about what inputs to use and what /// data members those inputs modify. - #[prop()] - schema: Vec, + #[prop(into)] schema: Signal>, + /// The unique ID for this entry. + #[prop(into)] id: Signal, + /// The function to run when the delete button is clicked. + #[prop(into)] on_delete: Callback<()>, ) -> impl IntoView { view! { -
- {schema.iter() - .map(|input_type| { - input_type.clone().into_view(data_map.clone()) - }).collect_view() - } +
+ // Delete button +
+ +
+ + // Input section +
+ {move || { + schema + .get() + .iter() + .map(|input_type| input_type.clone().into_view(data_map.clone(), id.get())) + .collect_view() + }} +
} } diff --git a/src/components/tabs/mod.rs b/src/components/tabs/mod.rs new file mode 100644 index 0000000..2c727dc --- /dev/null +++ b/src/components/tabs/mod.rs @@ -0,0 +1,2 @@ +mod sidebar; +pub use sidebar::*; \ No newline at end of file diff --git a/src/components/tabs/sidebar.rs b/src/components/tabs/sidebar.rs new file mode 100644 index 0000000..4a2b7f0 --- /dev/null +++ b/src/components/tabs/sidebar.rs @@ -0,0 +1,141 @@ +use leptos::prelude::*; +use leptos_router::components::{Outlet, A}; +use leptos_router::hooks::use_location; +use crate::common::{SubTabInfo, TabInfo}; + +/// # Tab Sidebar Component +/// +/// This component creates a list of tabs that correspond to the given `Vec`. +/// There are a few things to note: +/// +/// - This component defines an [`Outlet`], meaning that it is designed to display the route +/// information on its own. If you intend to use this on a page, the page should not also contain +/// an `Outlet` component unless it needs to render its own nested children. +/// - The "selected" appearance for the individual tabs work best when their paths are at the same +/// depth. For example: +/// ``` +/// let good_list = vec! [ +/// TabInfo { +/// ..., +/// path: "/default/path" +/// }, +/// TabInfo { +/// ..., +/// path: "/good/path" +/// } +/// ]; +/// +/// let bad_list = vec! [ +/// TabInfo { +/// ..., +/// path: "/default/path" +/// }, +/// TabInfo { +/// ..., +/// path: "/bad/path/here" +/// } +/// ] +/// ``` +/// +/// Example usage in a view (assuming the good list from above): +/// ``` +/// view! { +/// +/// // Remember, no Outlets! +/// } +/// ``` +#[component] +pub fn TabSidebarList( + #[prop(into)] base_path: Signal, + #[prop(into)] tabs: Signal>, +) -> impl IntoView { + // Create a series of tabs based on a given list of names and paths. + + view! { +
+ + +
+ +
+
+ } +} + +#[component] +fn SidebarTab( + #[prop(into)] base_path: Signal, + #[prop(into)] text: Signal, + #[prop(into)] path: Signal, + #[prop(into)] sub_paths: Signal>, +) -> impl IntoView { + let location = use_location(); + + let full_path = Memo::new(move |_| { + format!("/{}/{}", base_path.get(), path.get()) + }); + + let selected = Memo::new(move |_| { + let current_path = location.pathname.get(); + + // There's extra logic here as well, but for now this works. + current_path.starts_with(&full_path.get()) + }); + + view! { +
+ +
{text}
+
+
+
+ + + +
+
+
+ } +} + +#[component] +fn SidebarSubTab( + #[prop(into)] parent_path: Signal, + #[prop(into)] text: Signal, + #[prop(into)] path: Signal, +) -> impl IntoView { + let full_path = Memo::new(move |_| { + format!("{}/{}", parent_path.get(), path.get()) + }); + + view! { + + {text} + + } +} diff --git a/src/components/validated_form.rs b/src/components/validated_form.rs index 8da4b19..4420ccb 100644 --- a/src/components/validated_form.rs +++ b/src/components/validated_form.rs @@ -5,6 +5,7 @@ use crate::components::{ActionButton, Header}; /// Indicates the current state of an input. #[derive(Default, Debug, Clone)] +#[derive(PartialEq)] pub enum ValidationState { /// A valid input. #[default] @@ -149,15 +150,8 @@ pub fn ValidatedForm( }; view! { -
-
+ +
{children()} impl IntoView { + view! { +
+
"Admin Home Page"
+
"This is just a placeholder for now."
+
+ } +} diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs new file mode 100644 index 0000000..3bb847a --- /dev/null +++ b/src/pages/admin/mod.rs @@ -0,0 +1,11 @@ +mod shell; +mod home_page; +mod provider_page; +mod scholarship_page; +mod utils_page; + +pub use shell::*; +pub use home_page::*; +pub use provider_page::*; +pub use scholarship_page::*; +pub use utils_page::*; diff --git a/src/pages/admin/provider_page.rs b/src/pages/admin/provider_page.rs new file mode 100644 index 0000000..92ac896 --- /dev/null +++ b/src/pages/admin/provider_page.rs @@ -0,0 +1,83 @@ +use leptos::logging::log; +use leptos::prelude::*; +use crate::common::ValueType; +use crate::components::{NavListInfo, SelectableNavList}; +use crate::pages::api::get_all_providers; + +#[component] +pub fn AdminProviderPage() -> impl IntoView { + let selected_items = RwSignal::new(Vec::new()); + let provider_list = RwSignal::new(Vec::new()); + + let provider_trigger = Trigger::new(); + let provider_resource = Resource::new( + move || provider_trigger.track(), + async move |_| { + get_all_providers().await + } + ); + + Effect::new(move || { + if let Some(Err(err)) = provider_resource.get() { + log!("Found error: {}", err.to_string()); + } + let Some(Ok(list)) = provider_resource.get() else { + return; + }; + + let members_list = vec![ + "contact_email".to_string(), + "first_name".to_string(), + "last_name".to_string(), + ]; + + // Convert the list into a series of NavListInfo + let converted = list + .into_iter() + .map(|info| { + let mut text_list = vec![]; + members_list.iter().for_each(|member| { + let val = info.get(member) + .unwrap_or(&ValueType::String(None)) + .as_string() + .ok().flatten() + .unwrap_or_default(); + text_list.push(val); + }); + + let subject = info.get("subject") + .unwrap_or(&ValueType::String(None)) + .to_string(); + + NavListInfo { + text: text_list, + id: subject.clone(), + slug: subject, + } + }) + .collect(); + + provider_list.set(converted); + }); + + let headers = vec![ + "Email", + "First Name", + "Last Name" + ] + .iter() + .map(|s| s.to_string()) + .collect::>(); + + view! { +
+
"Provider View"
+ +
+ } +} diff --git a/src/pages/admin/scholarship_page.rs b/src/pages/admin/scholarship_page.rs new file mode 100644 index 0000000..c3b13ab --- /dev/null +++ b/src/pages/admin/scholarship_page.rs @@ -0,0 +1,15 @@ +use leptos::prelude::*; + +#[component] +pub fn AdminScholarshipPage() -> impl IntoView { + view! { +
+
+ "Scholarships View" +
+
+ "This is a placeholder for now. It will show a list of all scholarships." +
+
+ } +} \ No newline at end of file diff --git a/src/pages/admin/shell.rs b/src/pages/admin/shell.rs new file mode 100644 index 0000000..77c5192 --- /dev/null +++ b/src/pages/admin/shell.rs @@ -0,0 +1,28 @@ +use leptos::prelude::*; +use crate::common::TabInfo; +use crate::components::{Banner, TabSidebarList}; +use crate::components::login::AdminLoginContext; + +/// # Admin Shell Component +/// +/// This shell should keep track of pretty much everything that changes across the admin page. It +/// will provide the login context, the banner, and the tabs. +#[component] +pub fn AdminShell() -> impl IntoView { + // Create login context + view! { + + + // We'll put the tabs here. + + + } +} diff --git a/src/pages/admin/utils_page.rs b/src/pages/admin/utils_page.rs new file mode 100644 index 0000000..7b8e7d1 --- /dev/null +++ b/src/pages/admin/utils_page.rs @@ -0,0 +1,190 @@ +use chrono::DateTime; +use crate::components::ActionButton; +use leptos::prelude::*; +use crate::common::{DateInfo, DateRange}; +use crate::pages::api::{CreateDates, CreateTestComparisons}; + +fn get_important_dates() -> Vec { + vec! [ + DateInfo { + id: uuid::Uuid::new_v4().to_string(), + title: "Provider Forms Open".to_string(), + date: DateRange::Range( + DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(), + DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap() + ), + description: "".to_string(), + }, + DateInfo { + id: uuid::Uuid::new_v4().to_string(), + title: "Student Forms Open".to_string(), + date: DateRange::Range( + DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(), + DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap() + ), + description: "".to_string(), + }, + DateInfo { + id: uuid::Uuid::new_v4().to_string(), + title: "Scholarship Decision Window".to_string(), + date: DateRange::Range( + DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(), + DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap() + ), + description: "Start date may change if available sooner.".to_string(), + }, + DateInfo { + id: uuid::Uuid::new_v4().to_string(), + title: "PHS Scholarship Committee".to_string(), + date: DateRange::Single( + DateTime::parse_from_rfc3339("2026-05-18T00:00:00-05:00").unwrap(), + ), + description: "Time TBD".to_string(), + }, + DateInfo { + id: uuid::Uuid::new_v4().to_string(), + title: "Scholarship and Awards Night".to_string(), + date: DateRange::Single( + DateTime::parse_from_rfc3339("2026-06-09T00:00:00-05:00").unwrap(), + ), + description: "Time TBD".to_string(), + }, + ] +} + +#[server] +async fn create_scholarship_export() -> Result, ServerFnError> { + // Exports olarship information - specifically the name of the scholarship, the name of the + // provider, and the scholarship's requirements. + // This means we'll need to get all the relations, all the scholarships, and all the contact info. + use crate::utils::server::create_dynamo_client; + use aws_sdk_dynamodb::client::Client; + use crate::pages::api::{get_comparison_info, SCHOLARSHIPS_TABLE, PROVIDER_CONTACT_TABLE}; + use std::collections::HashMap; + use crate::common::ValueType; + + let scan_table_items = async |client: &Client, table_name: &str| { + client.scan() + .table_name(table_name) + .send() + .await + .ok().and_then(|res| res.items) + .unwrap_or_default() + }; + + #[derive(serde::Serialize, serde::Deserialize, Debug)] + struct ScholarshipInfo { + name: String, + provider_name: String, + relations: Vec, + } + + let client = create_dynamo_client().await; + + let scholarships = dbg!(scan_table_items(&client, SCHOLARSHIPS_TABLE) + .await + .iter().map(|item| { + item.into_iter() + .map(|(k, v)| (k.clone(), ValueType::from(v))) + .collect() + }).collect::>>() + ); + let contacts = dbg!( + scan_table_items(&client, PROVIDER_CONTACT_TABLE).await + .iter().map(|item| { + item.into_iter() + .map(|(k, v)| (k.clone(), ValueType::from(v))) + .collect() + }).collect::>>() + ); + let db_relations = dbg!( + get_comparison_info().await.unwrap_or_default() + ); + + // Build a new structure containing some of the scholarship's information. + let resolved = scholarships.into_iter() + .map(|scholarship| { + // We just want the scholarship's name, the relations, and the provider's name. + // In here, we're going to resolve all the scholarship's information to provide this in + // a readable format. + let name = scholarship.get("name") + .unwrap_or(&ValueType::String(None)) + .to_string(); + + // The list of relations - we're going to search the master list for each comparison's + // name. It's structured as a map of lists, or a ValueType::Map(Option>)>>) + let relations = scholarship.get("requirements") + .unwrap_or(&ValueType::Map(None)) + .as_map().ok().flatten() + .unwrap_or_default() // We have our map of categories, now we need all the values inside each category. + .iter() + .map(|(_, list)| { + list.as_list().ok().flatten() + .unwrap_or_default() + }) + .flatten() + .collect::>() // This is a Vec + .iter().map(|val| val.clone().as_string().ok().flatten().unwrap_or_default()) + .collect::>(); + + // The provider ID, which we'll use to search for the provider's name. + let provider_id = scholarship.get("provider_id") + .unwrap_or(&ValueType::String(None)) + .to_string(); + + // This is a mess, we'll clean it up later. + let parsed_relations = relations + .iter() + .filter_map(|relation| { + db_relations.iter().find(|search| { + search.id == *relation + }) + .and_then(|search| Some(search.display_text.clone())) + }) + .collect::>(); + + ScholarshipInfo { + name, + provider_name: provider_id, + relations: parsed_relations, + } + }) + .collect::>(); + + // TODO Convert into CSV and send to client. For now I think I'm just going to serialize to json and print it. + leptos::logging::log!("Scholarships list: {:?}", resolved); + + Ok(Vec::new()) +} + +#[component] +pub fn AdminUtilsPage() -> impl IntoView { + let create_comparisons = ServerAction::::new(); + let create_dates = ServerAction::::new(); + let create_export = ServerAction::::new(); + + let on_click_comparisons = move |_| { + create_comparisons.dispatch(CreateTestComparisons{}); + }; + + let on_click_dates = move |_| { + create_dates.dispatch(CreateDates{dates: get_important_dates()}); + }; + + let on_click_export = move |_| { + create_export.dispatch(CreateScholarshipExport{}); + }; + + view! { +
+
+
+ "This utility page provides buttons to initialize the corresponding lists of information." +
+ "Create Comparisons" + "Create Dates" + "Scholarship Export" +
+
+ }.into_any() +} diff --git a/src/pages/api/admin.rs b/src/pages/api/admin.rs new file mode 100644 index 0000000..288c7bc --- /dev/null +++ b/src/pages/api/admin.rs @@ -0,0 +1,44 @@ +use leptos::prelude::*; +use std::collections::HashMap; +use crate::common::ValueType; +use leptos::logging::log; + +#[cfg(feature = "ssr")] +mod server_logic { + pub use crate::utils::server::create_dynamo_client; + pub use super::super::PROVIDER_CONTACT_TABLE; +} + +#[server] +pub async fn get_all_providers() -> Result>, ServerFnError> { + use server_logic::*; + + // We want to get all provider information from the contacts table. + // This will be migrated to the new table structure when it happens. + + let client = create_dynamo_client().await; + + log!("Scanning all provider information from {}", PROVIDER_CONTACT_TABLE); + + client + .scan() + .table_name(PROVIDER_CONTACT_TABLE) + .send() + .await + .map(|output| { + if let Some(items) = output.items { + items + .into_iter() + .map(|item| { + item.into_iter().map(|(key, attr)| { + let val = ValueType::from(&attr); + (key, val) + }).collect() + }) + .collect::>>() + } else { + Vec::new() + } + }) + .map_err(ServerFnError::from) +} diff --git a/src/pages/api/comparisons.rs b/src/pages/api/comparisons.rs index 1022b1c..6ff9624 100644 --- a/src/pages/api/comparisons.rs +++ b/src/pages/api/comparisons.rs @@ -7,6 +7,9 @@ use aws_sdk_dynamodb::error::ProvideErrorMetadata; #[cfg(feature = "ssr")] use leptos::logging::log; +#[cfg(feature = "ssr")] +use super::COMPARISONS_TABLE; + use crate::common::{ ComparisonData, ComparisonType, MapListComparison, NumberComparison, NumberListComparison, TextComparison, TextListComparison, ValueType, @@ -15,8 +18,6 @@ use std::collections::HashMap; use leptos::prelude::ServerFnError; use leptos::server; -#[cfg(feature = "ssr")] -static COMPARISONS_TABLE: &str = "leptos-comparisons"; /// 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. @@ -188,6 +189,17 @@ fn create_sports_comparisons() -> Vec { "Sports Participation", "Tennis", ), + ComparisonData::new( + "sports_baseball", + "sports_participation", + ComparisonType::MapList(MapListComparison::FlattenToTextList( + "sport_name".to_string(), + Box::new(TextListComparison::Contains), + )), + ValueType::String(Some("Baseball".to_string())), + "Sports Participation", + "Baseball", + ), ] } @@ -252,12 +264,12 @@ fn create_major_comparisons() -> Vec { #[allow(unused)] fn make_comp_list() -> Vec { let gpa_3 = ComparisonData::new( - "gpa_3", + "gpa_top_third", "weighted_gpa", ComparisonType::Number(NumberComparison::GreaterThanOrEqual), - ValueType::Number(Some(3.0.to_string())), + ValueType::Number(Some(6.264.to_string())), "GPA Limits", - "GPA >= 3.0", + "Top Third GPA", ); let test_comp = ComparisonData::new( @@ -271,13 +283,10 @@ fn make_comp_list() -> Vec { 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, - ))), - )), + "service_hours", + ComparisonType::Number( + NumberComparison::GreaterThanOrEqual + ), ValueType::Number(Some(20.to_string())), "Community Service", "20+ service hours", @@ -285,13 +294,10 @@ fn make_comp_list() -> Vec { let service_hours_25 = ComparisonData::new( "service_hours_25", - "community_involvement", - ComparisonType::MapList(MapListComparison::FlattenToNumberList( - "service_hours".to_string(), - Box::new(NumberListComparison::Sum(Box::new( - NumberComparison::GreaterThanOrEqual, - ))), - )), + "service_hours", + ComparisonType::Number( + NumberComparison::GreaterThanOrEqual + ), ValueType::Number(Some(25.to_string())), "Community Service", "25+ service hours", @@ -299,13 +305,10 @@ fn make_comp_list() -> Vec { 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, - ))), - )), + "service_hours", + ComparisonType::Number( + NumberComparison::GreaterThanOrEqual + ), ValueType::Number(Some(30.to_string())), "Community Service", "30+ service hours", @@ -356,6 +359,42 @@ fn make_comp_list() -> Vec { "Family with Military Service", ); + let youth_baseball = ComparisonData::new( + "youth_baseball", + "youth_baseball", + ComparisonType::Text(TextComparison::Matches), + ValueType::String(Some("Yes".to_string())), + "Additional Eligibility Factors", + "Participated in Pomperaug Youth Baseball" + ); + + let aquatic_club = ComparisonData::new( + "aquatic_club", + "aquatic_club", + ComparisonType::Text(TextComparison::Matches), + ValueType::String(Some("Yes".to_string())), + "Additional Eligibility Factors", + "Participated in Panthers Aquatic Club" + ); + + let pea_member = ComparisonData::new( + "pea_member", + "pea_member", + ComparisonType::Text(TextComparison::Matches), + ValueType::String(Some("Yes".to_string())), + "Additional Eligibility Factors", + "Has a Parent in the PEA" + ); + + let music_program = ComparisonData::new( + "music_program", + "music_program", + ComparisonType::Text(TextComparison::Matches), + ValueType::String(Some("Yes".to_string())), + "Sports Participation", + "PHS Music Program" + ); + let mut sports_comps = create_sports_comparisons(); let mut major_comps = create_major_comparisons(); @@ -370,6 +409,10 @@ fn make_comp_list() -> Vec { attended_bas, midd_south, family_military, + youth_baseball, + aquatic_club, + pea_member, + music_program ]; comp_list.append(&mut sports_comps); diff --git a/src/pages/api/dates.rs b/src/pages/api/dates.rs index ee3a6a7..3cbf387 100644 --- a/src/pages/api/dates.rs +++ b/src/pages/api/dates.rs @@ -4,7 +4,7 @@ use leptos::prelude::*; use crate::common::DateInfo; #[cfg(feature = "ssr")] -static DATES_TABLE: &str = "leptos-dates"; +use super::DATES_TABLE; #[cfg(feature = "ssr")] use aws_sdk_dynamodb::{ diff --git a/src/pages/api/mod.rs b/src/pages/api/mod.rs index f7aa9bf..fb8c5e8 100644 --- a/src/pages/api/mod.rs +++ b/src/pages/api/mod.rs @@ -1,7 +1,27 @@ mod comparisons; -pub use comparisons::*; mod scholarships; -pub use scholarships::*; mod dates; -pub use dates::*; +mod admin; +mod providers; +pub mod students; +pub use comparisons::*; +pub use scholarships::*; +pub use dates::*; +pub use admin::*; +pub use providers::*; + +#[cfg(feature = "ssr")] +pub static COMPARISONS_TABLE: &str = "leptos-comparisons"; + +#[cfg(feature = "ssr")] +pub static DATES_TABLE: &str = "leptos-dates"; + +#[cfg(feature = "ssr")] +pub static SCHOLARSHIPS_TABLE: &str = "leptos-scholarships"; + +#[cfg(feature = "ssr")] +pub static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts"; + +#[cfg(feature = "ssr")] +pub static MAIN_TABLE_NAME: &str = "scholarships-main"; diff --git a/src/pages/api/providers.rs b/src/pages/api/providers.rs new file mode 100644 index 0000000..988e932 --- /dev/null +++ b/src/pages/api/providers.rs @@ -0,0 +1,71 @@ +use leptos::prelude::*; +use leptos::logging::debug_log; +use std::collections::HashMap; +use leptos::server_fn::codec::Json; +use crate::common::ValueType; + +#[cfg(feature = "ssr")] +use crate::utils::server::{create_dynamo_client, into_attr_map}; + +#[cfg(feature = "ssr")] +static PROVIDER_CONTACT_TABLE: &str = "leptos-provider-contacts"; + +#[server] +pub async fn get_provider_contact(id: String) -> Result, ServerFnError> { + use aws_sdk_dynamodb::types::AttributeValue; + use aws_sdk_dynamodb::error::ProvideErrorMetadata; + + let client = create_dynamo_client().await; + + debug_log!("Getting contact info for provider {}", id); + + match client + .get_item() + .table_name(PROVIDER_CONTACT_TABLE) + .key("subject", AttributeValue::S(id)) + .send() + .await + { + Ok(output) => { + let Some(item) = output.item else { + return Ok(HashMap::new()); + }; + + let map = item.iter().map(|(key, val)| { + let val_type = ValueType::from(val); + + (key.clone(), val_type) + }).collect::>(); + + Ok(map) + } + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred."); + Err(ServerFnError::new(msg)) + } + } +} + +#[server(input = Json)] +pub async fn put_provider_contact(id: String, contact_info: HashMap) -> Result<(), ServerFnError> { + use aws_sdk_dynamodb::error::ProvideErrorMetadata; + + let client = create_dynamo_client().await; + + let mut item = into_attr_map(contact_info); + item.insert("subject".to_owned(), aws_sdk_dynamodb::types::AttributeValue::S(id)); + + match client + .put_item() + .table_name(PROVIDER_CONTACT_TABLE) + .set_item(Some(item)) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) => { + let msg = err.message().unwrap_or("An unknown error occurred."); + Err(ServerFnError::new(msg)) + } + } +} \ No newline at end of file diff --git a/src/pages/api/scholarships.rs b/src/pages/api/scholarships.rs index 5101af6..a7764de 100644 --- a/src/pages/api/scholarships.rs +++ b/src/pages/api/scholarships.rs @@ -1,27 +1,26 @@ #[cfg(feature = "ssr")] -use aws_sdk_dynamodb::{ - types::AttributeValue, - error::ProvideErrorMetadata, -}; -#[cfg(feature = "ssr")] -use leptos::leptos_dom::log; -#[cfg(feature = "ssr")] -use uuid::Uuid; -#[cfg(feature = "ssr")] -use crate::utils::server::{create_dynamo_client, into_attr_map}; -#[cfg(feature = "ssr")] -use crate::common::ValueType; +mod imports { + pub use leptos::logging::debug_log; + pub use aws_sdk_dynamodb::{ + types::AttributeValue, + error::ProvideErrorMetadata, + }; + pub use leptos::leptos_dom::log; + pub use uuid::Uuid; + pub use crate::utils::server::{create_dynamo_client}; + pub use crate::common::ValueType; + pub use super::super::SCHOLARSHIPS_TABLE; +} use crate::common::ExpandableInfo; - use leptos::prelude::ServerFnError; use leptos::server; - -#[cfg(feature = "ssr")] -static SCHOLARSHIPS_TABLE: &str = "leptos-scholarships"; +use leptos::server_fn::codec::Json; #[server(GetScholarshipInfo)] pub async fn get_scholarship_info(id: String) -> Result { + use imports::*; + let client = create_dynamo_client().await; // Perform the operation - we just want to return all data that's contained in this entry, @@ -50,8 +49,9 @@ pub async fn get_scholarship_info(id: String) -> Result Result<(), ServerFnError> { + use imports::*; use aws_sdk_dynamodb::error::ProvideErrorMetadata; let client = create_dynamo_client().await; @@ -75,8 +75,10 @@ pub async fn create_scholarship_info(info: ExpandableInfo) -> Result<(), ServerF #[server(GetAllScholarshipInfo)] pub async fn get_all_scholarship_info() -> Result, ServerFnError> { + use imports::*; + let client = create_dynamo_client().await; - log!("Getting all scholarship info"); + debug_log!("Getting all scholarship info"); match client .scan() .table_name(SCHOLARSHIPS_TABLE) @@ -100,9 +102,11 @@ pub async fn get_all_scholarship_info() -> Result, ServerFnE /// Gets all scholarships that are associated with the given scholarship provider's ID. #[server(GetProviderScholarships)] pub async fn get_provider_scholarships(provider_id: String) -> Result, ServerFnError> { + use imports::*; + let client = create_dynamo_client().await; - log!("Getting provider scholarships for provider with ID {:?}", provider_id); + debug_log!("Getting provider scholarships for provider with ID {:?}", provider_id); match client .scan() @@ -129,9 +133,11 @@ pub async fn get_provider_scholarships(provider_id: String) -> Result Result { + use imports::*; + let client = create_dynamo_client().await; - log!("Creating scholarship for provider with ID {:?}", provider_id); + debug_log!("Creating scholarship for provider with ID {:?}", provider_id); let mut current_uuid = Uuid::new_v4().to_string(); let mut item = ExpandableInfo::new(current_uuid.clone()); @@ -173,9 +179,11 @@ pub async fn register_scholarship(provider_id: String) -> Result Result<(), ServerFnError> { + use imports::*; + let client = create_dynamo_client().await; - log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id); + debug_log!("Deleting scholarship with ID {:?} for provider with ID {:?}", scholarship_id, provider_id); // When we delete a scholarship, we need to ensure that the provider's ID matches the scholarship, // otherwise everyone can delete anyone else's scholarships. @@ -197,3 +205,29 @@ pub async fn delete_provider_scholarship(provider_id: String, scholarship_id: St } } } + +#[server] +pub async fn get_all_scholarships() -> Result>, ServerFnError> { + use imports::*; + use std::collections::HashMap; + + let client = create_dynamo_client().await; + + client + .scan() + .table_name(SCHOLARSHIPS_TABLE) + .send() + .await + .map(|output| { + let Some(items) = output.items else { + return Vec::new(); + }; + + items.iter().map(|item| { + // Each item should be simply converted from an AttributeValue to a ValueType. + item.iter().map(|(k, v)| (k.clone(), ValueType::from(v))) + .collect::>() + }).collect::>() + }) + .map_err(ServerFnError::from) +} diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs new file mode 100644 index 0000000..f5d7eb1 --- /dev/null +++ b/src/pages/api/students.rs @@ -0,0 +1,114 @@ +use leptos::prelude::*; +use leptos::server_fn::codec::Json; + +#[cfg(feature = "ssr")] +mod imports { + pub use crate::utils::server::*; + pub use aws_sdk_dynamodb::types::AttributeValue; + pub use super::super::MAIN_TABLE_NAME; + pub use crate::common::ValueType; + pub use std::collections::HashMap; + pub use leptos::logging::debug_log; +} + +#[server(input = Json)] +pub async fn put_student_data( + subject: String, + data_type: String, + data_map: std::collections::HashMap +) -> Result<(), ServerFnError> { + use imports::*; + + let client = create_dynamo_client().await; + + let mut data_map_attr = into_attr_map(data_map); + data_map_attr.insert( + "HK".into(), + AttributeValue::S(format!("STUDENT#{}", subject)) + ); + data_map_attr.insert( + "SK".into(), + AttributeValue::S(data_type.to_uppercase()) + ); + + debug_log!("Inserting this item: {:?}", data_map_attr); + + client + .put_item() + .table_name(MAIN_TABLE_NAME) + .set_item(Some(data_map_attr)) + .send() + .await + .map(|_| ()) + .map_err(ServerFnError::from) +} + +#[server] +pub async fn get_student_data( + subject: String, + data_type: String, +) -> Result, ServerFnError> { + use imports::*; + + let client = create_dynamo_client().await; + + client + .query() + .table_name(MAIN_TABLE_NAME) + .key_condition_expression("HK = :hk AND SK = :sk") + .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject))) + .expression_attribute_values(":sk", AttributeValue::S(data_type.to_uppercase())) + .send() + .await + .map(|output| { + // Map the output to a HashMap + let Some(list) = output.items else { + return HashMap::new(); + }; + + let mut map = HashMap::new(); + + let _ = list + .into_iter() + .flatten() + .for_each(|(k, v)| { + map.insert(k, ValueType::from(&v)); + }); + + map + }) + .map_err(ServerFnError::from) +} + +/// Gets and flattens all of a student's information from the database, regardless of their defined +/// sort key. +/// +/// Note: if sorted data from the `put_student_data` function contains the same fields, this +/// function will have unexpected behavior. For example, if a `first_name` field appears in multiple +/// locations in the database, all for this single student, they will overwrite each other in no +/// specific order. +#[server] +pub async fn get_all_student_data( + subject: String +) -> Result, ServerFnError> { + use imports::*; + + let client = create_dynamo_client().await; + + client.query() + .table_name(MAIN_TABLE_NAME) + .key_condition_expression("HK = :hk") + .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{}", subject))) + .send() + .await + .map(|output| { + let Some(items) = output.items else { + return HashMap::new(); + }; + + items.iter().flatten() + .map(|(|k, v)| (k.clone(), ValueType::from(v))) + .collect::>() + }) + .map_err(ServerFnError::from) +} diff --git a/src/pages/comparison_test.rs b/src/pages/comparison_test.rs index 50dd46f..28ce397 100644 --- a/src/pages/comparison_test.rs +++ b/src/pages/comparison_test.rs @@ -88,7 +88,7 @@ pub fn ComparisonTestPage() -> impl IntoView { _ => None, }; let comparison_outcome = match comp_opt { - Some(comp) => comp.compare(&student), + Some(comp) => comp.compare(&student.data), _ => Err("".to_string()), }; match comparison_outcome { diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 194c44f..f336eed 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -12,7 +12,7 @@ use crate::common::ExpandableInfo; use crate::pages::UnauthenticatedPage; use crate::components::{ ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row, - Select, TextFieldType, ValidatedForm + TextFieldType, ValidatedForm }; use crate::input; use leptos::leptos_dom::logging::console_log; @@ -329,21 +329,9 @@ pub fn HomePage() -> impl IntoView { required=true /> - - + +
+
+ + } +} diff --git a/src/pages/student/eligibility_page.rs b/src/pages/student/eligibility_page.rs new file mode 100644 index 0000000..e472472 --- /dev/null +++ b/src/pages/student/eligibility_page.rs @@ -0,0 +1,172 @@ +use leptos::logging::debug_log; +use leptos::prelude::*; +use crate::common::{ComparisonData, ExpandableInfo, ValueType}; +use crate::pages::api::{get_all_scholarship_info, get_comparison_info}; +use crate::pages::api::students::get_all_student_data; +use crate::utils::get_user_claims; + +#[component] +pub fn StudentEligibilityPage() -> impl IntoView { + // This page will show all scholarships that the student is eligible for. + // The scholarships are obviously pulled using a readonly API, and we'll also need to resolve + // all of the relation IDs that are stored inside of it. + // Then, for each scholarship found, we'll check every relation and filter out scholarships that + // do not pass the check. We'll show some sort of simple list to indicate what scholarships passed + // the check, and another to indicate which ones didn't. + + // My only reservation with showing the scholarships that aren't valid is that people will want + // to know why. That's hard information to keep track of right now. + + let user_claims = get_user_claims(); + let user_id = Memo::new(move |_| { + user_claims.get() + .map(|info| info.claims.subject.clone()) + }); + + let refresh_trigger = Trigger::new(); + + let scholarships_resource = Resource::new( + move || refresh_trigger.track(), + async move |_| { + get_all_scholarship_info().await + } + ); + let relations_resource = Resource::new( + move || refresh_trigger.track(), + async move |_| { + get_comparison_info().await + } + ); + let student_resource = Resource::new( + move || (user_id.get().unwrap_or_default(), refresh_trigger.track()), + async move |(user_id, _)| { + get_all_student_data(user_id).await + } + ); + + view! { +
+
+ "Checking eligibility..."
} + }> + {move || { + let Some(Ok(scholarships_list)) = scholarships_resource.get() else { + return view! {}.into_any(); + }; + let Some(Ok(relations_list)) = relations_resource.get() else { + return view! {}.into_any(); + }; + let Some(Ok(student_info)) = student_resource.get() else { + return view! {}.into_any(); + }; + + let valid_list = scholarships_list.iter() + .filter_map(|scholarship| { + let scholarship_name = scholarship.data.get("name") + .unwrap_or(&ValueType::String(None)) + .as_string().ok().flatten().unwrap_or_default(); + debug_log!("Checking scholarship: {}", scholarship_name); + + // Each scholarship will return Some(itself), or None. This is will be automatically + // filtered based on the information. Invalid scholarships are None, along with those + // that fail validation for any reason. Scholarships that are valid or did not choose + // any requirements are Some(scholarship). + let requirements_map = scholarship.data.get("requirements") + .unwrap_or(&ValueType::Map(None)) + .as_map().ok().flatten() + .unwrap_or_default() + .values().cloned() + .collect::>(); + + let resolved_requirements = requirements_map + .iter() + .map(|list_val| { + // We want to cast to a list, and then map the list to a new + // one that contains the correct ComparisonData. + let list = list_val.as_list().ok().flatten().unwrap_or_default(); + list.iter().filter_map(|v| { + let id_string = v.as_string().ok().flatten().unwrap_or_default(); + relations_list.iter() + .find(|relation| relation.id == id_string) + }) + .cloned() + .collect::>() + }) + .collect::>>(); + + debug_log!("Resolved requirements: {:?}", resolved_requirements.iter().flatten().map(|rel| rel.display_text.clone()).collect::>()); + + let valid = resolved_requirements.iter() + .all(|list| { + list.iter().fold(false, |prev, requirement| { + let result = requirement.compare(&student_info) + .unwrap_or(false); + + if prev { prev } else { result } + }) + }); + + if valid { + debug_log!("Scholarship is valid."); + Some(scholarship.clone()) + } else { + None + } + }) + .collect::>(); + + view! { +
{format!("Valid scholarships: {}", valid_list.len())}
+ +
{scholarship_name}
+ +
{scholarship_essay_prompt}
+
+
+ } + } + /> + }.into_any() + }} + +
+
+ } +} diff --git a/src/pages/student/extracurriculars_page.rs b/src/pages/student/extracurriculars_page.rs new file mode 100644 index 0000000..b9ca521 --- /dev/null +++ b/src/pages/student/extracurriculars_page.rs @@ -0,0 +1,51 @@ +use leptos::prelude::*; +use crate::components::{Loading, MultiEntry, OutlinedTextField, TextFieldType, ValidatedForm}; +use crate::input; +use crate::pages::student::form_setup::use_student_form; + +#[component] +pub fn StudentExtracurricularsPage() -> impl IntoView { + let controller = use_student_form("demographics"); + + view! { + +
+
+ + // Inputs here. + + + +
+
+ + } +} \ No newline at end of file diff --git a/src/pages/student/family_info_page.rs b/src/pages/student/family_info_page.rs new file mode 100644 index 0000000..49926af --- /dev/null +++ b/src/pages/student/family_info_page.rs @@ -0,0 +1,105 @@ +use leptos::prelude::*; +use crate::components::{Loading, OutlinedTextField, TextFieldType, ValidatedForm}; +use crate::pages::student::form_setup::use_student_form; + +#[component] +pub fn StudentFamilyPage() -> impl IntoView { + let controller = use_student_form("family"); + + view! { + +
+
+ + // Inputs here. + + + + // TODO Could this be a select dropdown? + + // TODO Get a better example + + + + + // TODO Could this be a select dropdown? + + // TODO Get a better example + + + +
+
+ + } +} \ No newline at end of file diff --git a/src/pages/student/form_setup.rs b/src/pages/student/form_setup.rs new file mode 100644 index 0000000..db1f166 --- /dev/null +++ b/src/pages/student/form_setup.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; +use leptos::prelude::*; +use crate::common::ValueType; +use crate::pages::api::students::{get_student_data, PutStudentData}; +use crate::utils::get_user_claims; + +pub struct StudentFormInfo { + pub data_map: RwSignal>, + pub user_id: Memo>, + pub submit_action: Callback<()>, + pub refresh_trigger: Trigger, + pub data_resource: Resource, ServerFnError>> +} + +pub fn use_student_form( + form_type: impl Into> +) -> StudentFormInfo { + let form_type = form_type.into(); + + let user_claims = get_user_claims(); + let user_id = Memo::new(move |_| { + user_claims.get() + .map(|info| info.claims.subject.clone()) + }); + + let data_map = RwSignal::new(HashMap::new()); + + let refresh_trigger = Trigger::new(); + let data_resource = Resource::new( + move || (user_id.get().unwrap_or_default(), form_type.get(), refresh_trigger.track()), + async move |(id, form_type, _)| get_student_data( + id, + form_type, + ).await + ); + + Effect::new(move || { + data_resource.with(|map_opt| { + let Some(Ok(map)) = map_opt else { + return; + }; + + data_map.set(map.to_owned()); + }); + }); + + let submit_action = ServerAction::::new(); + let on_submit = move || { + submit_action.dispatch(PutStudentData { + subject: user_id.get().unwrap_or_default(), + data_type: form_type.get(), + data_map: data_map.get() + }); + }; + + StudentFormInfo { + data_map, + refresh_trigger, + data_resource, + user_id, + submit_action: on_submit.into(), + } +} \ No newline at end of file diff --git a/src/pages/student/home_page.rs b/src/pages/student/home_page.rs new file mode 100644 index 0000000..f370932 --- /dev/null +++ b/src/pages/student/home_page.rs @@ -0,0 +1,55 @@ +use std::collections::HashMap; +use leptos::logging::log; +use leptos::prelude::*; +use crate::common::ValueType; +use crate::components::ActionButton; +use crate::pages::api::students::{GetStudentData, PutStudentData}; +use crate::utils::get_user_claims; + +#[component] +pub fn StudentHomePage() -> impl IntoView { + let user_claims = get_user_claims(); + let user_id = Memo::new(move |_| { + user_claims.get() + .map(|info| info.claims.subject.clone()) + }); + + let create_student_data = ServerAction::::new(); + let get_student_data = ServerAction::::new(); + + let on_click_student_test = move |_| { + create_student_data.dispatch(PutStudentData { + subject: user_id.get().unwrap_or_default(), + data_type: "TESTING".into(), + data_map: HashMap::from([ + ("testing_info".into(), ValueType::String(Some("hello!".to_string()))) + ]), + }); + }; + + let on_click_get_student = move |_| { + get_student_data.dispatch(GetStudentData { + subject: user_id.get().unwrap_or_default(), + data_type: "TESTING".into() + }); + }; + + Effect::new(move || { + let Some(value) = get_student_data.value().get() else { + return; + }; + + log!("{:?}", value); + }); + + view! { +
+
"Student Home Page"
+
+ "This is just a placeholder for now. We'll show some redirect buttons." +
+ "Test Put Student Function" + "Test Get Student Function" +
+ } +} diff --git a/src/pages/student/mod.rs b/src/pages/student/mod.rs new file mode 100644 index 0000000..5c3e5fa --- /dev/null +++ b/src/pages/student/mod.rs @@ -0,0 +1,24 @@ +mod shell; +mod home_page; +mod demographics_page; +mod additional_page; +mod athletics_page; +mod academics_page; +mod extracurriculars_page; +mod work_experience_page; +mod university_page; +mod family_info_page; +mod form_setup; +mod eligibility_page; + +pub use shell::*; +pub use home_page::*; +pub use demographics_page::*; +pub use additional_page::*; +pub use athletics_page::*; +pub use academics_page::*; +pub use extracurriculars_page::*; +pub use work_experience_page::*; +pub use university_page::*; +pub use family_info_page::*; +pub use eligibility_page::*; \ No newline at end of file diff --git a/src/pages/student/shell.rs b/src/pages/student/shell.rs new file mode 100644 index 0000000..ba0c9d6 --- /dev/null +++ b/src/pages/student/shell.rs @@ -0,0 +1,50 @@ +use leptos::prelude::*; +use leptos_oidc::{AuthLoaded, Authenticated}; +use crate::common::{SubTabInfo, TabInfo}; +use crate::components::{Banner, Loading, TabSidebarList}; +use crate::components::login::StudentLoginContext; +use crate::pages::UnauthenticatedPage; + +#[component] +pub fn StudentShell() -> impl IntoView { + // Create login context + view! { + +
+ + +
+ + + + + +
+
+
+ } +} diff --git a/src/pages/student/university_page.rs b/src/pages/student/university_page.rs new file mode 100644 index 0000000..01c6962 --- /dev/null +++ b/src/pages/student/university_page.rs @@ -0,0 +1,72 @@ +use leptos::prelude::*; +use crate::components::{Loading, OutlinedTextField, Select, TextFieldType, ValidatedForm}; +use crate::pages::student::form_setup::use_student_form; + +#[component] +pub fn StudentUniversityPage() -> impl IntoView { + let controller = use_student_form("university"); + + // college name, state, city, zip + // major, study field, intended career, college_acceptance + + view! { + +
+
+ + // Inputs here. + + + // TODO Make this a select dropdown. + + +