feat: create scholarship list skeleton

Next up is linking the scholarship IDs that are contained in the link parameters to the form that's displayed upon clicking "Edit".
This commit is contained in:
alphafetus committed 2025-12-18 14:29:27 -05:00
1 parent 620cc514d3
commit a0049d4797
8 files changed
+268 -38

No files matched your search

+11 -4
View File
@@ -3,7 +3,8 @@ use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_oidc::{Auth, AuthParameters, AuthSignal, Challenge};
use leptos_router::{components::{Route, Router, Routes}, path};
use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, TestPage};
use leptos_router::components::ParentRoute;
use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, ScholarshipInfoPage, TestPage};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
@@ -96,7 +97,7 @@ pub fn AppWithRoutes() -> impl IntoView {
// sets the document title
<Title text="R15 Scholarship App DEV" />
<main>
// TODO Create a 404 page
<Routes fallback=|| "Page not found.".into_view()>
@@ -104,8 +105,14 @@ pub fn AppWithRoutes() -> impl IntoView {
<Route path=path!("about") view=AboutPage/>
<Route path=path!("test_page") view=TestPage/>
<Route path=path!("comparison") view=ComparisonTestPage />
<Route path=path!("loaners") view=LoanerPage />
<Route path=path!("loaners/:form_name") view=LoanerPage />
<ParentRoute path=path!("providers/scholarships") view=ScholarshipInfoPage>
<Route path=path!(":id") view=ScholarshipInfoPage />
<Route path=path!("") view=ScholarshipInfoPage />
</ParentRoute>
<ParentRoute path=path!("loaners") view=LoanerPage>
<Route path=path!("") view=LoanerPage />
<Route path=path!(":form_name") view=LoanerPage />
</ParentRoute>
</Routes>
</main>
}
+1 -1
View File
@@ -4,7 +4,7 @@ use leptos::prelude::*;
pub fn Loading() -> impl IntoView {
view! {
<div>
<img src="loading.gif" />
<img src="/loading.gif" />
</div>
}
}
+76 -1
View File
@@ -7,7 +7,8 @@ use aws_sdk_dynamodb::{
use leptos::leptos_dom::log;
use leptos::prelude::ServerFnError;
use leptos::server;
use crate::common::ExpandableInfo;
use uuid::Uuid;
use crate::common::{ExpandableInfo, ValueType};
#[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client;
@@ -89,3 +90,77 @@ pub async fn get_all_scholarship_info() -> Result<Vec<ExpandableInfo>, ServerFnE
}
}
}
/// Gets all scholarships that are associated with the given scholarship provider's ID.
#[server(GetProviderScholarships, "/providers/scholarships/get")]
pub async fn get_provider_scholarships(provider_id: String) -> Result<Vec<ExpandableInfo>, ServerFnError> {
let client = create_dynamo_client().await;
log!("Getting provider scholarships for provider with ID {:?}", provider_id);
match client
.scan()
.table_name("leptos-scholarship-test")
.expression_attribute_values(":id", serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?)
.filter_expression("provider_id = :id")
.send()
.await
{
Ok(output) => {
if let Some(items) = output.items {
Ok(serde_dynamo::from_items(items)?)
} else {
Ok(Vec::new())
}
}
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
Err(ServerFnError::new(msg))
}
}
}
/// Creates a new scholarship with a unique ID, and then returns that ID.
#[server(RegisterScholarship, "/providers/scholarships/create")]
pub async fn register_scholarship(provider_id: String, scholarship_name: String) -> Result<String, ServerFnError> {
let client = create_dynamo_client().await;
log!("Creating scholarship for provider with ID {:?}", provider_id);
let mut current_uuid = "testing".to_string();
let mut item = ExpandableInfo::new(current_uuid.clone());
item.data.insert("provider_id".to_string(), ValueType::String(Some(provider_id)));
item.data.insert("scholarship_name".to_string(), ValueType::String(Some(scholarship_name)));
loop {
let ser_item = serde_dynamo::to_item(&item)?;
match client
.put_item()
.table_name("leptos-scholarship-test")
.set_item(Some(ser_item))
.condition_expression("attribute_not_exists(subject)")
.send()
.await
{
Ok(_) => {
// Return the uuid that we used.
return Ok(current_uuid);
}
Err(err) => {
// Check the error to figure out if it was a key validation error.
// If so, retry with new values.
match err.code() {
Some("ConditionalCheckFailedException") => {
log!("Retrying conditional check.");
log!("Current error: {:?}", err);
current_uuid = Uuid::new_v4().to_string();
item.subject = current_uuid.clone();
continue
}
_ => {
return Err(ServerFnError::new(err.message().unwrap_or("An unknown error occurred")));
}
}
}
}
}
}
+4 -10
View File
@@ -2,26 +2,20 @@ use leptos::either::Either;
use super::home_page::get_submission;
use super::{UnauthenticatedPage};
use crate::common::{
ComparisonData, ExpandableInfo, UserClaims, ValueType,
ComparisonData, ExpandableInfo, ValueType,
};
use crate::components::{ActionButton, Loading, Panel, Row};
use leptos::leptos_dom::logging::console_log;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
use leptos_oidc::{AuthLoaded, Authenticated};
use crate::pages::utils::get_user_claims;
use super::api::{get_comparison_info, get_all_scholarship_info};
#[component]
pub fn ComparisonTestPage() -> impl IntoView {
let result_msg = RwSignal::new("Found submission, waiting for input.".to_string());
let auth = use_context::<AuthSignal>().expect("Couldn't find user information.");
let user_claims = Signal::derive(move || {
auth.with(|auth| {
auth.authenticated().and_then(|data| {
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
})
})
});
let user_claims = get_user_claims();
// Note that the value passed in MUST be equatable.
// We get/unwrap the value repeatedly until we get a simple string value, then clone it so that
+4 -11
View File
@@ -9,7 +9,7 @@ use std::process::{Command, Stdio};
#[cfg(feature = "ssr")]
use std::io::Write;
use crate::common::{ExpandableInfo, UserClaims};
use crate::common::ExpandableInfo;
use crate::pages::UnauthenticatedPage;
use crate::components::{
ActionButton, CheckboxList, Loading, MultiEntry, OutlinedTextField, Panel, RadioList, Row,
@@ -18,12 +18,13 @@ use crate::components::{
use crate::input;
use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*;
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
use leptos_oidc::{AuthLoaded, Authenticated};
use base64::Engine;
use leptos::task::spawn_local;
use leptos::web_sys::HtmlAnchorElement;
use leptos::wasm_bindgen::JsCast;
use traits::{AsReactive, ReactiveCapture};
use crate::pages::utils::get_user_claims;
/// # Get Student Info
/// Gets a student's information given their `subject`.
@@ -146,15 +147,7 @@ pub async fn create_example_pdf() -> Result<Vec<u8>, ServerFnError> {
#[component]
pub fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button
let auth = use_context::<AuthSignal>().expect("Couldn't find user information.");
let user_claims = Signal::derive(move || {
auth.with(|auth| {
auth.authenticated().and_then(|data| {
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
})
})
});
let user_claims = get_user_claims();
// Note that the value passed in MUST be equatable.
// We get/unwrap the value repeatedly until we get a simple string value, then clone it so that
+1 -1
View File
@@ -11,5 +11,5 @@ mod loaner_page;
pub use self::{
about_page::*, home_page::*, provider_portal::*, test_page::*, unauthenticated_page::*,
loaner_page::LoanerPage, comparison_test::*,
loaner_page::*, comparison_test::*, scholarship_info::*,
};
+154 -10
View File
@@ -1,13 +1,15 @@
use leptos::either::Either;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::use_params;
use crate::common::{ExpandableInfo, ScholarshipFormParams};
use crate::components::{ActionButton, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row};
use leptos_router::hooks::{use_navigate, use_params};
use crate::common::{ExpandableInfo, ScholarshipFormParams, ValueType};
use crate::components::{ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row};
use super::UnauthenticatedPage;
use traits::{AsReactive, ReactiveCapture};
use crate::common::ComparisonData;
use super::api::{get_comparison_info, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons};
use crate::pages::utils::get_user_claims;
use super::api::{get_comparison_info, get_provider_scholarships, get_scholarship_info, CreateScholarshipInfo, CreateTestComparisons, RegisterScholarship};
/// # Scholarship Info Page
@@ -15,12 +17,8 @@ use super::api::{get_comparison_info, get_scholarship_info, CreateScholarshipInf
/// scholarship ID and scholarship provider subject number. The form itself will only
/// track a single scholarship (for now) and so selection of what scholarship will be edited
/// must be handled *before* navigation to this page.
///
/// In its current testing form, we'll be using a single fixed ID. This absolutely *MUST* be
/// changed before a full release.
#[component]
pub fn ScholarshipInfoPage() -> impl IntoView {
// let scholarship_id = String::from("test-scholarship-id2");
let url_params = use_params::<ScholarshipFormParams>();
let scholarship_id = move || {
url_params.read()
@@ -61,11 +59,17 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
// Remember that we only want to show the page after we've got authentication information
// and if there's data for us to use.
view! {
<Banner
title="R15 Scholarships"
logo="/PHS_Stacked_Acronym.png"
path="/"
/>
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<Suspense fallback=Loading>
<Row>
<div class="flex flex-row align-top items-start">
<div class="flex flex-col flex-1" />
<ScholarshipList />
{move || Suspend::new(async move {
let response = get_scholarship_action.get();
let comparisons = get_comparison_info.get();
@@ -91,13 +95,153 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
</Row>
</div>
<div class="flex flex-col flex-1" />
</Row>
</div>
</Suspense>
</Authenticated>
</AuthLoaded>
}
}
#[component]
fn ScholarshipList() -> impl IntoView {
// Get scholarship provider information from authentication. This element will not display
// unless that information is available.
let user_claims = get_user_claims();
let provider_id = Memo::new(move |_|
user_claims.get()
.as_ref()
.map(|info| info.claims.subject.clone())
);
// Register scholarship server resource
let scholarships = Resource::new(
move || provider_id,
async move |provider_id| {
let Some(provider_id) = provider_id.get() else {
return Ok(Vec::new());
};
// Get the scholarships
get_provider_scholarships(provider_id).await
}
);
// Register scholarship creation action
let create_action = ServerAction::<RegisterScholarship>::new();
// Button handlers
let create_on_click = move |_| {
// In the future, we'd actually like to create/show a dialog that gets the
// desired name of the scholarship.
// After the request is sent, we'll handle it in an effect.
create_action.dispatch(RegisterScholarship {
provider_id: provider_id.get().unwrap(),
scholarship_name: "Testing Scholarship".to_string()
});
};
// Register scholarship creation effect
Effect::new(move || {
// We want to wait for the action to complete first, but then find whether the request
// succeeded.
if let Some(Ok(_)) = create_action.value().get() {
// Refetch and clear.
scholarships.refetch();
create_action.clear();
}
});
view! {
<div class="w-75">
<Panel>
<div class="pt-2">
<h2 class="text-xl font-bold text-center">"Scholarships"</h2>
</div>
<Transition fallback=Loading>
<div class="flex flex-col gap-2">
{move || {
scholarships.get().map(|result| {
match result {
Ok(list) => {
let views = list.iter().map(|entry| {
view! { <ScholarshipListEntry scholarship=entry.clone() /> }
});
Either::Left(views.collect_view())
}
Err(err) => {
Either::Right(view! {
<div>
<p>"Failed to load scholarship list: "{err.to_string()}</p>
</div>
})
}
}
}).collect_view()
}}
</div>
<div>
<div class="p-2 bg-red-700 rounded-md text-center text-white hover:bg-red-800 transition-all"
on:click=create_on_click>
"Create New"
</div>
</div>
</Transition>
</Panel>
</div>
}
}
#[component]
fn ScholarshipListEntry(scholarship: ExpandableInfo) -> impl IntoView {
let navigate = use_navigate();
// Get the values that we need out of the info. We just need the subject and the name of
// the scholarship.
let name = scholarship.data.get("scholarship_name")
.unwrap_or(&ValueType::String(None))
.as_string().unwrap_or_default()
.unwrap_or("<no name found>".to_string());
// Button click handlers
let edit_click = {
let subject = scholarship.subject.clone();
move |_| {
let path = format!("/providers/scholarships/{}", subject);
navigate(path.as_str(), Default::default());
}
};
let delete_click = {
let subject = scholarship.subject.clone();
move |_| {
// Delete the scholarship. For now, we'll just log it.
log!("Deleting scholarship with ID {:?}", subject);
}
};
view! {
<div class="flex flex-col rounded-md border-light inset-shadow-xs shadow-md my-2 transition-all hover:shadow-lg/33">
<div class="p-2 text-center">
<span>{name}</span>
</div>
<div class="flex flex-row">
<div class="p-2 flex-1 bg-red-700 border-r border-white rounded-bl-md text-white text-center
hover:bg-red-800 cursor-pointer transition-all"
on:click=edit_click
>
"Edit"
</div>
<div class="p-2 flex-1 bg-red-700 border-l border-white rounded-br-md text-white text-center
hover:bg-red-800 cursor-pointer transition-all"
on:click=delete_click
>
"Delete"
</div>
</div>
</div>
}
}
#[component]
fn ScholarshipForm(
response: ExpandableInfo,
+17
View File
@@ -1,3 +1,7 @@
use leptos::prelude::{use_context, Signal, With};
use leptos_oidc::{Algorithm, AuthSignal, TokenData};
use crate::common::UserClaims;
/// # Server Utilities
///
/// These utility functions can only be used from the server. They are created to use server-side
@@ -17,3 +21,16 @@ pub mod server_utils {
aws_sdk_dynamodb::Client::new(&create_aws_config().await)
}
}
/// Gets the current user claims. This function should only be used in an area that has
/// access to an AuthSignal, or it will result in a total failure.
pub fn get_user_claims() -> Signal<Option<TokenData<UserClaims>>> {
let auth = use_context::<AuthSignal>().expect("Couldn't find AuthSignal.");
Signal::derive(move || {
auth.with(|auth| {
auth.authenticated().and_then(|data| {
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
})
})
})
}