Merge pull request #92 from gbj/action-api
`Action` and `MultiAction` API changes
This commit is contained in:
commit
5ff806d35a
|
@ -23,6 +23,7 @@ members = [
|
|||
"examples/parent-child",
|
||||
"examples/router",
|
||||
"examples/todomvc",
|
||||
"examples/todo-app-sqlite",
|
||||
"examples/view-tests",
|
||||
|
||||
# book
|
||||
|
|
|
@ -3,16 +3,19 @@
|
|||
This example demonstrates how to use a function isomorphically, to run a server side function from the browser and receive a result.
|
||||
|
||||
## Server Side Rendering With Hydration
|
||||
To run it as a server side app with hydration, first you should run
|
||||
|
||||
To run it as a server side app with hydration, first you should run
|
||||
|
||||
```bash
|
||||
wasm-pack build --target=web --no-default-features --features=hydrate
|
||||
```
|
||||
|
||||
to generate the Webassembly to provide hydration features for the server.
|
||||
Then run the server with `cargo run` to serve the server side rendered HTML and the WASM bundle for hydration.
|
||||
Then run the server with `cargo run` to serve the server side rendered HTML and the WASM bundle for hydration.
|
||||
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
|
||||
> Note that if your hydration code changes, you will have to rerun the wasm-pack command above
|
||||
> This should be temporary, and vastly improve once cargo-leptos becomes ready for prime time!
|
||||
|
||||
If for some reason you want to run it as a fully client side app, that can be done with the instructions below.
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
[package]
|
||||
name = "todo-app-sqlite"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
actix-files = { version = "0.6", optional = true }
|
||||
actix-web = { version = "4", optional = true, features = ["openssl", "macros"] }
|
||||
anyhow = "1"
|
||||
broadcaster = "1"
|
||||
console_log = "0.2"
|
||||
console_error_panic_hook = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
futures = "0.3"
|
||||
cfg-if = "1"
|
||||
leptos = { path = "../../../leptos/leptos", default-features = false, features = [
|
||||
"serde",
|
||||
] }
|
||||
leptos_meta = { path = "../../../leptos/meta", default-features = false }
|
||||
leptos_router = { path = "../../../leptos/router", default-features = false }
|
||||
log = "0.4"
|
||||
simple_logger = "2"
|
||||
gloo = { git = "https://github.com/rustwasm/gloo" }
|
||||
sqlx = { version = "0.6", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
|
||||
[features]
|
||||
default = ["ssr"]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:actix-files",
|
||||
"dep:actix-web",
|
||||
"dep:sqlx",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["actix-files", "actix-web", "sqlx"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 Greg Johnston
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,21 @@
|
|||
# Leptos Counter Isomorphic Example
|
||||
|
||||
This example demonstrates how to use a server functions and multi-actions to build a simple todo app.
|
||||
|
||||
## Server Side Rendering With Hydration
|
||||
|
||||
To run it as a server side app with hydration, first you should run
|
||||
|
||||
```bash
|
||||
wasm-pack build --target=web --no-default-features --features=hydrate
|
||||
```
|
||||
|
||||
to generate the Webassembly to provide hydration features for the server.
|
||||
Then run the server with `cargo run` to serve the server side rendered HTML and the WASM bundle for hydration.
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
> Note that if your hydration code changes, you will have to rerun the wasm-pack command above
|
||||
> This should be temporary, and vastly improve once cargo-leptos becomes ready for prime time!
|
Binary file not shown.
|
@ -0,0 +1,6 @@
|
|||
CREATE TABLE IF NOT EXISTS todos
|
||||
(
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
title VARCHAR,
|
||||
completed BOOLEAN
|
||||
);
|
|
@ -0,0 +1,22 @@
|
|||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
pub mod todo;
|
||||
|
||||
// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong.
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "hydrate")] {
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use crate::todo::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::hydrate(body().unwrap(), |cx| {
|
||||
view! { cx, <TodoApp/> }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
mod todo;
|
||||
|
||||
// boilerplate to run in different modes
|
||||
cfg_if! {
|
||||
// server-only stuff
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use actix_files::{Files};
|
||||
use actix_web::*;
|
||||
use crate::todo::*;
|
||||
|
||||
#[get("{tail:.*}")]
|
||||
async fn render(req: HttpRequest) -> impl Responder {
|
||||
let path = req.path();
|
||||
let path = "http://leptos".to_string() + path;
|
||||
println!("path = {path}");
|
||||
|
||||
HttpResponse::Ok().content_type("text/html").body(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Leptos Todos</title>
|
||||
<style>.pending {{ color: purple; }}</style>
|
||||
</head>
|
||||
<body>
|
||||
{}
|
||||
</body>
|
||||
<script type="module">import init, {{ hydrate }} from './pkg/todo_app_sqlite.js'; init().then(hydrate);</script>
|
||||
</html>"#,
|
||||
run_scope({
|
||||
move |cx| {
|
||||
let integration = ServerIntegration { path: path.clone() };
|
||||
provide_context(cx, RouterIntegrationContext::new(integration));
|
||||
|
||||
view! { cx, <TodoApp/>}
|
||||
}
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
#[post("/api/{tail:.*}")]
|
||||
async fn handle_server_fns(
|
||||
req: HttpRequest,
|
||||
params: web::Path<String>,
|
||||
body: web::Bytes,
|
||||
) -> impl Responder {
|
||||
let path = params.into_inner();
|
||||
let accept_header = req
|
||||
.headers()
|
||||
.get("Accept")
|
||||
.and_then(|value| value.to_str().ok());
|
||||
|
||||
if let Some(server_fn) = server_fn_by_path(path.as_str()) {
|
||||
let body: &[u8] = &body;
|
||||
match server_fn(&body).await {
|
||||
Ok(serialized) => {
|
||||
// if this is Accept: application/json then send a serialized JSON response
|
||||
if let Some("application/json") = accept_header {
|
||||
HttpResponse::Ok().body(serialized)
|
||||
}
|
||||
// otherwise, it's probably a <form> submit or something: redirect back to the referrer
|
||||
else {
|
||||
HttpResponse::SeeOther()
|
||||
.insert_header(("Location", "/"))
|
||||
.content_type("application/json")
|
||||
.body(serialized)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("server function error: {e:#?}");
|
||||
HttpResponse::InternalServerError().body(e.to_string())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpResponse::BadRequest().body(format!("Could not find a server function at that route."))
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let mut conn = db().await.expect("couldn't connect to DB");
|
||||
sqlx::migrate!()
|
||||
.run(&mut conn)
|
||||
.await
|
||||
.expect("could not run SQLx migrations");
|
||||
|
||||
crate::todo::register_server_functions();
|
||||
|
||||
HttpServer::new(|| {
|
||||
App::new()
|
||||
.service(Files::new("/pkg", "./pkg"))
|
||||
.service(handle_server_fns)
|
||||
.service(render)
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(("127.0.0.1", 8081))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
fn main() {
|
||||
// no client-side main function
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,195 @@
|
|||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use sqlx::{Connection, SqliteConnection};
|
||||
|
||||
pub async fn db() -> Result<SqliteConnection, ServerFnError> {
|
||||
Ok(SqliteConnection::connect("sqlite:Todos.db").await.map_err(|e| ServerFnError::ServerError(e.to_string()))?)
|
||||
}
|
||||
|
||||
pub fn register_server_functions() {
|
||||
GetTodos::register();
|
||||
AddTodo::register();
|
||||
DeleteTodo::register();
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Todo {
|
||||
id: u16,
|
||||
title: String,
|
||||
completed: bool,
|
||||
}
|
||||
} else {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Todo {
|
||||
id: u16,
|
||||
title: String,
|
||||
completed: bool,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server(GetTodos, "/api")]
|
||||
pub async fn get_todos() -> Result<Vec<Todo>, ServerFnError> {
|
||||
use futures::TryStreamExt;
|
||||
|
||||
let mut conn = db().await?;
|
||||
|
||||
let mut todos = Vec::new();
|
||||
let mut rows = sqlx::query_as::<_, Todo>("SELECT * FROM todos").fetch(&mut conn);
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?
|
||||
{
|
||||
todos.push(row);
|
||||
}
|
||||
|
||||
Ok(todos)
|
||||
}
|
||||
|
||||
#[server(AddTodo, "/api")]
|
||||
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
|
||||
let mut conn = db().await?;
|
||||
|
||||
// fake API delay
|
||||
std::thread::sleep(std::time::Duration::from_millis(1250));
|
||||
|
||||
match sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)")
|
||||
.bind(title)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(row) => Ok(()),
|
||||
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[server(DeleteTodo, "/api")]
|
||||
pub async fn delete_todo(id: u16) -> Result<(), ServerFnError> {
|
||||
let mut conn = db().await?;
|
||||
|
||||
sqlx::query("DELETE FROM todos WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TodoApp(cx: Scope) -> Element {
|
||||
view! {
|
||||
cx,
|
||||
<div>
|
||||
<Router>
|
||||
<header>
|
||||
<h1>"My Tasks"</h1>
|
||||
</header>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="" element=|cx| view! {
|
||||
cx,
|
||||
<Todos/>
|
||||
}/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Todos(cx: Scope) -> Element {
|
||||
let add_todo = create_server_multi_action::<AddTodo>(cx);
|
||||
let delete_todo = create_server_action::<DeleteTodo>(cx);
|
||||
let submissions = add_todo.submissions();
|
||||
|
||||
// track mutations that should lead us to refresh the list
|
||||
let add_changed = add_todo.version;
|
||||
let todo_deleted = delete_todo.version;
|
||||
|
||||
// list of todos is loaded from the server in reaction to changes
|
||||
let todos = create_resource(
|
||||
cx,
|
||||
move || (add_changed(), todo_deleted()),
|
||||
|_| get_todos()
|
||||
);
|
||||
|
||||
let existing_todos = {
|
||||
let delete_todo = delete_todo.clone();
|
||||
move || {
|
||||
todos
|
||||
.read()
|
||||
.map({
|
||||
let delete_todo = delete_todo.clone();
|
||||
move |todos| match todos {
|
||||
Err(e) => {
|
||||
vec![view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}]
|
||||
}
|
||||
Ok(todos) => {
|
||||
if todos.is_empty() {
|
||||
vec![view! { cx, <p>"No tasks were found."</p> }]
|
||||
} else {
|
||||
todos
|
||||
.into_iter()
|
||||
.map({
|
||||
let delete_todo = delete_todo.clone();
|
||||
move |todo| {
|
||||
let delete_todo = delete_todo.clone();
|
||||
view! {
|
||||
cx,
|
||||
<li>
|
||||
{todo.title}
|
||||
<ActionForm action=delete_todo.clone()>
|
||||
<input type="hidden" name="id" value={todo.id}/>
|
||||
<input type="submit" value="X"/>
|
||||
</ActionForm>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
};
|
||||
|
||||
let pending_todos = move || {
|
||||
submissions
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter(|submission| submission.pending().get())
|
||||
.map(|submission| {
|
||||
view! {
|
||||
cx,
|
||||
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
view! {
|
||||
cx,
|
||||
<div>
|
||||
<MultiActionForm action=add_todo>
|
||||
<label>
|
||||
"Add a Todo"
|
||||
<input type="text" name="title"/>
|
||||
</label>
|
||||
<input type="submit" value="Add"/>
|
||||
</MultiActionForm>
|
||||
<ul>
|
||||
<div>{existing_todos}</div>
|
||||
<div>{pending_todos}</div>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
use crate::{ServerFn, ServerFnError};
|
||||
use leptos_reactive::{create_rw_signal, spawn_local, ReadSignal, RwSignal, Scope};
|
||||
use std::{future::Future, pin::Pin, rc::Rc};
|
||||
|
||||
/// An action synchronizes an imperative `async` call to the synchronous reactive system.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [Resource](leptos_reactive::Resource) instead. If you’re trying to occasionally
|
||||
/// run an `async` function in response to something like a user clicking a button, you're in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let save_data = create_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// // the argument currently running
|
||||
/// let input = save_data.input();
|
||||
/// // the most recent returned result
|
||||
/// let result_of_call = save_data.value();
|
||||
/// // whether the call is pending
|
||||
/// let pending = save_data.pending();
|
||||
/// // how many times the action has run
|
||||
/// // useful for reactively updating something else in response to a `dispatch` and response
|
||||
/// let version = save_data.version;
|
||||
///
|
||||
/// // before we do anything
|
||||
/// assert_eq!(input(), None); // no argument yet
|
||||
/// assert_eq!(pending(), false); // isn't pending a response
|
||||
/// assert_eq!(result_of_call(), None); // there's no "last value"
|
||||
/// assert_eq!(version(), 0);
|
||||
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
|
||||
/// // dispatch the action
|
||||
/// save_data.dispatch("My todo".to_string());
|
||||
///
|
||||
/// // when we're making the call
|
||||
/// // assert_eq!(input(), Some("My todo".to_string()));
|
||||
/// // assert_eq!(pending(), true); // is pending
|
||||
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
|
||||
///
|
||||
/// // after call has resolved
|
||||
/// assert_eq!(input(), None); // input clears out after resolved
|
||||
/// assert_eq!(pending(), false); // no longer pending
|
||||
/// assert_eq!(result_of_call(), Some(42));
|
||||
/// assert_eq!(version(), 1);
|
||||
/// # }
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [Action::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// How many times the action has successfully resolved.
|
||||
pub version: RwSignal<usize>,
|
||||
/// The current argument that was dispatched to the `async` function.
|
||||
/// `Some` while we are waiting for it to resolve, `None` if it has resolved.
|
||||
pub input: RwSignal<Option<I>>,
|
||||
/// The most recent return value of the `async` function.
|
||||
pub value: RwSignal<Option<O>>,
|
||||
pending: RwSignal<bool>,
|
||||
url: Option<String>,
|
||||
#[allow(clippy::complexity)]
|
||||
action_fn: Rc<dyn Fn(&I) -> Pin<Box<dyn Future<Output = O>>>>,
|
||||
}
|
||||
|
||||
impl<I, O> Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// Calls the `async` function with a reference to the input type as its argument.
|
||||
pub fn dispatch(&self, input: I) {
|
||||
let fut = (self.action_fn)(&input);
|
||||
self.input.set(Some(input));
|
||||
let input = self.input;
|
||||
let version = self.version;
|
||||
let pending = self.pending;
|
||||
let value = self.value;
|
||||
pending.set(true);
|
||||
spawn_local(async move {
|
||||
let new_value = fut.await;
|
||||
input.set(None);
|
||||
pending.set(false);
|
||||
value.set(Some(new_value));
|
||||
version.update(|n| *n += 1);
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the action has been dispatched and is currently waiting for its future to be resolved.
|
||||
pub fn pending(&self) -> ReadSignal<bool> {
|
||||
self.pending.read_only()
|
||||
}
|
||||
|
||||
/// The URL associated with the action (typically as part of a server function.)
|
||||
/// This enables integration with the `ActionForm` component in `leptos_router`.
|
||||
pub fn url(&self) -> Option<&str> {
|
||||
self.url.as_deref()
|
||||
}
|
||||
|
||||
/// Associates the URL of the given server function with this action.
|
||||
/// This enables integration with the `ActionForm` component in `leptos_router`.
|
||||
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
|
||||
let prefix = T::prefix();
|
||||
self.url = if prefix.is_empty() {
|
||||
Some(T::url().to_string())
|
||||
} else {
|
||||
Some(prefix.to_string() + "/" + T::url())
|
||||
};
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [Action] to synchronize an imperative `async` call to the synchronous reactive system.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [create_resource](leptos_reactive::create_resource) instead. If you’re trying
|
||||
/// to occasionally run an `async` function in response to something like a user clicking a button,
|
||||
/// you're in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let save_data = create_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// // the argument currently running
|
||||
/// let input = save_data.input();
|
||||
/// // the most recent returned result
|
||||
/// let result_of_call = save_data.value();
|
||||
/// // whether the call is pending
|
||||
/// let pending = save_data.pending();
|
||||
/// // how many times the action has run
|
||||
/// // useful for reactively updating something else in response to a `dispatch` and response
|
||||
/// let version = save_data.version;
|
||||
///
|
||||
/// // before we do anything
|
||||
/// assert_eq!(input(), None); // no argument yet
|
||||
/// assert_eq!(pending(), false); // isn't pending a response
|
||||
/// assert_eq!(result_of_call(), None); // there's no "last value"
|
||||
/// assert_eq!(version(), 0);
|
||||
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
|
||||
/// // dispatch the action
|
||||
/// save_data.dispatch("My todo".to_string());
|
||||
///
|
||||
/// // when we're making the call
|
||||
/// // assert_eq!(input(), Some("My todo".to_string()));
|
||||
/// // assert_eq!(pending(), true); // is pending
|
||||
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
|
||||
///
|
||||
/// // after call has resolved
|
||||
/// assert_eq!(input(), None); // input clears out after resolved
|
||||
/// assert_eq!(pending(), false); // no longer pending
|
||||
/// assert_eq!(result_of_call(), Some(42));
|
||||
/// assert_eq!(version(), 1);
|
||||
/// # }
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [Action::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_action<I, O, F, Fu>(cx: Scope, action_fn: F) -> Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
F: Fn(&I) -> Fu + 'static,
|
||||
Fu: Future<Output = O> + 'static,
|
||||
{
|
||||
let version = create_rw_signal(cx, 0);
|
||||
let input = create_rw_signal(cx, None);
|
||||
let value = create_rw_signal(cx, None);
|
||||
let pending = create_rw_signal(cx, false);
|
||||
let action_fn = Rc::new(move |input: &I| {
|
||||
let fut = action_fn(input);
|
||||
Box::pin(async move { fut.await }) as Pin<Box<dyn Future<Output = O>>>
|
||||
});
|
||||
|
||||
Action {
|
||||
version,
|
||||
url: None,
|
||||
input,
|
||||
value,
|
||||
pending,
|
||||
action_fn,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [Action] that can be used to call a server function.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::{create_server_action, ServerFnError, ServerFn};
|
||||
/// # use leptos_macro::server;
|
||||
///
|
||||
/// #[server(MyServerFn)]
|
||||
/// async fn my_server_fn() -> Result<(), ServerFnError> {
|
||||
/// todo!()
|
||||
/// }
|
||||
///
|
||||
/// # run_scope(|cx| {
|
||||
/// let my_server_action = create_server_action::<MyServerFn>(cx);
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_server_action<S>(cx: Scope) -> Action<S, Result<S::Output, ServerFnError>>
|
||||
where
|
||||
S: Clone + ServerFn,
|
||||
{
|
||||
#[cfg(feature = "ssr")]
|
||||
let c = |args: &S| S::call_fn(args.clone());
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
let c = |args: &S| S::call_fn_client(args.clone());
|
||||
create_action(cx, c).using_server_fn::<S>()
|
||||
}
|
|
@ -62,9 +62,14 @@
|
|||
pub use form_urlencoded;
|
||||
use leptos_reactive::*;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use std::{future::Future, pin::Pin, rc::Rc};
|
||||
use std::{future::Future, pin::Pin};
|
||||
use thiserror::Error;
|
||||
|
||||
mod action;
|
||||
mod multi_action;
|
||||
pub use action::*;
|
||||
pub use multi_action::*;
|
||||
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
|
@ -264,297 +269,3 @@ where
|
|||
|
||||
T::from_json(&text).map_err(|e| ServerFnError::Deserialization(e.to_string()))
|
||||
}
|
||||
|
||||
/// An action synchronizes an imperative `async` call to the synchronous reactive system.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [Resource](leptos_reactive::Resource) instead. If you’re trying to occasionally
|
||||
/// run an `async` function in response to something like a user clicking a button, you're in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let save_data = create_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// // the argument currently running
|
||||
/// let input = save_data.input();
|
||||
/// // the most recent returned result
|
||||
/// let result_of_call = save_data.value();
|
||||
/// // whether the call is pending
|
||||
/// let pending = save_data.pending();
|
||||
/// // how many times the action has run
|
||||
/// // useful for reactively updating something else in response to a `dispatch` and response
|
||||
/// let version = save_data.version;
|
||||
///
|
||||
/// // before we do anything
|
||||
/// assert_eq!(input(), None); // no argument yet
|
||||
/// assert_eq!(pending(), false); // isn't pending a response
|
||||
/// assert_eq!(result_of_call(), None); // there's no "last value"
|
||||
/// assert_eq!(version(), 0);
|
||||
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
|
||||
/// // dispatch the action
|
||||
/// save_data.dispatch("My todo".to_string());
|
||||
///
|
||||
/// // when we're making the call
|
||||
/// // assert_eq!(input(), Some("My todo".to_string()));
|
||||
/// // assert_eq!(pending(), true); // is pending
|
||||
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
|
||||
///
|
||||
/// // after call has resolved
|
||||
/// assert_eq!(input(), None); // input clears out after resolved
|
||||
/// assert_eq!(pending(), false); // no longer pending
|
||||
/// assert_eq!(result_of_call(), Some(42));
|
||||
/// assert_eq!(version(), 1);
|
||||
/// # }
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [Action::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// How many times the action has successfully resolved.
|
||||
pub version: RwSignal<usize>,
|
||||
input: RwSignal<Option<I>>,
|
||||
value: RwSignal<Option<O>>,
|
||||
pending: RwSignal<bool>,
|
||||
url: Option<String>,
|
||||
#[allow(clippy::complexity)]
|
||||
action_fn: Rc<dyn Fn(&I) -> Pin<Box<dyn Future<Output = O>>>>,
|
||||
}
|
||||
|
||||
impl<I, O> Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// Calls the server function a reference to the input type as its argument.
|
||||
pub fn dispatch(&self, input: I) {
|
||||
let fut = (self.action_fn)(&input);
|
||||
self.input.set(Some(input));
|
||||
let input = self.input;
|
||||
let version = self.version;
|
||||
let pending = self.pending;
|
||||
let value = self.value;
|
||||
pending.set(true);
|
||||
spawn_local(async move {
|
||||
let new_value = fut.await;
|
||||
input.set(None);
|
||||
pending.set(false);
|
||||
value.set(Some(new_value));
|
||||
version.update(|n| *n += 1);
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the action has been dispatched and is currently waiting for its future to be resolved.
|
||||
pub fn pending(&self) -> ReadSignal<bool> {
|
||||
self.pending.read_only()
|
||||
}
|
||||
|
||||
/// The argument that was dispatched to the `async` function,
|
||||
/// only while we are waiting for it to resolve.
|
||||
pub fn input(&self) -> ReadSignal<Option<I>> {
|
||||
self.input.read_only()
|
||||
}
|
||||
|
||||
/// The argument that was dispatched to the `async` function.
|
||||
///
|
||||
/// You probably don't need to call this unless you are implementing a form
|
||||
/// or some other kind of wrapper for an action and need to set the input
|
||||
/// based on its internal logic.
|
||||
pub fn set_input(&self, value: I) {
|
||||
self.input.set(Some(value));
|
||||
}
|
||||
|
||||
/// The most recent return value of the `async` function.
|
||||
pub fn value(&self) -> ReadSignal<Option<O>> {
|
||||
self.value.read_only()
|
||||
}
|
||||
|
||||
/// Sets the most recent return value of the `async` function.
|
||||
///
|
||||
/// You probably don't need to call this unless you are implementing a form
|
||||
/// or some other kind of wrapper for an action and need to set the value
|
||||
/// based on its internal logic.
|
||||
pub fn set_value(&self, value: O) {
|
||||
self.value.set(Some(value));
|
||||
}
|
||||
|
||||
/// The URL associated with the action (typically as part of a server function.)
|
||||
/// This enables integration with the `ActionForm` component in `leptos_router`.
|
||||
pub fn url(&self) -> Option<&str> {
|
||||
self.url.as_deref()
|
||||
}
|
||||
|
||||
/// Associates the URL of the given server function with this action.
|
||||
/// This enables integration with the `ActionForm` component in `leptos_router`.
|
||||
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
|
||||
let prefix = T::prefix();
|
||||
self.url = if prefix.is_empty() {
|
||||
Some(T::url().to_string())
|
||||
} else {
|
||||
Some(prefix.to_string() + "/" + T::url())
|
||||
};
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [Action] to synchronize an imperative `async` call to the synchronous reactive system.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [create_resource](leptos_reactive::create_resource) instead. If you’re trying
|
||||
/// to occasionally run an `async` function in response to something like a user clicking a button,
|
||||
/// you're in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let save_data = create_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// // the argument currently running
|
||||
/// let input = save_data.input();
|
||||
/// // the most recent returned result
|
||||
/// let result_of_call = save_data.value();
|
||||
/// // whether the call is pending
|
||||
/// let pending = save_data.pending();
|
||||
/// // how many times the action has run
|
||||
/// // useful for reactively updating something else in response to a `dispatch` and response
|
||||
/// let version = save_data.version;
|
||||
///
|
||||
/// // before we do anything
|
||||
/// assert_eq!(input(), None); // no argument yet
|
||||
/// assert_eq!(pending(), false); // isn't pending a response
|
||||
/// assert_eq!(result_of_call(), None); // there's no "last value"
|
||||
/// assert_eq!(version(), 0);
|
||||
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
|
||||
/// // dispatch the action
|
||||
/// save_data.dispatch("My todo".to_string());
|
||||
///
|
||||
/// // when we're making the call
|
||||
/// // assert_eq!(input(), Some("My todo".to_string()));
|
||||
/// // assert_eq!(pending(), true); // is pending
|
||||
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
|
||||
///
|
||||
/// // after call has resolved
|
||||
/// assert_eq!(input(), None); // input clears out after resolved
|
||||
/// assert_eq!(pending(), false); // no longer pending
|
||||
/// assert_eq!(result_of_call(), Some(42));
|
||||
/// assert_eq!(version(), 1);
|
||||
/// # }
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [Action::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_action<I, O, F, Fu>(cx: Scope, action_fn: F) -> Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
F: Fn(&I) -> Fu + 'static,
|
||||
Fu: Future<Output = O> + 'static,
|
||||
{
|
||||
let version = create_rw_signal(cx, 0);
|
||||
let input = create_rw_signal(cx, None);
|
||||
let value = create_rw_signal(cx, None);
|
||||
let pending = create_rw_signal(cx, false);
|
||||
let action_fn = Rc::new(move |input: &I| {
|
||||
let fut = action_fn(input);
|
||||
Box::pin(async move { fut.await }) as Pin<Box<dyn Future<Output = O>>>
|
||||
});
|
||||
|
||||
Action {
|
||||
version,
|
||||
url: None,
|
||||
input,
|
||||
value,
|
||||
pending,
|
||||
action_fn,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [Action] that can be used to call a server function.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::{create_server_action, ServerFnError, ServerFn};
|
||||
/// # use leptos_macro::server;
|
||||
///
|
||||
/// #[server(MyServerFn)]
|
||||
/// async fn my_server_fn() -> Result<(), ServerFnError> {
|
||||
/// todo!()
|
||||
/// }
|
||||
///
|
||||
/// # run_scope(|cx| {
|
||||
/// let my_server_action = create_server_action::<MyServerFn>(cx);
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_server_action<S>(cx: Scope) -> Action<S, Result<S::Output, ServerFnError>>
|
||||
where
|
||||
S: Clone + ServerFn,
|
||||
{
|
||||
#[cfg(feature = "ssr")]
|
||||
let c = |args: &S| S::call_fn(args.clone());
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
let c = |args: &S| S::call_fn_client(args.clone());
|
||||
create_action(cx, c).using_server_fn::<S>()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,276 @@
|
|||
use crate::{ServerFn, ServerFnError};
|
||||
use leptos_reactive::{create_rw_signal, spawn_local, ReadSignal, RwSignal, Scope};
|
||||
use std::{future::Future, pin::Pin, rc::Rc};
|
||||
|
||||
/// An action that synchronizes multiple imperative `async` calls to the reactive system,
|
||||
/// tracking the progress of each one.
|
||||
///
|
||||
/// Where an [Action](crate::Action) fires a single call, a `MultiAction` allows you to
|
||||
/// keep track of multiple in-flight actions.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [Resource](leptos_reactive::Resource) instead. If you’re trying to occasionally
|
||||
/// run an `async` function in response to something like a user adding a task to a todo list,
|
||||
/// you’re in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_multi_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let add_todo = create_multi_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// add_todo.dispatch("Buy milk".to_string());
|
||||
/// add_todo.dispatch("???".to_string());
|
||||
/// add_todo.dispatch("Profit!!!".to_string());
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [MultiAction::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_multi_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_multi_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_multi_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_multi_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct MultiAction<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
cx: Scope,
|
||||
/// How many times an action has successfully resolved.
|
||||
pub version: RwSignal<usize>,
|
||||
submissions: RwSignal<Vec<Submission<I, O>>>,
|
||||
url: Option<String>,
|
||||
#[allow(clippy::complexity)]
|
||||
action_fn: Rc<dyn Fn(&I) -> Pin<Box<dyn Future<Output = O>>>>,
|
||||
}
|
||||
|
||||
/// An action that has been submitted by dispatching it to a [MultiAction](crate::MultiAction).
|
||||
pub struct Submission<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// The current argument that was dispatched to the `async` function.
|
||||
/// `Some` while we are waiting for it to resolve, `None` if it has resolved.
|
||||
pub input: RwSignal<Option<I>>,
|
||||
/// The most recent return value of the `async` function.
|
||||
pub value: RwSignal<Option<O>>,
|
||||
pub(crate) pending: RwSignal<bool>,
|
||||
/// Controls this submission has been canceled.
|
||||
pub canceled: RwSignal<bool>,
|
||||
}
|
||||
|
||||
impl<I, O> Clone for Submission<I, O> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
input: self.input,
|
||||
value: self.value,
|
||||
pending: self.pending,
|
||||
canceled: self.canceled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I, O> Copy for Submission<I, O> {}
|
||||
|
||||
impl<I, O> Submission<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// Whether this submission is currently waiting to resolve.
|
||||
pub fn pending(&self) -> ReadSignal<bool> {
|
||||
self.pending.read_only()
|
||||
}
|
||||
|
||||
/// Cancels the submission, preventing it from resolving.
|
||||
pub fn cancel(&self) {
|
||||
self.canceled.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
impl<I, O> MultiAction<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// Calls the `async` function with a reference to the input type as its argument.
|
||||
pub fn dispatch(&self, input: I) {
|
||||
let cx = self.cx;
|
||||
let fut = (self.action_fn)(&input);
|
||||
|
||||
let submission = Submission {
|
||||
input: create_rw_signal(cx, Some(input)),
|
||||
value: create_rw_signal(cx, None),
|
||||
pending: create_rw_signal(cx, true),
|
||||
canceled: create_rw_signal(cx, false),
|
||||
};
|
||||
|
||||
self.submissions.update(|subs| subs.push(submission));
|
||||
|
||||
let canceled = submission.canceled;
|
||||
let input = submission.input;
|
||||
let pending = submission.pending;
|
||||
let value = submission.value;
|
||||
let version = self.version;
|
||||
|
||||
spawn_local(async move {
|
||||
let new_value = fut.await;
|
||||
let canceled = cx.untrack(move || canceled.get());
|
||||
input.set(None);
|
||||
pending.set(false);
|
||||
if !canceled {
|
||||
value.set(Some(new_value));
|
||||
}
|
||||
version.update(|n| *n += 1);
|
||||
})
|
||||
}
|
||||
|
||||
/// The set of all submissions to this multi-action.
|
||||
pub fn submissions(&self) -> ReadSignal<Vec<Submission<I, O>>> {
|
||||
self.submissions.read_only()
|
||||
}
|
||||
|
||||
/// The URL associated with the action (typically as part of a server function.)
|
||||
/// This enables integration with the `MultiActionForm` component in `leptos_router`.
|
||||
pub fn url(&self) -> Option<&str> {
|
||||
self.url.as_deref()
|
||||
}
|
||||
|
||||
/// Associates the URL of the given server function with this action.
|
||||
/// This enables integration with the `MultiActionForm` component in `leptos_router`.
|
||||
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
|
||||
let prefix = T::prefix();
|
||||
self.url = if prefix.is_empty() {
|
||||
Some(T::url().to_string())
|
||||
} else {
|
||||
Some(prefix.to_string() + "/" + T::url())
|
||||
};
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [MultiAction] to synchronize an imperative `async` call to the synchronous reactive system.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [create_resource](leptos_reactive::create_resource) instead. If you’re trying
|
||||
/// to occasionally run an `async` function in response to something like a user clicking a button,
|
||||
/// you're in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_multi_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let add_todo = create_multi_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// add_todo.dispatch("Buy milk".to_string());
|
||||
/// add_todo.dispatch("???".to_string());
|
||||
/// add_todo.dispatch("Profit!!!".to_string());
|
||||
///
|
||||
/// assert_eq!(add_todo.submissions().get().len(), 3);
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [MultiAction::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_multi_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_multi_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_multi_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_multi_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_multi_action<I, O, F, Fu>(cx: Scope, action_fn: F) -> MultiAction<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
F: Fn(&I) -> Fu + 'static,
|
||||
Fu: Future<Output = O> + 'static,
|
||||
{
|
||||
let version = create_rw_signal(cx, 0);
|
||||
let submissions = create_rw_signal(cx, Vec::new());
|
||||
let action_fn = Rc::new(move |input: &I| {
|
||||
let fut = action_fn(input);
|
||||
Box::pin(async move { fut.await }) as Pin<Box<dyn Future<Output = O>>>
|
||||
});
|
||||
|
||||
MultiAction {
|
||||
cx,
|
||||
version,
|
||||
submissions,
|
||||
url: None,
|
||||
action_fn,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [MultiAction] that can be used to call a server function.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::{create_server_multi_action, ServerFnError, ServerFn};
|
||||
/// # use leptos_macro::server;
|
||||
///
|
||||
/// #[server(MyServerFn)]
|
||||
/// async fn my_server_fn() -> Result<(), ServerFnError> {
|
||||
/// todo!()
|
||||
/// }
|
||||
///
|
||||
/// # run_scope(|cx| {
|
||||
/// let my_server_multi_action = create_server_multi_action::<MyServerFn>(cx);
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_server_multi_action<S>(cx: Scope) -> MultiAction<S, Result<S::Output, ServerFnError>>
|
||||
where
|
||||
S: Clone + ServerFn,
|
||||
{
|
||||
#[cfg(feature = "ssr")]
|
||||
let c = |args: &S| S::call_fn(args.clone());
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
let c = |args: &S| S::call_fn_client(args.clone());
|
||||
create_multi_action(cx, c).using_server_fn::<S>()
|
||||
}
|
|
@ -23,6 +23,7 @@ urlencoding = "2"
|
|||
thiserror = "1"
|
||||
typed-builder = "0.10"
|
||||
serde_urlencoded = "0.7"
|
||||
serde = "1"
|
||||
js-sys = { version = "0.3" }
|
||||
wasm-bindgen = { version = "0.2" }
|
||||
wasm-bindgen-futures = { version = "0.4" }
|
||||
|
|
|
@ -69,93 +69,9 @@ where
|
|||
return;
|
||||
}
|
||||
ev.prevent_default();
|
||||
let submitter = ev.unchecked_ref::<web_sys::SubmitEvent>().submitter();
|
||||
let navigate = use_navigate(cx);
|
||||
|
||||
let (form, method, action, enctype) = match &submitter {
|
||||
Some(el) => {
|
||||
if let Some(form) = el.dyn_ref::<web_sys::HtmlFormElement>() {
|
||||
(
|
||||
form.clone(),
|
||||
form.get_attribute("method")
|
||||
.unwrap_or_else(|| "get".to_string())
|
||||
.to_lowercase(),
|
||||
form.get_attribute("action")
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.to_lowercase(),
|
||||
form.get_attribute("enctype")
|
||||
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
|
||||
.to_lowercase(),
|
||||
)
|
||||
} else if let Some(input) = el.dyn_ref::<web_sys::HtmlInputElement>() {
|
||||
let form = ev
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlFormElement>();
|
||||
(
|
||||
form.clone(),
|
||||
input.get_attribute("method").unwrap_or_else(|| {
|
||||
form.get_attribute("method")
|
||||
.unwrap_or_else(|| "get".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
input.get_attribute("action").unwrap_or_else(|| {
|
||||
form.get_attribute("action")
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
input.get_attribute("enctype").unwrap_or_else(|| {
|
||||
form.get_attribute("enctype")
|
||||
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
)
|
||||
} else if let Some(button) = el.dyn_ref::<web_sys::HtmlButtonElement>() {
|
||||
let form = ev
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlFormElement>();
|
||||
(
|
||||
form.clone(),
|
||||
button.get_attribute("method").unwrap_or_else(|| {
|
||||
form.get_attribute("method")
|
||||
.unwrap_or_else(|| "get".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
button.get_attribute("action").unwrap_or_else(|| {
|
||||
form.get_attribute("action")
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
button.get_attribute("enctype").unwrap_or_else(|| {
|
||||
form.get_attribute("enctype")
|
||||
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
leptos_dom::debug_warn!("<Form/> cannot be submitted from a tag other than <form>, <input>, or <button>");
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
None => match ev.target() {
|
||||
None => {
|
||||
leptos_dom::debug_warn!("<Form/> SubmitEvent fired without a target.");
|
||||
panic!()
|
||||
}
|
||||
Some(form) => {
|
||||
let form = form.unchecked_into::<web_sys::HtmlFormElement>();
|
||||
(
|
||||
form.clone(),
|
||||
form.get_attribute("method")
|
||||
.unwrap_or_else(|| "get".to_string()),
|
||||
form.get_attribute("action").unwrap_or_default(),
|
||||
form.get_attribute("enctype")
|
||||
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string()),
|
||||
)
|
||||
}
|
||||
},
|
||||
};
|
||||
let (form, method, action, enctype) = extract_form_attributes(&ev);
|
||||
|
||||
let form_data = web_sys::FormData::new_with_form(&form).unwrap_throw();
|
||||
if let Some(on_form_data) = on_form_data.clone() {
|
||||
|
@ -258,16 +174,15 @@ where
|
|||
""
|
||||
}.to_string();
|
||||
let version = props.action.version;
|
||||
let value = props.action.value;
|
||||
let input = props.action.input;
|
||||
|
||||
let on_form_data = {
|
||||
let action = props.action.clone();
|
||||
Rc::new(move |form_data: &web_sys::FormData| {
|
||||
let data =
|
||||
web_sys::UrlSearchParams::new_with_str_sequence_sequence(&form_data).unwrap_throw();
|
||||
let data = data.to_string().as_string().unwrap_or_default();
|
||||
let data = serde_urlencoded::from_str::<I>(&data);
|
||||
let data = action_input_from_form_data(form_data);
|
||||
match data {
|
||||
Ok(data) => action.set_input(data),
|
||||
Ok(data) => input.set(Some(data)),
|
||||
Err(e) => log::error!("{e}"),
|
||||
}
|
||||
})
|
||||
|
@ -291,9 +206,9 @@ where
|
|||
match O::from_json(
|
||||
&json.as_string().expect("couldn't get String from JsString"),
|
||||
) {
|
||||
Ok(res) => action.set_value(Ok(res)),
|
||||
Ok(res) => value.set(Some(Ok(res))),
|
||||
Err(e) => {
|
||||
action.set_value(Err(ServerFnError::Deserialization(e.to_string())))
|
||||
value.set(Some(Err(ServerFnError::Deserialization(e.to_string()))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -315,3 +230,167 @@ where
|
|||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Properties that can be passed to the [MultiActionForm] component, which
|
||||
/// automatically turns a server [MultiAction](leptos_server::MultiAction) into an HTML
|
||||
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct MultiActionFormProps<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// The action from which to build the form. This should include a URL, which can be generated
|
||||
/// by default using [create_server_action](leptos_server::create_server_action) or added
|
||||
/// manually using [leptos_server::Action::using_server_fn].
|
||||
pub action: MultiAction<I, Result<O, ServerFnError>>,
|
||||
/// Component children; should include the HTML of the form elements.
|
||||
pub children: Box<dyn Fn() -> Vec<Element>>,
|
||||
}
|
||||
|
||||
/// Automatically turns a server [MultiAction](leptos_server::MultiAction) into an HTML
|
||||
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn MultiActionForm<I, O>(cx: Scope, props: MultiActionFormProps<I, O>) -> Element
|
||||
where
|
||||
I: Clone + ServerFn + 'static,
|
||||
O: Clone + Serializable + 'static,
|
||||
{
|
||||
let multi_action = props.action;
|
||||
let action = if let Some(url) = multi_action.url() {
|
||||
url
|
||||
} else {
|
||||
debug_warn!("<MultiActionForm/> action needs a URL. Either use create_server_action() or Action::using_server_fn().");
|
||||
""
|
||||
}.to_string();
|
||||
|
||||
let on_submit = move |ev: web_sys::Event| {
|
||||
if ev.default_prevented() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (form, method, action, enctype) = extract_form_attributes(&ev);
|
||||
|
||||
let form_data = web_sys::FormData::new_with_form(&form).unwrap_throw();
|
||||
let data = action_input_from_form_data(&form_data);
|
||||
match data {
|
||||
Err(e) => log::error!("{e}"),
|
||||
Ok(input) => {
|
||||
ev.prevent_default();
|
||||
multi_action.dispatch(input);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let children = (props.children)();
|
||||
|
||||
view! { cx,
|
||||
<form
|
||||
method="POST"
|
||||
action=action
|
||||
on:submit=on_submit
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_form_attributes(
|
||||
ev: &web_sys::Event,
|
||||
) -> (web_sys::HtmlFormElement, String, String, String) {
|
||||
let submitter = ev.unchecked_ref::<web_sys::SubmitEvent>().submitter();
|
||||
match &submitter {
|
||||
Some(el) => {
|
||||
if let Some(form) = el.dyn_ref::<web_sys::HtmlFormElement>() {
|
||||
(
|
||||
form.clone(),
|
||||
form.get_attribute("method")
|
||||
.unwrap_or_else(|| "get".to_string())
|
||||
.to_lowercase(),
|
||||
form.get_attribute("action")
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.to_lowercase(),
|
||||
form.get_attribute("enctype")
|
||||
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
|
||||
.to_lowercase(),
|
||||
)
|
||||
} else if let Some(input) = el.dyn_ref::<web_sys::HtmlInputElement>() {
|
||||
let form = ev
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlFormElement>();
|
||||
(
|
||||
form.clone(),
|
||||
input.get_attribute("method").unwrap_or_else(|| {
|
||||
form.get_attribute("method")
|
||||
.unwrap_or_else(|| "get".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
input.get_attribute("action").unwrap_or_else(|| {
|
||||
form.get_attribute("action")
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
input.get_attribute("enctype").unwrap_or_else(|| {
|
||||
form.get_attribute("enctype")
|
||||
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
)
|
||||
} else if let Some(button) = el.dyn_ref::<web_sys::HtmlButtonElement>() {
|
||||
let form = ev
|
||||
.target()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlFormElement>();
|
||||
(
|
||||
form.clone(),
|
||||
button.get_attribute("method").unwrap_or_else(|| {
|
||||
form.get_attribute("method")
|
||||
.unwrap_or_else(|| "get".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
button.get_attribute("action").unwrap_or_else(|| {
|
||||
form.get_attribute("action")
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
button.get_attribute("enctype").unwrap_or_else(|| {
|
||||
form.get_attribute("enctype")
|
||||
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string())
|
||||
.to_lowercase()
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
leptos_dom::debug_warn!("<Form/> cannot be submitted from a tag other than <form>, <input>, or <button>");
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
None => match ev.target() {
|
||||
None => {
|
||||
leptos_dom::debug_warn!("<Form/> SubmitEvent fired without a target.");
|
||||
panic!()
|
||||
}
|
||||
Some(form) => {
|
||||
let form = form.unchecked_into::<web_sys::HtmlFormElement>();
|
||||
(
|
||||
form.clone(),
|
||||
form.get_attribute("method")
|
||||
.unwrap_or_else(|| "get".to_string()),
|
||||
form.get_attribute("action").unwrap_or_default(),
|
||||
form.get_attribute("enctype")
|
||||
.unwrap_or_else(|| "application/x-www-form-urlencoded".to_string()),
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn action_input_from_form_data<I: serde::de::DeserializeOwned>(
|
||||
form_data: &web_sys::FormData,
|
||||
) -> Result<I, serde_urlencoded::de::Error> {
|
||||
let data = web_sys::UrlSearchParams::new_with_str_sequence_sequence(&form_data).unwrap_throw();
|
||||
let data = data.to_string().as_string().unwrap_or_default();
|
||||
serde_urlencoded::from_str::<I>(&data)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue