From ec26fb6fce570d3ddafdf6d07acd91a345154929 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 3 Sep 2025 11:28:59 -0400 Subject: [PATCH 1/3] feat: create reactive traits (AsReactive and ReactiveCapture) --- Cargo.lock | 9 ++++ Cargo.toml | 3 +- macros/Cargo.toml | 4 +- macros/src/lib.rs | 91 +++++++++++++++++++++++++++++++---- src/components/multi_entry.rs | 33 +++++++------ src/pages/home_page.rs | 5 +- traits/Cargo.toml | 7 +++ traits/src/lib.rs | 3 ++ traits/src/reactive.rs | 82 +++++++++++++++++++++++++++++++ 9 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 traits/Cargo.toml create mode 100644 traits/src/lib.rs create mode 100644 traits/src/reactive.rs diff --git a/Cargo.lock b/Cargo.lock index c94c8d7..48617ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2159,6 +2159,7 @@ version = "0.1.0" dependencies = [ "quote", "syn", + "traits", ] [[package]] @@ -2960,6 +2961,7 @@ dependencies = [ "serde", "serde_dynamo", "tokio", + "traits", "url", "uuid", "wasm-bindgen", @@ -3645,6 +3647,13 @@ dependencies = [ "once_cell", ] +[[package]] +name = "traits" +version = "0.1.0" +dependencies = [ + "leptos", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index f7d088a..57d7b92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "scholarships-rs" version = "0.1.0" -edition = "2021" +edition = "2024" [lib] crate-type = ["cdylib", "rlib"] @@ -24,6 +24,7 @@ leptos_oidc = "0.9.0" url = "2.5.4" macros = { path = "./macros" } +traits = { path = "./traits" } [features] hydrate = [ diff --git a/macros/Cargo.toml b/macros/Cargo.toml index 3fa6245..d8b6a20 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -8,4 +8,6 @@ proc-macro = true [dependencies] syn = "2.0" -quote = "1.0" \ No newline at end of file +quote = "1.0" + +traits = { path = "../traits" } diff --git a/macros/src/lib.rs b/macros/src/lib.rs index ed499b6..6ddf59e 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,7 +1,72 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{Data, Ident, parse_macro_input}; +use syn::{parse_macro_input, Data, Ident}; +/// Derives the `AsReactive` and `ReactiveCapture` traits for a struct, while also +/// providing a new reactive struct with the same field names, but they are wrapped +/// in the Leptos `RwSignal`. +/// +/// Fields that are custom types are not automatically converted into reactive types. They +/// will be wrapped in an `RwSignal` struct, but that is where this process stops. Making +/// other information reactive must be implemented manually. +/// +/// For example, when the `Reactive` macro is expanded for the struct `User`, it will look +/// like this: +/// +/// ``` +/// #[derive(Reactive)] +/// struct User { +/// id: i32, +/// name: String, +/// custom: CustomType +/// } +/// +/// // This code will be added: +/// struct UserReactive { +/// id: RwSignal, +/// name: RwSignal, +/// custom: RwSignal +/// } +/// +/// impl AsReactive for User { +/// type ReactiveType = UserReactive; +/// // Other methods - refer to AsReactive documentation. +/// } +/// +/// impl ReactiveCapture for UserReactive { +/// type CaptureType = User; +/// // Other methods - refer to ReactiveCapture documentation. +/// } +/// ``` +/// +/// The base type (`User`) can then be converted into the reactive type: +/// ``` +/// let user = User::new(); +/// let user_reactive = user.as_reactive(); +/// ``` +/// And captured back into a non-reactive type: +/// ``` +/// let captured_user = user_reactive.capture() +/// ``` +/// +/// # Important Considerations +/// +/// A reminder that custom types, in this case `CustomType`, are just wrapped in an `RwSignal`. +/// They are not automatically converted to their own `Reactive` type in the case that they +/// derive this macro. As a result, custom reactive types *must* be updated manually by utilizing +/// the `RwSignal::update` and `RwSignal::set` methods. +/// +/// For example, the `UserReactive` struct from earlier does not convert the `CustomType` into a +/// `CustomTypeReactive`. We have to do that ourselves: +/// ``` +/// let user: User = User::new(); +/// let user_reactive: UserReactive = user.as_reactive(); +/// let custom_reactive: CustomTypeReactive = user_reactive.custom.as_reactive(); +/// // And then convert back... +/// user_reactive.custom.set(custom_reactive.capture()); +/// ``` +/// If a shorthand for the `capture` and `as_reactive` functions are absolutely *required*, then this +/// macro is probably not the way to go - as of now, this functionality should be impmlemented manually. #[proc_macro_derive(Reactive)] pub fn make_reactive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as syn::DeriveInput); @@ -32,7 +97,7 @@ pub fn make_reactive(input: TokenStream) -> TokenStream { // Create new function arguments/inits let new_fn_arg = quote! { - #field_name: _RwSignal::new(data.#field_name), + #field_name: _RwSignal::new(self.#field_name), }; let from_fn_arg = quote! { @@ -43,30 +108,36 @@ pub fn make_reactive(input: TokenStream) -> TokenStream { from_fn_args.push(from_fn_arg); } - let reactive_docs = format!("Reactive version of the [`{name}`] struct. Automatically created by the `Reactive` macro."); + let reactive_docs = format!( + "Reactive version of the [`{name}`] struct. Automatically created by the `Reactive` macro." + ); // Expand the macro. The output is a struct with new reactive fields and a new function, // which initializes the reactive fields based on the original struct's fields. let expanded = quote! { use leptos::prelude::RwSignal as _RwSignal; // in case the user imports it themselves use leptos::prelude::GetUntracked as _; + use traits::{AsReactive, ReactiveCapture}; #[doc = #reactive_docs] pub struct #reactive_name { #(#new_fields)* } - impl #reactive_name { - /// Creates a new `#reactive_name` struct from a non-reactive `#name`. - pub fn new(data: #name) -> Self { - Self { + impl AsReactive for #name { + type ReactiveType = #reactive_name; + + fn as_reactive(self) -> Self::ReactiveType { + #reactive_name { #(#new_fn_args)* } } + } - /// Captures the information of this struct into a `#name` struct, for use - /// in server functions. - pub fn capture(&self) -> #name { + impl ReactiveCapture for #reactive_name { + type CaptureType = #name; + + fn capture(&self) -> Self::CaptureType { #name { #(#from_fn_args)* } diff --git a/src/components/multi_entry.rs b/src/components/multi_entry.rs index c0e2725..9cb9c4a 100644 --- a/src/components/multi_entry.rs +++ b/src/components/multi_entry.rs @@ -2,10 +2,11 @@ use std::collections::HashMap; use crate::components::ActionButton; use leptos::prelude::*; +use macros::Reactive; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Clone, Serialize, Deserialize, Debug)] +#[derive(Clone, Serialize, Deserialize, Debug, Reactive, Default)] pub struct MultiEntryData { pub id: Uuid, pub data: HashMap, @@ -130,27 +131,31 @@ pub fn MultiEntry(
// This div MUST have the same spacing CSS as the main entry div
- {name_member.get().display_name} - {info_member.get().display_name} + + {name_member.get().display_name} + + + {info_member.get().display_name} +
// Entry section
+ each=move || entries.get() + key=|entry: &MultiEntryData| entry.id + children=move |entry| { + view! { + + } } />
- - Add Entry - + Add Entry } } diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 41b3b02..29e3222 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -6,7 +6,7 @@ use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue, Clien use serde_dynamo::{from_item, to_item}; use crate::app::Unauthenticated; -use crate::common::{StudentInfo, StudentInfoReactive, UserClaims}; +use crate::common::{StudentInfo, UserClaims}; use crate::components::{ ActionButton, CheckboxList, Loading, OutlinedTextField, Panel, Row, Select, RadioList @@ -14,6 +14,7 @@ use crate::components::{ use leptos::leptos_dom::logging::console_log; use leptos::prelude::*; use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; +use traits::{AsReactive, ReactiveCapture}; #[server(GetSubmission, endpoint = "/get-submission")] pub async fn get_submission(id: String) -> Result { @@ -119,7 +120,7 @@ pub fn HomePage() -> impl IntoView { server_resource .get() .map(|submission: StudentInfo| { - let reactive_info = StudentInfoReactive::new(submission); + let reactive_info = submission.as_reactive(); let select_value = RwSignal::new(String::from("Math")); let chk_select = RwSignal::new(vec!["Testing 2".into()]); let elements_disabled = RwSignal::new(false); diff --git a/traits/Cargo.toml b/traits/Cargo.toml new file mode 100644 index 0000000..b0351d5 --- /dev/null +++ b/traits/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "traits" +version = "0.1.0" +edition = "2024" + +[dependencies] +leptos = { version = "0.8" } diff --git a/traits/src/lib.rs b/traits/src/lib.rs new file mode 100644 index 0000000..8582022 --- /dev/null +++ b/traits/src/lib.rs @@ -0,0 +1,3 @@ +pub mod reactive; + +pub use reactive::*; diff --git a/traits/src/reactive.rs b/traits/src/reactive.rs new file mode 100644 index 0000000..d3b63c5 --- /dev/null +++ b/traits/src/reactive.rs @@ -0,0 +1,82 @@ +/// Trait that ensures a reactive struct can be created from a non-reactive struct. +/// +/// For example: +/// +/// ```rust +/// struct User { +/// id: i32, +/// name: String +/// } +/// +/// struct UserReactive { +/// id: RwSignal, +/// name: RwSignal +/// } +/// +/// impl ReactiveCapture for UserReactive { +/// // implement - required for ReactiveType. +/// } +/// +/// impl AsReactive for User { +/// type ReactiveType = UserReactive; +/// fn as_reactive(self) -> UserReactive { +/// UserReactive { +/// id: RwSignal::new(self.id), +/// name: RwSignal::new(self.name) +/// } +/// } +/// } +/// ``` +pub trait AsReactive { + /// The type of the reactive struct. It must implement `ReactiveCapture`. + type ReactiveType: ReactiveCapture; + + /// Wraps all fields of the struct into an `RwSignal`, returning a new + /// "reactive" struct. + /// + /// Note that this method consumes the original struct and returns a new + /// reactive struct. + fn as_reactive(self) -> Self::ReactiveType; +} + +/// Trait that ensures a reactive struct can be captured into a non-reactive struct. +/// The "captured" struct does not contain *any* reactive fields, meaning that it is not +/// wrapped in an `RwSignal`. +/// +/// The captured struct should simply collect all the values from the reactive fields at +/// that point in time. +/// +/// For example: +/// +/// ```rust +/// struct User { +/// id: i32, +/// name: String +/// } +/// +/// struct UserReactive { +/// id: RwSignal, +/// name: RwSignal +/// } +/// +/// impl AsReactive for User { +/// // implement - required for CaptureType. +/// } +/// +/// impl ReactiveCapture for UserReactive { +/// type CaptureType = User; +/// fn capture(self) -> User { +/// User { +/// id: self.id.get(), +/// name: self.name.get() +/// } +/// } +/// } +/// ``` +pub trait ReactiveCapture { + /// The type of the captured struct. It must implement `AsReactive`. + type CaptureType: AsReactive; + + /// Captures the current state of the reactive struct into a non-reactive struct. + fn capture(&self) -> Self::CaptureType; +} From 6eacb08e587cdaf2039a79e7f79c1a31e18fe752 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 3 Sep 2025 11:34:47 -0400 Subject: [PATCH 2/3] chore: add one additional comment --- src/pages/home_page.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs index 29e3222..1e20bef 100644 --- a/src/pages/home_page.rs +++ b/src/pages/home_page.rs @@ -18,11 +18,14 @@ use traits::{AsReactive, ReactiveCapture}; #[server(GetSubmission, endpoint = "/get-submission")] pub async fn get_submission(id: String) -> Result { + let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; let dbclient = Client::new(&config); console_log(format!("Getting values from API using username {}", id).as_str()); + // Gets the item from the database. It only returns an error if the database + // experiences an error - not if the item is not found. match dbclient .get_item() .table_name("student-applications") @@ -52,7 +55,6 @@ pub async fn get_submission(id: String) -> Result { pub async fn create_sample_submission( student_info: StudentInfo, ) -> Result<(), ServerFnError> { - use aws_sdk_dynamodb::Client; let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; let dbclient = Client::new(&config); @@ -69,10 +71,12 @@ pub async fn create_sample_submission( .send().await { Ok(_) => { + // The student's information was successfully created console_log(format!("Set up student information: {:?}", student_info).as_str()); Ok(()) }, Err(err) => { + // There was an error while creating the student's information let msg = err.message().unwrap_or("An unknown error occurred."); console_log(format!("Error creating sample submission: {}", msg).as_str()); Err(ServerFnError::new(msg)) From 16a7e49fbf28083893440203a8db233c0a179c8a Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Wed, 3 Sep 2025 11:50:10 -0400 Subject: [PATCH 3/3] chore: update documentation comments to match implemented code --- macros/src/lib.rs | 4 ++-- traits/src/reactive.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 6ddf59e..08dbc4d 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -61,12 +61,12 @@ use syn::{parse_macro_input, Data, Ident}; /// ``` /// let user: User = User::new(); /// let user_reactive: UserReactive = user.as_reactive(); -/// let custom_reactive: CustomTypeReactive = user_reactive.custom.as_reactive(); +/// let custom_reactive: CustomTypeReactive = user_reactive.custom.get().as_reactive(); /// // And then convert back... /// user_reactive.custom.set(custom_reactive.capture()); /// ``` /// If a shorthand for the `capture` and `as_reactive` functions are absolutely *required*, then this -/// macro is probably not the way to go - as of now, this functionality should be impmlemented manually. +/// macro is probably not the way to go - as of now, this functionality should be implemented manually. #[proc_macro_derive(Reactive)] pub fn make_reactive(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as syn::DeriveInput); diff --git a/traits/src/reactive.rs b/traits/src/reactive.rs index d3b63c5..40cb7d1 100644 --- a/traits/src/reactive.rs +++ b/traits/src/reactive.rs @@ -65,7 +65,7 @@ pub trait AsReactive { /// /// impl ReactiveCapture for UserReactive { /// type CaptureType = User; -/// fn capture(self) -> User { +/// fn capture(&self) -> User { /// User { /// id: self.id.get(), /// name: self.name.get()