Add date list component #50

Merged
BobdaFett merged 37 commits from feat/date-component into main 2026-01-16 14:02:18 +00:00
19 changed files with 798 additions and 274 deletions
Showing only changes of commit f401e994a4 - Show all commits

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
name: Docker build test
on:
pull_request:
branches:
- "main"
- "develop"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build image
run: |
docker build .
@@ -0,0 +1,33 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: read
packages: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push runner image
uses: docker/build-push-action@v6
with:
push: true
tags: |
ghcr.io/r15-software-dev/scholarships-runner:latest
ghcr.io/r15-software-dev/scholarships-runner:${{ github.ref_name }}
+2 -3
View File
@@ -1,6 +1,5 @@
services:
scholarships-rs:
build: .
image: scholarships-rs-runner:latest
image: ghcr.io/r15-software-dev/scholarships-runner:latest
ports:
- '8080:8080'
- '3000:8080'
+18 -9
View File
@@ -1,10 +1,13 @@
use crate::pages::{AboutPage, ComparisonTestPage, HomePage, LoanerPage, ProviderPortal, TestPage};
use leptos::logging::log;
use crate::pages::{
AboutPage, ComparisonTestPage, HomePage, LoanerPage, ProviderPortal, ScholarshipInfoPage,
TestPage,
};
use leptos::leptos_dom::log;
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},
components::{ParentRoute, Route, Router, Routes},
path,
};
@@ -106,12 +109,18 @@ pub fn AppWithRoutes() -> impl IntoView {
// TODO Create a 404 page
<Routes fallback=|| "Page not found.".into_view()>
<Route path=path!("") view=HomePage/>
<Route path=path!("about") view=AboutPage/>
<Route path=path!("test_page") view=TestPage/>
<Route path=path!("comparison") view=ComparisonTestPage />
<Route path=path!("providers") view=ProviderPortal />
<Route path=path!("loaners") view=LoanerPage />
<Route path=path!("loaners/:form_name") view=LoanerPage />
<Route path=path!("/about") view=AboutPage/>
<Route path=path!("/test_page") view=TestPage/>
<Route path=path!("/comparison") view=ComparisonTestPage />
<Route path=path!("/providers") view=ProviderPortal/>
<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>
}
+3 -2
View File
@@ -3,10 +3,11 @@ mod date_info;
mod expandable_info;
mod form_params;
mod form_schema;
mod submit_status;
mod user;
mod value_type;
pub use self::{
comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*, user::*,
value_type::*,
comparison::*, date_info::*, expandable_info::*, form_params::*, form_schema::*,
submit_status::*, user::*, value_type::*,
};
+7
View File
@@ -0,0 +1,7 @@
#[derive(Debug, Clone)]
pub enum SubmitStatus {
Idle,
Sending,
Success,
Error(String),
}
+2 -2
View File
@@ -2,8 +2,8 @@ use leptos::prelude::*;
#[component]
pub fn ActionButton(
#[prop(optional, default = String::from("button"))] button_type: String,
#[prop(optional, default = RwSignal::new(false))] disabled: RwSignal<bool>,
#[prop(default = String::from("button"))] button_type: String,
#[prop(optional, into)] disabled: Signal<bool>,
children: Children,
) -> impl IntoView {
view! {
+4 -4
View File
@@ -70,8 +70,8 @@ pub fn Chip(
pub fn ChipsList(
#[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop()] displayed_text: Vec<String>,
#[prop()] values: Vec<String>,
#[prop(into)] displayed_text: Signal<Vec<String>>,
#[prop(into)] values: Signal<Vec<String>>,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
#[prop(optional, into)] label: String,
) -> impl IntoView {
@@ -79,7 +79,7 @@ pub fn ChipsList(
<div class="m-1.5 mt-0 mb-0">
<span class="font-bold">{label}</span>
<div class="flex flex-row flex-wrap gap-2 mt-1">
{displayed_text.into_iter().zip(values)
{move || { displayed_text.get().into_iter().zip(values.get())
.into_iter()
.map(|(text, value)| {
let checked_signal = Signal::derive({
@@ -140,7 +140,7 @@ pub fn ChipsList(
}
})
.collect_view()
}
}}
</div>
</div>
}
+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>
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ pub fn Radio(
#[prop(into)] on_change: Callback<(), ()>,
#[prop(into)] value: String,
#[prop(into)] name: String,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
#[prop(optional, into)] disabled: Signal<bool>,
) -> impl IntoView {
let id = create_unique_id(&name, &value);
@@ -53,7 +53,7 @@ pub fn RadioList(
#[prop(into)] data_member: String,
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop()] items: Vec<String>,
#[prop(default = RwSignal::new(false))] disabled: RwSignal<bool>,
#[prop(optional, into)] disabled: Signal<bool>,
#[prop(optional, into)] label: String,
) -> impl IntoView {
view! {
+1 -1
View File
@@ -11,7 +11,7 @@ pub fn OutlinedTextField(
#[prop()] data_map: RwSignal<HashMap<String, ValueType>>,
#[prop(optional)] value: RwSignal<ValueType>,
#[prop(default = "text".to_owned(), into)] input_type: String,
#[prop(optional)] disabled: RwSignal<bool>,
#[prop(into, optional)] disabled: Signal<bool>,
#[prop(optional)] error: RwSignal<bool>,
#[prop(optional, into)] name: String,
#[prop(optional, into)] label: String,
+138 -3
View File
@@ -1,10 +1,11 @@
#[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue};
use crate::common::ExpandableInfo;
use crate::common::{ExpandableInfo, ValueType};
use leptos::leptos_dom::log;
use leptos::prelude::ServerFnError;
use leptos::server;
use uuid::Uuid;
#[cfg(feature = "ssr")]
use crate::pages::utils::server_utils::create_dynamo_client;
@@ -28,8 +29,10 @@ pub async fn get_scholarship_info(id: String) -> Result<ExpandableInfo, ServerFn
log!("Found output from API: {:?}", item);
Ok(serde_dynamo::from_item(item)?)
} else {
log!("Couldn't find any values, returning default struct.");
Ok(ExpandableInfo::new(id.clone()))
log!("Couldn't find scholarship with ID {:?}", id);
Err(ServerFnError::new(
"Couldn't find scholarship with given ID.",
))
}
}
Err(err) => {
@@ -89,3 +92,135 @@ 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"),
));
}
}
}
}
}
}
/// Deletes a provider's scholarship given their provider ID and scholarship ID.
#[server(DeleteProviderScholarship, "/providers/scholarships/delete")]
pub async fn delete_provider_scholarship(
provider_id: String,
scholarship_id: String,
) -> Result<(), ServerFnError> {
let client = create_dynamo_client().await;
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.
match client
.delete_item()
.table_name("leptos-scholarship-test")
.key("subject", AttributeValue::S(scholarship_id))
.expression_attribute_values(
":provider_id",
serde_dynamo::to_attribute_value(ValueType::String(Some(provider_id)))?,
)
.condition_expression("provider_id = :provider_id")
.send()
.await
{
Ok(_) => Ok(()),
Err(err) => {
let msg = err.message().unwrap_or("An unknown error occurred");
Err(ServerFnError::new(msg))
}
}
}
+4 -10
View File
@@ -1,25 +1,19 @@
use super::UnauthenticatedPage;
use super::api::{get_all_scholarship_info, get_comparison_info};
use super::home_page::get_submission;
use crate::common::{ComparisonData, ExpandableInfo, UserClaims, ValueType};
use crate::common::{ComparisonData, ExpandableInfo, ValueType};
use crate::components::{ActionButton, Loading, Panel, Row};
use crate::pages::utils::get_user_claims;
use leptos::either::Either;
use leptos::leptos_dom::logging::console_log;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
use leptos_oidc::{AuthLoaded, Authenticated};
#[component]
pub fn ComparisonTestPage() -> impl IntoView {
let result_msg = RwSignal::new("Found submission, waiting for input.".to_string());
let auth = use_context::<AuthSignal>().expect("Couldn't find user information.");
let user_claims = Signal::derive(move || {
auth.with(|auth| {
auth.authenticated().and_then(|data| {
data.decoded_access_token::<UserClaims>(Algorithm::RS256, &["account"])
})
})
});
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
+3 -10
View File
@@ -16,13 +16,14 @@ use crate::components::{
};
use crate::input;
use crate::pages::UnauthenticatedPage;
use crate::pages::utils::get_user_claims;
use base64::Engine;
use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos::wasm_bindgen::JsCast;
use leptos::web_sys::HtmlAnchorElement;
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
use leptos_oidc::{AuthLoaded, Authenticated};
use traits::{AsReactive, ReactiveCapture};
/// # Get Student Info
@@ -144,15 +145,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
+6 -12
View File
@@ -327,6 +327,8 @@ pub fn LoanerBorrowForm() -> impl IntoView {
#[component]
pub fn LoanerReturnForm() -> impl IntoView {
let navigate = use_navigate();
// Register server actions
let check_in_action = ServerAction::<CheckInLoaner>::new();
@@ -344,26 +346,18 @@ pub fn LoanerReturnForm() -> impl IntoView {
let dialog_ref = NodeRef::<Dialog>::new();
let chosen_student = RwSignal::new(None::<LoanerReturnOutput>);
let dialog_disabled = RwSignal::new(false);
let dialog_disabled = Signal::derive(move || check_in_action.pending().get());
// 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);
// Return to the main page.
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);
navigate("/loaners", Default::default());
check_in_action.clear();
}
});
+2 -2
View File
@@ -10,6 +10,6 @@ mod unauthenticated_page;
mod utils;
pub use self::{
about_page::*, comparison_test::*, home_page::*, loaner_page::LoanerPage, provider_portal::*,
test_page::*, unauthenticated_page::*,
about_page::*, comparison_test::*, home_page::*, loaner_page::*, provider_portal::*,
scholarship_info::*, test_page::*, unauthenticated_page::*,
};
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn ProviderPortal() -> impl IntoView {
title="Create Scholarship"
description="Navigate to Scholarship Creation Page"
icon="/Create_Black.png"
path="/"
path="/providers/scholarships"
/>
<DashboardButton
title="Applicants"
+535 -212
View File
@@ -1,16 +1,20 @@
use super::UnauthenticatedPage;
use super::api::{
CreateScholarshipInfo, CreateTestComparisons, get_comparison_info, get_scholarship_info,
CreateScholarshipInfo, CreateTestComparisons, DeleteProviderScholarship, RegisterScholarship,
get_comparison_info, get_provider_scholarships, get_scholarship_info,
};
use crate::common::ComparisonData;
use crate::common::{ExpandableInfo, ScholarshipFormParams};
use crate::common::{ExpandableInfo, ScholarshipFormParams, SubmitStatus, ValueType};
use crate::components::{
ActionButton, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row,
ActionButton, Banner, ChipsList, Loading, OutlinedTextField, Panel, RadioList, Row,
};
use crate::pages::utils::get_user_claims;
use leptos::either::Either;
use leptos::html::Dialog;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_oidc::{AuthLoaded, Authenticated};
use leptos_router::hooks::use_params;
use leptos_router::hooks::{use_navigate, use_params};
use traits::{AsReactive, ReactiveCapture};
/// # Scholarship Info Page
@@ -18,40 +22,379 @@ use traits::{AsReactive, ReactiveCapture};
/// 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");
// Changes to this signal update the scholarship list.
let list_refresh = RwSignal::new(0);
let url_params = use_params::<ScholarshipFormParams>();
let scholarship_id = move || {
let scholarship_id = Memo::new(move |_| {
url_params
.read()
.as_ref()
.ok()
.and_then(|params| params.id.clone())
.unwrap_or_default()
});
let create_test_comps = ServerAction::<CreateTestComparisons>::new();
let on_delete = move || {
let navigate = use_navigate();
navigate("/providers/scholarships", Default::default());
};
let get_scholarship_action: Resource<ExpandableInfo> = Resource::new(
move || scholarship_id().clone(),
async |id| {
get_scholarship_info(id.clone())
.await
// Note that this really should notify us of an error and then redirect or ask
// the user to try again.
.unwrap_or_else(|e| {
log!("Failed to get scholarship info: {:?}", e);
log!("Using default ExpandableInfo");
ExpandableInfo::new(id)
})
// 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>
<div class="flex flex-row align-top items-start">
<div class="flex flex-col flex-1" />
<ScholarshipList
refresh_token=list_refresh
on_delete=on_delete
/>
<Transition fallback=Loading>
<ScholarshipForm
scholarship_id=scholarship_id
on_submit=move || {
list_refresh.update(|n| *n += 1);
}
/>
</Transition>
<div class="flex flex-col flex-1" />
</div>
</Authenticated>
</AuthLoaded>
}
}
/// # Scholarship List Component
///
/// This component should always be wrapped in an Authenticated component. Assuming that a provider
/// is authenticated, this component will get all the scholarships that this provider owns and
/// will display them in a list, giving them the option to edit, delete, or create new scholarships.
///
/// Example usage:
/// ```
/// let refresh = RwSignal::new(0);
///
/// view! {
/// <AuthLoaded>
/// <Authenticated>
/// <ScholarshipList
/// refresh_token=refresh
/// on_delete=move || log!("This is run when an item is deleted on the server.")
/// />
/// </Authenticated>
/// </AuthLoaded>
/// }
/// ```
#[component]
fn ScholarshipList(
/// A signal that refreshes the list when changed. Most times, we don't need to worry about the
/// user reloading our page 2 billion times.
#[prop()]
refresh_token: RwSignal<i32>,
/// A callback that is run on successful server-side deletion of an item.
#[prop(into)]
on_delete: Callback<()>,
) -> impl IntoView {
// List state
let pending_delete = RwSignal::new(None::<ExpandableInfo>);
let delete_dialog_ref = NodeRef::<Dialog>::new();
// 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 || (refresh_token.get(), provider_id.get()),
|(_, provider_id)| async move {
log!("Fetching from scholarship API.");
let Some(provider_id) = provider_id else {
return Ok(Vec::new());
};
// Get the scholarships
get_provider_scholarships(provider_id).await
},
);
let get_comparison_info: Resource<Vec<ComparisonData>> = Resource::new(
move || Option::<String>::None, // There's no input to this function.
async |_| {
// Register scholarship creation/deletion actions
let create_action = ServerAction::<RegisterScholarship>::new();
let delete_action = ServerAction::<DeleteProviderScholarship>::new();
// Button handlers
let create_on_click = move |_| {
create_action.dispatch(RegisterScholarship {
provider_id: provider_id.get().unwrap(),
scholarship_name: "Testing Scholarship".to_string(),
});
};
let on_item_delete = move |s| {
pending_delete.set(Some(s));
};
// 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. We could alter this slightly to get the new element and
// update the list on the client side only (no refetch). We also want to set the name
// of the new scholarship before it's created on the server.
scholarships.refetch();
create_action.clear();
}
});
// Register scholarship deletion effects
let modal_disabled = Signal::derive(move || delete_action.pending().get());
Effect::new(move || {
if let Some(Ok(_)) = delete_action.value().get() {
scholarships.refetch();
pending_delete.set(None);
delete_action.clear();
on_delete.run(());
}
});
// Register scholarship pending delete effects
Effect::new(move || {
if let Some(dialog) = delete_dialog_ref.get() {
if pending_delete.get().is_some() {
// Find the modal dialog and show it. Ask for the user's confirmation. Afterward,
// take the correct action.
dialog
.show_modal()
.expect("Couldn't show dialog. Is it already open?");
} else {
dialog.close();
}
}
});
view! {
<dialog
node_ref=delete_dialog_ref
class="m-auto p-2"
>
<h2 class="text-2xl font-bold">"Confirm Deletion"</h2>
<p>
"Are you sure you want to delete "
{move || {
pending_delete.get().map(|s| {
s.data.get("name").unwrap_or(&ValueType::String(None))
.as_string()
.unwrap_or_default()
.unwrap_or("<no name found>".to_string())
})
}}
"?"
</p>
<ActionButton
on:click=move |_| {
let subject = pending_delete.get().map(|s| s.subject.clone()).unwrap_or_default();
log!("Deleting scholarship with subject {}", subject);
delete_action.dispatch(DeleteProviderScholarship {
scholarship_id: subject,
provider_id: provider_id.get().unwrap_or_default()
});
}
disabled=modal_disabled
>"Confirm"</ActionButton>
<ActionButton
on:click=move |_| pending_delete.set(None)
>"Cancel"</ActionButton>
</dialog>
<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()
on_delete=Callback::new(on_item_delete)/>
}
});
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-800 rounded-md text-center text-white hover:bg-red-900 transition-all"
on:click=create_on_click>
"Create New"
</div>
</div>
</Transition>
</Panel>
</div>
}
}
/// # Scholarship List Entry Component
///
/// Displays a single scholarship within the [`ScholarshipList`] component.
///
/// Example usage (within the [`ScholarshipList`]):
/// ```
/// view! {
/// <ScholarshipListEntry
/// scholarship= /* An ExpandableInfo object containing scholarship info */
/// on_delete=move |_| log!("This is a callback on successful deletion.")
/// />
/// }
/// ```
#[component]
fn ScholarshipListEntry(
/// The scholarship information. This element will attempt to get the name of the scholarship
/// using its `name` field, and also utilizes the `subject` field.
#[prop()]
scholarship: ExpandableInfo,
/// A [`Callback<ExpandableInfo>`] that runs upon successful deletion of the scholarship.
#[prop(into)]
on_delete: Callback<ExpandableInfo, ()>,
) -> impl IntoView {
let navigate = use_navigate();
let user_claims = get_user_claims();
let user_id = Memo::new(move |_| user_claims.get().map(|info| info.claims.subject.clone()));
// 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("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());
}
};
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-800 border-r border-white rounded-bl-md text-white text-center
hover:bg-red-900 cursor-pointer transition-all"
on:click=edit_click
>
"Edit"
</div>
<div class="p-2 flex-1 bg-red-800 border-l border-white rounded-br-md text-white text-center
hover:bg-red-900 cursor-pointer transition-all"
on:click=move |_| on_delete.run(scholarship.clone())
>
"Delete"
</div>
</div>
</div>
}
}
/// # Scholarship Edit Form Component
///
/// Displays all information about a scholarship and allows the provider to edit that information.
/// All information is only saved when the submit button is clicked.
///
/// The component will call the `on_submit` callback when the API returns an `Ok` result. In the
/// event that the API call fails, it will not call anything. This is mainly used to allow updates
/// to neighboring components from the parent.
///
/// Example usage:
/// ```
/// view! {
/// <ScholarshipForm
/// scholarship_id="SomeScholarshipID"
/// on_submit=move || log!("This is a callback!")
/// />
/// }
/// ```
#[component]
fn ScholarshipForm(
/// The ID of the scholarship to edit. The form will handle getting all information about the
/// scholarship and submitting new information.
#[prop(into)]
scholarship_id: Signal<Option<String>>,
/// A callback that is run when the form successfully submits new data. In the event that the
/// submission fails, this function is not run.
#[prop(into)]
on_submit: Callback<()>,
) -> impl IntoView {
let submit_status = RwSignal::new(SubmitStatus::Idle);
let elements_disabled = Signal::derive(move || match submit_status.get() {
SubmitStatus::Sending => true,
_ => false,
});
let result_msg = Signal::derive(move || match submit_status.get() {
SubmitStatus::Idle => "".into(),
SubmitStatus::Sending => "Sending submission...".into(),
SubmitStatus::Success => "Success".into(),
SubmitStatus::Error(msg) => format!("Error: {}", msg),
});
let submit_action = ServerAction::<CreateScholarshipInfo>::new();
Effect::new(move || {
if submit_action.pending().get() {
submit_status.set(SubmitStatus::Sending);
return;
}
if let Some(result) = submit_action.value().get() {
match result {
Ok(()) => {
submit_status.set(SubmitStatus::Success);
on_submit.run(());
}
Err(err) => submit_status.set(SubmitStatus::Error(err.to_string())),
}
}
});
let comparison_list: Resource<Vec<ComparisonData>> = Resource::new(
move || scholarship_id.get(), // There's no input to this function.
async move |_| {
get_comparison_info().await.unwrap_or_else(|e| {
log!("Failed to get comparison info: {:?}", e);
Vec::new()
@@ -59,74 +402,37 @@ pub fn ScholarshipInfoPage() -> impl IntoView {
},
);
let create_scholarship_action = ServerAction::<CreateScholarshipInfo>::new();
let create_test_comps = ServerAction::<CreateTestComparisons>::new();
// Remember that we only want to show the page after we've got authentication information
// and if there's data for us to use.
view! {
<AuthLoaded fallback=Loading>
<Authenticated unauthenticated=UnauthenticatedPage>
<Suspense fallback=Loading>
<Row>
<div class="flex flex-col flex-1" />
{move || Suspend::new(async move {
let response = get_scholarship_action.get();
let comparisons = get_comparison_info.get();
response
.zip(comparisons)
.map(|(response, comparison_list)| view! {
<ScholarshipForm
response=response
comparison_list=comparison_list
submit_action=create_scholarship_action
/>
})
.collect_view()
})}
<div class="hidden">
<Row>
<ActionButton
on:click=move |_| {
create_test_comps.dispatch(CreateTestComparisons {});
}
>"Create test comparisons"</ActionButton>
</Row>
</div>
<div class="flex flex-col flex-1" />
</Row>
</Suspense>
</Authenticated>
</AuthLoaded>
}
}
#[component]
fn ScholarshipForm(
response: ExpandableInfo,
comparison_list: Vec<ComparisonData>,
submit_action: ServerAction<CreateScholarshipInfo>,
) -> impl IntoView {
let reactive_info = response.as_reactive();
let elements_disabled = RwSignal::new(false);
let result_msg = Signal::derive(move || {
if submit_action.pending().get() {
elements_disabled.set(true);
"Sending request...".to_string()
} else {
elements_disabled.set(false);
if let Some(result) = submit_action.value().get() {
match result {
Ok(()) => "Request sent successfully".to_string(),
Err(err) => {
format!("An error occurred: {:?}", err)
}
}
let scholarship_info = Resource::new(
move || scholarship_id.get(),
async move |opt_id| {
if let Some(id) = opt_id {
get_scholarship_info(id).await
} else {
"".to_string()
Err(ServerFnError::new("Cannot get from server with no ID."))
}
}
},
);
let comparison_ids = Memo::new(move |_| {
comparison_list
.get()
.map(|list| {
list.iter()
.map(|comp| comp.clone().id)
.collect::<Vec<String>>()
})
.unwrap_or_default()
});
let comparison_text = Memo::new(move |_| {
comparison_list
.get()
.map(|list| {
list.iter()
.map(|comp| comp.clone().display_text)
.collect::<Vec<String>>()
})
.unwrap_or_default()
});
// We'll collect the scholarship's name, num_awards, amount_per_award, total_awards,
@@ -135,127 +441,144 @@ fn ScholarshipForm(
view! {
<Panel>
<Row>
<h1 class="text-3xl font-bold">
"Scholarship Info Form (test)"
</h1>
</Row>
<Row>
<OutlinedTextField
label="Scholarship Name"
placeholder="Example Scholarship Name"
disabled=elements_disabled
data_member="name"
data_map=reactive_info.data
/>
</Row>
<Row>
<OutlinedTextField
label="Amount per award: (if multiple, enter the highest value)"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="amount_per_award"
data_map=reactive_info.data
input_type="number"
/>
</Row>
<Row>
<OutlinedTextField
label="Total number of awards:"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="num_awards"
data_map=reactive_info.data
input_type="number"
/>
</Row>
<Row>
<OutlinedTextField
label="Total amount of all awards:"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="total_awards"
data_map=reactive_info.data
input_type="number"
/>
</Row>
<Row>
<RadioList
data_member="fafsa_required"
data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require student financial information?"
/>
</Row>
<Row>
<RadioList
data_member="transcript_required"
data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student transcript?"
/>
</Row>
<Row>
<RadioList
data_member="award_to"
data_map=reactive_info.data
items=vec!["School".to_string(), "Student".to_string()]
disabled=elements_disabled
label="Will the award be made to the school or the student?"
/>
</Row>
<Row>
<RadioList
data_member="essay_required"
data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student essay?"
/>
</Row>
<Row>
<RadioList
data_member="essay_prompt"
data_map=reactive_info.data
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect()
disabled=elements_disabled
label="If so, select a prompt from the list below."
/>
</Row>
<Row>
<ChipsList
label="Scholarship Requirements"
data_member="requirements"
data_map=reactive_info.data
values=comparison_list
.iter()
.cloned()
.map(|comp| comp.id)
.collect()
displayed_text=comparison_list
.iter()
.cloned()
.map(|comp| comp.display_text)
.collect()
/>
</Row>
<Row>
<ActionButton
disabled=elements_disabled
on:click=move |_| {
let captured = reactive_info.capture();
log!("Map values: {:?}", captured);
submit_action.dispatch(CreateScholarshipInfo {
info: captured
});
}
>"Submit"</ActionButton>
</Row>
<Row>
<p>{result_msg}</p>
</Row>
<Show
when=move || scholarship_id.get().is_some()
fallback=|| view! { <p>"Choose a scholarship from the right, or create a new one."</p> }
>
<Suspense fallback=Loading>
{move || {
scholarship_info.get()
.map(|res_scholarship| {
match res_scholarship {
Ok(scholarship) => {
let reactive_info = scholarship.as_reactive();
Either::Left(view! {
<Row>
<h1 class="text-3xl font-bold">
"Scholarship Info Form (test)"
</h1>
</Row>
<Row>
<OutlinedTextField
label="Scholarship Name"
placeholder="Example Scholarship Name"
disabled=elements_disabled
data_member="name"
data_map=reactive_info.data
/>
</Row>
<Row>
<OutlinedTextField
label="Amount per award: (if multiple, enter the highest value)"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="amount_per_award"
data_map=reactive_info.data
input_type="number"
/>
</Row>
<Row>
<OutlinedTextField
label="Total number of awards:"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="num_awards"
data_map=reactive_info.data
input_type="number"
/>
</Row>
<Row>
<OutlinedTextField
label="Total amount of all awards:"
placeholder="Enter an amount..."
disabled=elements_disabled
data_member="total_awards"
data_map=reactive_info.data
input_type="number"
/>
</Row>
<Row>
<RadioList
data_member="fafsa_required"
data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require student financial information?"
/>
</Row>
<Row>
<RadioList
data_member="transcript_required"
data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student transcript?"
/>
</Row>
<Row>
<RadioList
data_member="award_to"
data_map=reactive_info.data
items=vec!["School".to_string(), "Student".to_string()]
disabled=elements_disabled
label="Will the award be made to the school or the student?"
/>
</Row>
<Row>
<RadioList
data_member="essay_required"
data_map=reactive_info.data
items=vec!["Yes".to_string(), "No".to_string()]
disabled=elements_disabled
label="Do you require a student essay?"
/>
</Row>
<Row>
<RadioList
data_member="essay_prompt"
data_map=reactive_info.data
items=vec!["Test 1", "Test 2", "Test 3"].iter().map(|s| s.to_string()).collect()
disabled=elements_disabled
label="If so, select a prompt from the list below."
/>
</Row>
<Row>
<ChipsList
label="Scholarship Requirements"
data_member="requirements"
data_map=reactive_info.data
values=comparison_ids
displayed_text=comparison_text
/>
</Row>
<Row>
<ActionButton
disabled=elements_disabled
on:click=move |_| {
let captured = reactive_info.capture();
log!("Map values: {:?}", captured);
submit_action.dispatch(CreateScholarshipInfo {
info: captured.clone()
});
}
>"Submit"</ActionButton>
</Row>
<Row>
<p>{result_msg}</p>
</Row>
})
}
Err(err) => {
Either::Right(view! {
<p>"Error while getting scholarship: "{err.to_string()}</p>
})
}
}
}).collect_view()
}}
</Suspense>
</Show>
</Panel>
}
}
+17
View File
@@ -1,3 +1,7 @@
use crate::common::UserClaims;
use leptos::prelude::{Signal, With, use_context};
use leptos_oidc::{Algorithm, AuthSignal, TokenData};
/// # 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"])
})
})
})
}