feat: create file download capability

This commit is contained in:
alphafetus committed 2026-04-09 23:49:31 -04:00
1 parent c5b11481b3
commit f8d399f9ae
4 files changed
+321 -25

No files matched your search

Generated
+202
View File
@@ -2,6 +2,23 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -739,6 +756,15 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]] [[package]]
name = "camino" name = "camino"
version = "1.2.2" version = "1.2.2"
@@ -789,6 +815,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.57" version = "0.1.57"
@@ -895,6 +931,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.6.0" version = "0.6.0"
@@ -1173,6 +1215,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "deflate64"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]] [[package]]
name = "der" name = "der"
version = "0.6.1" version = "0.6.1"
@@ -1359,6 +1407,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"miniz_oxide",
"zlib-rs",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -2237,6 +2295,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "interpolator" name = "interpolator"
version = "0.5.0" version = "0.5.0"
@@ -2683,6 +2750,12 @@ dependencies = [
"tachys", "tachys",
] ]
[[package]]
name = "libbz2-rs-sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.182" version = "0.2.182"
@@ -2725,6 +2798,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae"
dependencies = [
"sha2",
]
[[package]] [[package]]
name = "macros" name = "macros"
version = "0.1.0" version = "0.1.0"
@@ -2795,6 +2877,16 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@@ -2958,6 +3050,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -3016,6 +3118,12 @@ dependencies = [
"spki", "spki",
] ]
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -3031,6 +3139,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -3621,6 +3735,7 @@ dependencies = [
"chrono", "chrono",
"console_error_panic_hook", "console_error_panic_hook",
"csv", "csv",
"futures",
"icondata", "icondata",
"indexmap", "indexmap",
"jsonwebtoken 10.3.0", "jsonwebtoken 10.3.0",
@@ -3640,6 +3755,7 @@ dependencies = [
"traits", "traits",
"uuid", "uuid",
"wasm-bindgen", "wasm-bindgen",
"zip",
] ]
[[package]] [[package]]
@@ -3931,6 +4047,12 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "simple_asn1" name = "simple_asn1"
version = "0.6.4" version = "0.6.4"
@@ -4177,6 +4299,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
"js-sys",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
@@ -4464,6 +4587,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@@ -5311,8 +5440,81 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zip"
version = "8.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59"
dependencies = [
"aes",
"bzip2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.4.1",
"hmac",
"indexmap",
"lzma-rust2",
"memchr",
"pbkdf2",
"ppmd-rust",
"sha1",
"time",
"typed-path",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
+4
View File
@@ -34,6 +34,8 @@ csv = "1.4.0"
leptos-use = { version = "0.18.3", features = ["use_drop_zone"] } leptos-use = { version = "0.18.3", features = ["use_drop_zone"] }
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true } jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"], optional = true }
reqwest = { version = "0.13.2", features = ["json"] } reqwest = { version = "0.13.2", features = ["json"] }
zip = { version = "8.5.1", optional = true }
futures = { version = "0.3.32", optional = true }
[features] [features]
default = ["ssr"] default = ["ssr"]
@@ -51,6 +53,8 @@ ssr = [
"dep:aws-config", "dep:aws-config",
"dep:serde_dynamo", "dep:serde_dynamo",
"dep:jsonwebtoken", "dep:jsonwebtoken",
"dep:zip",
"dep:futures",
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",
+51 -17
View File
@@ -1,6 +1,7 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::server_fn::codec::Json; use leptos::server_fn::codec::Json;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
mod imports { mod imports {
@@ -12,6 +13,7 @@ mod imports {
pub use aws_sdk_dynamodb::types::AttributeValue; pub use aws_sdk_dynamodb::types::AttributeValue;
pub use leptos::logging::{debug_log, error}; pub use leptos::logging::{debug_log, error};
pub use std::collections::HashMap; pub use std::collections::HashMap;
pub use zip::write::SimpleFileOptions;
} }
#[server(input = Json)] #[server(input = Json)]
@@ -211,7 +213,7 @@ pub async fn get_completed_students(
Ok(output) Ok(output)
} }
/// # Get Student File API /// # Get File by Key API
/// This function gets a file from the corresponding S3 file key. If the subject found from the /// This function gets a file from the corresponding S3 file key. If the subject found from the
/// `access_token` does not match the owner of the requested file, the requesting user must have /// `access_token` does not match the owner of the requested file, the requesting user must have
/// provider-level access or higher. /// provider-level access or higher.
@@ -221,7 +223,7 @@ pub async fn get_completed_students(
/// if the requesting user does not have the correct permissions, it will return a message indicating /// if the requesting user does not have the correct permissions, it will return a message indicating
/// such. If the file is not found, an error containing "File not found" will be returned. /// such. If the file is not found, an error containing "File not found" will be returned.
#[server] #[server]
pub async fn get_student_file( pub async fn get_file_by_key(
access_token: String, access_token: String,
file_key: String, file_key: String,
) -> Result<Vec<u8>, ServerFnError> { ) -> Result<Vec<u8>, ServerFnError> {
@@ -262,29 +264,18 @@ pub async fn get_student_file(
Ok(bytes.to_vec()) Ok(bytes.to_vec())
} }
/// # Batch Get Student Files API
/// Gets all indicated files and returns a single `.zip` file containing all of them. They will be
/// organized based on their file key.
///
/// This is more efficient than calling the [`get_student_file`] function as it makes use of S3's
/// `batch_get_object` function.
#[server]
pub async fn batch_get_student_files() -> Result<Vec<u8>, ServerFnError> {
todo!()
}
/// # Get Student File Names API /// # Get Student File Names API
/// Gets a list of file names given a student's ID and the corresponding input ID from the forms. /// Gets a list of file names given a student's ID and the corresponding input ID from the forms.
/// These file names can be used to construct a file key for S3. /// These file names can be used to construct a file key for S3.
/// ///
/// This API is only accessible by provider-level users. /// This API is only accessible by provider-level users.
#[server] #[server]
pub async fn get_student_file_names( pub async fn get_student_files(
access_token: String, access_token: String,
student_id: String, student_id: String,
form_name: String, form_name: String,
question_id: String, question_id: String,
) -> Result<Vec<String>, ServerFnError> { ) -> Result<Vec<u8>, ServerFnError> {
use imports::*; use imports::*;
let claims = validate_and_get_token_info(access_token).await?; let claims = validate_and_get_token_info(access_token).await?;
@@ -296,7 +287,7 @@ pub async fn get_student_file_names(
let client = create_dynamo_client().await; let client = create_dynamo_client().await;
client let file_keys = client
.query() .query()
.table_name(MAIN_TABLE_NAME) .table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
@@ -319,9 +310,52 @@ pub async fn get_student_file_names(
items items
.into_iter() .into_iter()
.filter_map(|item| item.get("SK")?.as_s().ok().cloned()) .filter_map(|item| item.get("file_key")?.as_s().ok().cloned())
.collect::<Vec<String>>() .collect::<Vec<String>>()
})?;
debug_log!("Await student file futures...");
let futures: Vec<_> = file_keys
.into_iter()
.map(|file_key| async move {
debug_log!("Requesting file from API with key {file_key}");
// Get all files from S3. This is a batch operation.
let s3_client = aws_sdk_s3::Client::new(&create_aws_config().await);
let file_output = s3_client
.get_object()
.bucket("leptos-scholarships")
.key(&file_key)
.send()
.await
.ok()?;
let bytes = file_output.body.collect().await.ok()?.to_vec();
Some((file_key, bytes))
}) })
.collect();
let results = futures::future::join_all(futures)
.await
.into_iter()
.flatten()
.collect::<Vec<(String, Vec<u8>)>>();
let cur = std::io::Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(cur);
debug_log!("Writing {} files to zip file...", results.len());
for (key, file_bytes) in results {
writer
.start_file(key.clone(), SimpleFileOptions::default())
.map_err(ServerFnError::new)?;
writer.write_all(&file_bytes).map_err(ServerFnError::new)?;
}
debug_log!("Getting finished file");
let finished = writer.finish().map_err(ServerFnError::new)?;
Ok(finished.into_inner())
} }
/// # Get All Input Files API /// # Get All Input Files API
+64 -8
View File
@@ -2,14 +2,14 @@
use crate::components::{ActionButton, Banner, Loading, ValueDisplay}; use crate::components::{ActionButton, Banner, Loading, ValueDisplay};
use crate::pages::UnauthenticatedPage; use crate::pages::UnauthenticatedPage;
use crate::pages::api::get_comparison_info; use crate::pages::api::get_comparison_info;
use crate::pages::api::students::{ use crate::pages::api::students::{GetStudentFiles, get_all_input_files, get_student_data};
BatchGetStudentFiles, GetStudentFile, GetStudentFileNames, get_all_input_files, use base64::Engine;
get_all_student_data, get_student_data, get_student_file_names,
};
use leptos::ev::{Event, MouseEvent}; use leptos::ev::{Event, MouseEvent};
use leptos::html::Dialog; use leptos::html::Dialog;
use leptos::logging::debug_log; use leptos::logging::debug_log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::wasm_bindgen::JsCast;
use leptos::web_sys::HtmlAnchorElement;
use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated}; use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated};
use leptos_router::components::Outlet; use leptos_router::components::Outlet;
use leptos_router::hooks::{query_signal, use_navigate}; use leptos_router::hooks::{query_signal, use_navigate};
@@ -501,9 +501,12 @@ fn ApplicantsStudentListView(
// flex container. // flex container.
view! { view! {
<StudentInformationDialog <StudentInformationDialog
scholarship_id=scholarship.get().subject
student_id=current_student_id student_id=current_student_id
visible=dialog_visible visible=dialog_visible
on_close=on_dialog_close on_close=on_dialog_close
essay_required=essay_required
fafsa_required=fafsa_required
/> />
<Show when=move || !error_msg.get().is_empty()> <Show when=move || !error_msg.get().is_empty()>
<div>"Error checking student eligibility: "{error_msg}</div> <div>"Error checking student eligibility: "{error_msg}</div>
@@ -550,9 +553,11 @@ fn ApplicantsStudentListView(
#[component] #[component]
fn StudentInformationDialog( fn StudentInformationDialog(
#[prop(into)] scholarship_id: Signal<String>,
#[prop(into)] student_id: Signal<Option<String>>, #[prop(into)] student_id: Signal<Option<String>>,
// #[prop(into)] scholarship_info: StoredValue<ExpandableInfo>,
#[prop(into)] visible: Signal<bool>, #[prop(into)] visible: Signal<bool>,
#[prop(into)] fafsa_required: Signal<bool>,
#[prop(into)] essay_required: Signal<bool>,
#[prop(into)] on_close: Callback<()>, #[prop(into)] on_close: Callback<()>,
) -> impl IntoView { ) -> impl IntoView {
// This component needs to just display all student information. // This component needs to just display all student information.
@@ -560,6 +565,9 @@ fn StudentInformationDialog(
// requested an essay or financial information. // requested an essay or financial information.
let dialog_ref = NodeRef::<Dialog>::new(); let dialog_ref = NodeRef::<Dialog>::new();
let auth = expect_context::<AuthSignal>();
let access_token =
Memo::new(move |_| auth.with(|auth| auth.authenticated().map(|a| a.access_token())));
//#region Schema Definitions //#region Schema Definitions
@@ -635,13 +643,50 @@ fn StudentInformationDialog(
// We want to get the student's uploaded files - only the ones that are relevant to the current // We want to get the student's uploaded files - only the ones that are relevant to the current
// scholarship, meaning their essays and/or FAFSA by request. // scholarship, meaning their essays and/or FAFSA by request.
let get_student_files = ServerAction::<GetStudentFiles>::new();
let get_buttons_disabled = get_student_files.pending();
let on_click_fafsa = move |_| { let on_click_fafsa = move |_| {
// We need to get the student's file names/keys and then tell the server that we want to // We need to get the student's file names/keys and then tell the server that we want to
// download them. // download them.
let fafsa_key = format!("financial_info/user_id/fafsa/file_name"); get_student_files.dispatch(GetStudentFiles {
debug_log!("Getting FAFSA file with key {fafsa_key}"); access_token: access_token.get().unwrap_or_default(),
student_id: student_id.get().unwrap_or_default(),
form_name: "financial_info".to_string(),
question_id: "fafsa".to_string(),
});
}; };
let on_click_essay = move |_| {
debug_log!("Getting essay files");
get_student_files.dispatch(GetStudentFiles {
access_token: access_token.get().unwrap_or_default(),
student_id: student_id.get().unwrap_or_default(),
form_name: "scholarship_essays".to_string(),
question_id: scholarship_id.get(),
});
};
Effect::new(move || {
if let Some(Ok(file)) = get_student_files.value().get() {
let base64 = base64::engine::general_purpose::STANDARD.encode(file);
let data_url = format!("data:application/zip;base64,{}", base64);
// Create a base64 link and open it.
let a: HtmlAnchorElement = document()
.create_element("a")
.expect("Failed to create anchor element")
.dyn_into()
.unwrap();
a.set_download("testing.zip");
a.set_href(&data_url);
document().body().unwrap().append_child(&a).unwrap();
a.click();
a.remove();
get_student_files.clear();
}
});
view! { view! {
<dialog <dialog
node_ref=dialog_ref node_ref=dialog_ref
@@ -653,7 +698,18 @@ fn StudentInformationDialog(
> >
<div class="p-5" on:click=move |e: MouseEvent| e.stop_propagation()> <div class="p-5" on:click=move |e: MouseEvent| e.stop_propagation()>
<Suspense fallback=Loading> <Suspense fallback=Loading>
<ActionButton on:click=on_click_fafsa>"Get FAFSA"</ActionButton> <div class="flex flex-row">
<Show when=move || fafsa_required.get()>
<ActionButton on:click=on_click_fafsa disabled=get_buttons_disabled>
"Get FAFSA File(s)"
</ActionButton>
</Show>
<Show when=move || essay_required.get()>
<ActionButton on:click=on_click_essay disabled=get_buttons_disabled>
"Get Essay File(s)"
</ActionButton>
</Show>
</div>
{move || Suspend::new(async move { {move || Suspend::new(async move {
get_student_data( get_student_data(
student_id.get().unwrap_or_default(), student_id.get().unwrap_or_default(),