Merge branch 'main' into feature/form-schema

This commit is contained in:
Lucas Vas authored and GitHub committed 2025-12-12 11:33:40 -05:00
commit dc239cdaba
42 files changed
+679 -43

No files matched your search

+6 -3
View File
@@ -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
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 20C7 17.2386 9.23858 15 12 15C14.7614 15 17 17.2386 17 20M7.2 20H16.8C17.9201 20 18.4802 20 18.908 19.782C19.2843 19.5903 19.5903 19.2843 19.782 18.908C20 18.4802 20 17.9201 20 16.8V7.2C20 6.0799 20 5.51984 19.782 5.09202C19.5903 4.71569 19.2843 4.40973 18.908 4.21799C18.4802 4 17.9201 4 16.8 4H7.2C6.0799 4 5.51984 4 5.09202 4.21799C4.71569 4.40973 4.40973 4.71569 4.21799 5.09202C4 5.51984 4 6.07989 4 7.2V16.8C4 17.9201 4 18.4802 4.21799 18.908C4.40973 19.2843 4.71569 19.5903 5.09202 19.782C5.51984 20 6.07989 20 7.2 20ZM14 10C14 11.1046 13.1046 12 12 12C10.8954 12 10 11.1046 10 10C10 8.89543 10.8954 8 12 8C13.1046 8 14 8.89543 14 10Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 968 B

+57 -17
View File
@@ -1,11 +1,11 @@
use leptos::logging::log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge}; use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge};
use leptos_router::{components::{Route, Router, Routes}, path}; use leptos_router::{components::{Route, Router, Routes}, path};
use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, TestPage};
use url::Url; use url::Url;
use crate::pages::{AboutPage, ComparisonTestPage, HomePage, ScholarshipInfoPage};
pub fn shell(options: LeptosOptions) -> impl IntoView { pub fn shell(options: LeptosOptions) -> impl IntoView {
view! { view! {
<!DOCTYPE html> <!DOCTYPE html>
@@ -29,12 +29,53 @@ 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,
@@ -57,18 +98,17 @@ 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 <main>
<Router> // TODO Create a 404 page
<main> <Routes fallback=|| "Page not found.".into_view()>
// TODO Create a 404 page <Route path=path!("") view=HomePage/>
<Routes fallback=|| "Page not found.".into_view()> <Route path=path!("about") view=AboutPage/>
<Route path=path!("") view=HomePage /> <Route path=path!("test_page") view=TestPage/>
<Route path=path!("about") view=AboutPage /> <Route path=path!("comparison") view=ComparisonTestPage /
<Route path=path!("comparison") view=ComparisonTestPage /> <Route path=path!("loaners") view=LoanerPage />
<Route path=path!("scholarship/:id") view=ScholarshipInfoPage /> <Route path=path!("loaners/:form_name") view=LoanerPage />
</Routes> </Routes>
</main> </main>
</Router>
} }
} }
+48
View File
@@ -0,0 +1,48 @@
use leptos::prelude::*;
use leptos_router::hooks::use_navigate;
/// # Banner Component
///
/// This component displays a red banner at the top of the page with a title and a logo.
/// Clicking the logo will redirect a user to specified page, preferably a home page.
///
/// Example usage:
/// ```
/// <Banner
/// title="Test Page"
/// logo="medal.png"
/// path="/"
/// />
/// ```
#[component]
pub fn Banner(
/// The title of the banner.
#[prop(into)]
title: String,
/// The file path or URL of a logo displayed at the top left of the banner.
#[prop(into)]
logo: String,
/// The route that the navigates user to a page (home page).
#[prop(into, default = String::from("/"))]
path: String,
) -> impl IntoView {
let navigate = use_navigate();
let on_click = move |_| navigate(&path, Default::default());
view! {
<div class="mx-auto">
<div class="bg-red-900 shadow-lg shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.3),0_4px_6px_-1px_rgba(127,29,29,0.3),0_2px_4px_-1px_rgba(127,29,29,0.2)]">
<div class="flex items-center gap-4 p-6">
<div class="flex-shrink-0">
<img src={logo.clone()} class="h-10 w-10 cursor-pointer" alt="icon" on:click=on_click/>
</div>
<div class="flex-1">
<h2 class="text-white text-lg font-bold">{title.clone()}</h2>
</div>
</div>
</div>
</div>
}
}
+8 -2
View File
@@ -1,4 +1,5 @@
use crate::common::ValueType; use crate::common::ValueType;
use crate::components::utils::create_unique_id;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
@@ -7,10 +8,13 @@ pub fn Checkbox(
#[prop()] checked: Signal<bool>, #[prop()] checked: Signal<bool>,
#[prop(into)] on_change: Callback<(), ()>, #[prop(into)] on_change: Callback<(), ()>,
#[prop(into)] value: String, #[prop(into)] value: String,
#[prop(into)] name: String,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, #[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
) -> impl IntoView { ) -> impl IntoView {
let id = create_unique_id(&name, &value);
view! { view! {
<label for=value class="flex items-center"> <label for=id class="flex items-center">
<input <input
// Using the "peer" class links this input with the div "peer" at the same level. // Using the "peer" class links this input with the div "peer" at the same level.
// By using the "peer-checked" selector this div can toggle states based on this // By using the "peer-checked" selector this div can toggle states based on this
@@ -19,7 +23,8 @@ pub fn Checkbox(
relative peer shrink-0 relative peer shrink-0
hidden" hidden"
type="checkbox" type="checkbox"
id=value.clone() id=id.clone()
name=name.clone()
on:change=move |_| on_change.run(()) on:change=move |_| on_change.run(())
prop:checked=checked prop:checked=checked
disabled=disabled disabled=disabled
@@ -109,6 +114,7 @@ pub fn CheckboxList(
on_change=on_change on_change=on_change
// The actual selected values are tracked by this element, not by the checkboxes themselves. // The actual selected values are tracked by this element, not by the checkboxes themselves.
value=item.clone() value=item.clone()
name=data_member.clone()
disabled=disabled disabled=disabled
/> />
} }
+4 -6
View File
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use leptos::prelude::*;
use crate::common::ValueType; use crate::common::ValueType;
use crate::components::utils::create_unique_id;
use leptos::prelude::*;
use std::collections::HashMap;
/// # Chip Component /// # Chip Component
/// ///
@@ -31,10 +32,7 @@ pub fn Chip(
) -> impl IntoView { ) -> impl IntoView {
// Generate a unique id - ensure that the value doesn't contain spaces. // Generate a unique id - ensure that the value doesn't contain spaces.
// Without this id, checkbox/radio inputs can interfere with each other. // Without this id, checkbox/radio inputs can interfere with each other.
let value_no_spaces = value.clone().chars() let id = create_unique_id(&name, &value);
.filter(|c| !c.is_whitespace())
.collect::<String>();
let id = format!("{}-{}", name.clone(), value_no_spaces);
view! { view! {
<label for=id> <label for=id>
+61
View File
@@ -0,0 +1,61 @@
use leptos::prelude::*;
use leptos_router::hooks::use_navigate;
/// # Dashboard Button Component
///
/// This component displays a large panel-like button that redirects a user to a specified page.
/// It's best used on a user dashboard, where multiple instances of the button are present.
/// It has a title, a description, and icon
///
/// Example usage:
/// ```
/// <div class="p-6 space-y-4 max-w-1/3">
/// <DashboardButton
/// title="Home Page"
/// description="Navigate to Home Page"
/// icon="square-user.svg"
/// path="/"
/// />
/// </div>
/// ```
#[component]
pub fn DashboardButton(
/// The main label displayed on the button.
#[prop(into)]
title: String,
/// A brief explanatory text that describes the destination page.
#[prop(into)]
description: String,
/// The file path or URL of the icon displayed at the top of the button.
#[prop(into)]
icon: String,
/// The route that the user is navigated to when clicking the button.
#[prop(into)]
path: String,
) -> impl IntoView {
let navigate = use_navigate();
let on_click = move |_| navigate(&path, Default::default());
view! {
<button
type="button"
class="dashboard-button flex items-start gap-3 rounded-lg bg-white border-grey-300 p-6
hover:bg-gray-100 transition cursor-pointer w-full text-left
shadow-[inset_0_0_6px_rgba(0,0,0,0.12)]"
on:click=on_click>
<div class="flex flex-col">
<img src={icon.clone()} class="h-8 w-8" alt="icon"/>
<h3 class="font-semibold text-base">
{title.clone()}
</h3>
<p class="text-sm text-gray-600 pt-6">
{description.clone()}
</p>
</div>
</button>
}
}
+6 -3
View File
@@ -1,5 +1,8 @@
mod action_button; mod action_button;
mod banner;
mod checkbox; mod checkbox;
mod chips;
mod dashboard_button;
mod loading; mod loading;
mod multi_entry; mod multi_entry;
mod panel; mod panel;
@@ -7,9 +10,9 @@ mod radio;
mod row; mod row;
mod select; mod select;
mod text_field; mod text_field;
mod chips; mod utils;
pub use self::{ pub use self::{
action_button::*, checkbox::*, loading::*, multi_entry::*, panel::*, radio::*, row::*, action_button::*, banner::*, checkbox::*, chips::*, dashboard_button::*, loading::*,
select::*, text_field::*, chips::* multi_entry::*, panel::*, radio::*, row::*, select::*, text_field::*,
}; };
+9 -3
View File
@@ -1,4 +1,5 @@
use crate::common::ValueType; use crate::common::ValueType;
use crate::components::utils::create_unique_id;
use leptos::prelude::*; use leptos::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
@@ -7,10 +8,13 @@ pub fn Radio(
#[prop()] checked: Signal<bool>, #[prop()] checked: Signal<bool>,
#[prop(into)] on_change: Callback<(), ()>, #[prop(into)] on_change: Callback<(), ()>,
#[prop(into)] value: String, #[prop(into)] value: String,
#[prop(into)] name: String,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>, #[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
) -> impl IntoView { ) -> impl IntoView {
let id = create_unique_id(&name, &value);
view! { view! {
<label for=value class="flex items-center"> <label for=id class="flex items-center">
<input <input
// Using the "peer" class links this input with the div "peer" at the same level. // Using the "peer" class links this input with the div "peer" at the same level.
// By using the "peer-checked" selector this div can toggle states based on this // By using the "peer-checked" selector this div can toggle states based on this
@@ -18,8 +22,9 @@ pub fn Radio(
class=" class="
relative peer shrink-0 relative peer shrink-0
hidden" hidden"
type="checkbox" type="radio"
id=value.clone() id=id.clone()
name=name.clone()
on:change=move |_| on_change.run(()) on:change=move |_| on_change.run(())
prop:checked=checked prop:checked=checked
disabled=disabled disabled=disabled
@@ -96,6 +101,7 @@ pub fn RadioList(
on_change=on_change on_change=on_change
// The actual selected values are tracked by this element, not by the checkboxes themselves. // The actual selected values are tracked by this element, not by the checkboxes themselves.
value=item.clone() value=item.clone()
name=data_member.clone()
disabled=disabled disabled=disabled
/> />
} }
+10
View File
@@ -0,0 +1,10 @@
/// Creates a unique ID for use in components like the [`CheckboxList`],
/// [`RadioList`], and [`ChipsList`].
pub fn create_unique_id(name: &String, value: &String) -> String {
let value_no_spaces = value
.clone()
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>();
format!("{}-{}", name.clone(), value_no_spaces)
}
+422
View File
@@ -0,0 +1,422 @@
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue};
#[cfg(feature = "ssr")]
use serde_dynamo::{from_item, to_item};
use leptos::either::{Either, EitherOf3};
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use leptos::logging::log;
use leptos_router::params::Params;
use leptos_router::hooks::{use_navigate, use_params};
use leptos::Params;
use traits::{AsReactive, ReactiveCapture};
use crate::common::{ExpandableInfo, ValueType};
use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select};
use std::collections::HashMap;
use chrono::{FixedOffset, TimeZone};
use leptos::html::Dialog;
#[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
View File
@@ -1,9 +1,15 @@
mod about_page; mod about_page;
mod comparison_test; mod comparison_test;
mod home_page; mod home_page;
mod provider_portal;
mod test_page;
mod unauthenticated_page; mod unauthenticated_page;
mod scholarship_info; mod scholarship_info;
mod utils; mod utils;
mod api; mod api;
mod loaner_page;
pub use self::{about_page::*, comparison_test::*, home_page::*, unauthenticated_page::*, scholarship_info::*}; pub use self::{
about_page::*, home_page::*, provider_portal::*, test_page::*, unauthenticated_page::*,
loaner_page::LoanerPage, comparison_test::*,
};
+1
View File
@@ -0,0 +1 @@
+28
View File
@@ -0,0 +1,28 @@
use crate::components::DashboardButton;
use leptos::prelude::*;
#[component]
pub fn TestPage() -> impl IntoView {
view! {
<div class="p-6 space-y-4 max-w-1/3">
<DashboardButton
title="Home Page"
description="Navigate to Home Page"
icon="Book.png"
path="/"
/>
<DashboardButton
title="Home Page"
description="Navigate to Home Page"
icon="Calendar.png"
path="/"
/>
<DashboardButton
title="Home Page"
description="Navigate to Home Page"
icon="Edit.png"
path="/"
/>
</div>
}
}
+3
View File
@@ -1,3 +1,4 @@
use leptos::logging::log;
use crate::components::ActionButton; use crate::components::ActionButton;
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! (