Merge pull request #2 from R15-Software-Dev/feature/macro-requirements

Adds new `AsReactive` and `ReactiveCapture` traits, and automatically implements them through the altered `Reactive` derive macro.
This commit was merged in pull request #2.
This commit is contained in:
Lucas Vas authored and GitHub committed 2025-09-03 11:52:50 -04:00
commit 4ac1b50832
9 files changed
+213 -28

No files matched your search

Generated
+9
View File
@@ -2159,6 +2159,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"quote", "quote",
"syn", "syn",
"traits",
] ]
[[package]] [[package]]
@@ -2960,6 +2961,7 @@ dependencies = [
"serde", "serde",
"serde_dynamo", "serde_dynamo",
"tokio", "tokio",
"traits",
"url", "url",
"uuid", "uuid",
"wasm-bindgen", "wasm-bindgen",
@@ -3645,6 +3647,13 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "traits"
version = "0.1.0"
dependencies = [
"leptos",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
+2 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "scholarships-rs" name = "scholarships-rs"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[lib] [lib]
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
@@ -24,6 +24,7 @@ leptos_oidc = "0.9.0"
url = "2.5.4" url = "2.5.4"
macros = { path = "./macros" } macros = { path = "./macros" }
traits = { path = "./traits" }
[features] [features]
hydrate = [ hydrate = [
+2
View File
@@ -9,3 +9,5 @@ proc-macro = true
[dependencies] [dependencies]
syn = "2.0" syn = "2.0"
quote = "1.0" quote = "1.0"
traits = { path = "../traits" }
+81 -10
View File
@@ -1,7 +1,72 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote; 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<i32>,
/// name: RwSignal<String>,
/// custom: RwSignal<CustomType>
/// }
///
/// 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.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 implemented manually.
#[proc_macro_derive(Reactive)] #[proc_macro_derive(Reactive)]
pub fn make_reactive(input: TokenStream) -> TokenStream { pub fn make_reactive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::DeriveInput); 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 // Create new function arguments/inits
let new_fn_arg = quote! { let new_fn_arg = quote! {
#field_name: _RwSignal::new(data.#field_name), #field_name: _RwSignal::new(self.#field_name),
}; };
let from_fn_arg = quote! { let from_fn_arg = quote! {
@@ -43,30 +108,36 @@ pub fn make_reactive(input: TokenStream) -> TokenStream {
from_fn_args.push(from_fn_arg); 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, // 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. // which initializes the reactive fields based on the original struct's fields.
let expanded = quote! { let expanded = quote! {
use leptos::prelude::RwSignal as _RwSignal; // in case the user imports it themselves use leptos::prelude::RwSignal as _RwSignal; // in case the user imports it themselves
use leptos::prelude::GetUntracked as _; use leptos::prelude::GetUntracked as _;
use traits::{AsReactive, ReactiveCapture};
#[doc = #reactive_docs] #[doc = #reactive_docs]
pub struct #reactive_name { pub struct #reactive_name {
#(#new_fields)* #(#new_fields)*
} }
impl #reactive_name { impl AsReactive for #name {
/// Creates a new `#reactive_name` struct from a non-reactive `#name`. type ReactiveType = #reactive_name;
pub fn new(data: #name) -> Self {
Self { fn as_reactive(self) -> Self::ReactiveType {
#reactive_name {
#(#new_fn_args)* #(#new_fn_args)*
} }
} }
}
/// Captures the information of this struct into a `#name` struct, for use impl ReactiveCapture for #reactive_name {
/// in server functions. type CaptureType = #name;
pub fn capture(&self) -> #name {
fn capture(&self) -> Self::CaptureType {
#name { #name {
#(#from_fn_args)* #(#from_fn_args)*
} }
+19 -14
View File
@@ -2,10 +2,11 @@ use std::collections::HashMap;
use crate::components::ActionButton; use crate::components::ActionButton;
use leptos::prelude::*; use leptos::prelude::*;
use macros::Reactive;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone, Serialize, Deserialize, Debug)] #[derive(Clone, Serialize, Deserialize, Debug, Reactive, Default)]
pub struct MultiEntryData { pub struct MultiEntryData {
pub id: Uuid, pub id: Uuid,
pub data: HashMap<String, ValueType>, pub data: HashMap<String, ValueType>,
@@ -130,27 +131,31 @@ pub fn MultiEntry(
<div class="rounded-md bg-red-700"> <div class="rounded-md bg-red-700">
// This div MUST have the same spacing CSS as the main entry div // This div MUST have the same spacing CSS as the main entry div
<div class="flex bg-inherit p-3 m-1"> <div class="flex bg-inherit p-3 m-1">
<span class="flex-1 text-white font-bold">{name_member.get().display_name}</span> <span class="flex-1 text-white font-bold">
<span class="flex-1 text-white font-bold">{info_member.get().display_name}</span> {name_member.get().display_name}
</span>
<span class="flex-1 text-white font-bold">
{info_member.get().display_name}
</span>
</div> </div>
</div> </div>
// Entry section // Entry section
<div> <div>
<For <For
each = move || entries.get() each=move || entries.get()
key = |entry: &MultiEntryData| entry.id key=|entry: &MultiEntryData| entry.id
children = move |entry| view! { children=move |entry| {
<Entry view! {
entry_data=entry.clone() <Entry
name_member=name_member entry_data=entry.clone()
info_member=info_member name_member=name_member
/> info_member=info_member
/>
}
} }
/> />
</div> </div>
</div> </div>
<ActionButton on:click=create_entry> <ActionButton on:click=create_entry>Add Entry</ActionButton>
Add Entry
</ActionButton>
} }
} }
+8 -3
View File
@@ -6,7 +6,7 @@ use aws_sdk_dynamodb::{error::ProvideErrorMetadata, types::AttributeValue, Clien
use serde_dynamo::{from_item, to_item}; use serde_dynamo::{from_item, to_item};
use crate::app::Unauthenticated; use crate::app::Unauthenticated;
use crate::common::{StudentInfo, StudentInfoReactive, UserClaims}; use crate::common::{StudentInfo, UserClaims};
use crate::components::{ use crate::components::{
ActionButton, CheckboxList, Loading, OutlinedTextField, Panel, Row, Select, ActionButton, CheckboxList, Loading, OutlinedTextField, Panel, Row, Select,
RadioList RadioList
@@ -14,14 +14,18 @@ use crate::components::{
use leptos::leptos_dom::logging::console_log; use leptos::leptos_dom::logging::console_log;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated}; use leptos_oidc::{Algorithm, AuthLoaded, AuthSignal, Authenticated};
use traits::{AsReactive, ReactiveCapture};
#[server(GetSubmission, endpoint = "/get-submission")] #[server(GetSubmission, endpoint = "/get-submission")]
pub async fn get_submission(id: String) -> Result<StudentInfo, ServerFnError> { pub async fn get_submission(id: String) -> Result<StudentInfo, ServerFnError> {
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let dbclient = Client::new(&config); let dbclient = Client::new(&config);
console_log(format!("Getting values from API using username {}", id).as_str()); 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 match dbclient
.get_item() .get_item()
.table_name("student-applications") .table_name("student-applications")
@@ -51,7 +55,6 @@ pub async fn get_submission(id: String) -> Result<StudentInfo, ServerFnError> {
pub async fn create_sample_submission( pub async fn create_sample_submission(
student_info: StudentInfo, student_info: StudentInfo,
) -> Result<(), ServerFnError> { ) -> Result<(), ServerFnError> {
use aws_sdk_dynamodb::Client;
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let dbclient = Client::new(&config); let dbclient = Client::new(&config);
@@ -68,10 +71,12 @@ pub async fn create_sample_submission(
.send().await .send().await
{ {
Ok(_) => { Ok(_) => {
// The student's information was successfully created
console_log(format!("Set up student information: {:?}", student_info).as_str()); console_log(format!("Set up student information: {:?}", student_info).as_str());
Ok(()) Ok(())
}, },
Err(err) => { Err(err) => {
// There was an error while creating the student's information
let msg = err.message().unwrap_or("An unknown error occurred."); let msg = err.message().unwrap_or("An unknown error occurred.");
console_log(format!("Error creating sample submission: {}", msg).as_str()); console_log(format!("Error creating sample submission: {}", msg).as_str());
Err(ServerFnError::new(msg)) Err(ServerFnError::new(msg))
@@ -119,7 +124,7 @@ pub fn HomePage() -> impl IntoView {
server_resource server_resource
.get() .get()
.map(|submission: StudentInfo| { .map(|submission: StudentInfo| {
let reactive_info = StudentInfoReactive::new(submission); let reactive_info = submission.as_reactive();
let select_value = RwSignal::new(String::from("Math")); let select_value = RwSignal::new(String::from("Math"));
let chk_select = RwSignal::new(vec!["Testing 2".into()]); let chk_select = RwSignal::new(vec!["Testing 2".into()]);
let elements_disabled = RwSignal::new(false); let elements_disabled = RwSignal::new(false);
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "traits"
version = "0.1.0"
edition = "2024"
[dependencies]
leptos = { version = "0.8" }
+3
View File
@@ -0,0 +1,3 @@
pub mod reactive;
pub use reactive::*;
+82
View File
@@ -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<i32>,
/// name: RwSignal<String>
/// }
///
/// 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<i32>,
/// name: RwSignal<String>
/// }
///
/// 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;
}