leptos/
form.rs

1use crate::{children::Children, component, prelude::*, IntoView};
2use leptos_dom::helpers::window;
3use leptos_server::{ServerAction, ServerMultiAction};
4use serde::de::DeserializeOwned;
5use server_fn::{
6    client::Client,
7    codec::PostUrl,
8    error::{IntoAppError, ServerFnErrorErr},
9    request::ClientReq,
10    Http, ServerFn,
11};
12use tachys::{
13    either::Either,
14    html::{
15        element::{form, Form},
16        event::submit,
17    },
18    reactive_graph::node_ref::NodeRef,
19};
20use thiserror::Error;
21use wasm_bindgen::{JsCast, JsValue, UnwrapThrowExt};
22use web_sys::{
23    Event, FormData, HtmlButtonElement, HtmlFormElement, HtmlInputElement,
24    SubmitEvent,
25};
26
27/// Automatically turns a server [Action](leptos_server::Action) into an HTML
28/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
29/// progressively enhanced to use client-side routing.
30///
31/// ## Encoding
32/// **Note:** `<ActionForm/>` only works with server functions that use the
33/// default `Url` encoding. This is to ensure that `<ActionForm/>` works correctly
34/// both before and after WASM has loaded.
35///
36/// ## Complex Inputs
37/// Server function arguments that are structs with nested serializable fields
38/// should make use of indexing notation of `serde_qs`.
39///
40/// ```rust
41/// # use leptos::prelude::*;
42/// use leptos::form::ActionForm;
43///
44/// #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
45/// struct HeftyData {
46///     first_name: String,
47///     last_name: String,
48/// }
49///
50/// #[component]
51/// fn ComplexInput() -> impl IntoView {
52///     let submit = ServerAction::<VeryImportantFn>::new();
53///
54///     view! {
55///       <ActionForm action=submit>
56///         <input type="text" name="hefty_arg[first_name]" value="leptos"/>
57///         <input
58///           type="text"
59///           name="hefty_arg[last_name]"
60///           value="closures-everywhere"
61///         />
62///         <input type="submit"/>
63///       </ActionForm>
64///     }
65/// }
66///
67/// #[server]
68/// async fn very_important_fn(
69///     hefty_arg: HeftyData,
70/// ) -> Result<(), ServerFnError> {
71///     assert_eq!(hefty_arg.first_name.as_str(), "leptos");
72///     assert_eq!(hefty_arg.last_name.as_str(), "closures-everywhere");
73///     Ok(())
74/// }
75/// ```
76#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip_all))]
77#[component]
78pub fn ActionForm<ServFn, OutputProtocol>(
79    /// The action from which to build the form.
80    action: ServerAction<ServFn>,
81    /// A [`NodeRef`] in which the `<form>` element should be stored.
82    #[prop(optional)]
83    node_ref: Option<NodeRef<Form>>,
84    /// Component children; should include the HTML of the form elements.
85    children: Children,
86) -> impl IntoView
87where
88    ServFn: DeserializeOwned
89        + ServerFn<Protocol = Http<PostUrl, OutputProtocol>>
90        + Clone
91        + Send
92        + Sync
93        + 'static,
94    <<ServFn::Client as Client<ServFn::Error>>::Request as ClientReq<
95        ServFn::Error,
96    >>::FormData: From<FormData>,
97    ServFn: Send + Sync + 'static,
98    ServFn::Output: Send + Sync + 'static,
99    ServFn::Error: Send + Sync + 'static,
100    <ServFn as ServerFn>::Client: Client<<ServFn as ServerFn>::Error>,
101{
102    // if redirect hook has not yet been set (by a router), defaults to a browser redirect
103    _ = server_fn::redirect::set_redirect_hook(|loc: &str| {
104        if let Some(url) = resolve_redirect_url(loc) {
105            _ = window().location().set_href(&url.href());
106        }
107    });
108
109    let version = action.version();
110    let value = action.value();
111
112    let on_submit = {
113        move |ev: SubmitEvent| {
114            if ev.default_prevented() {
115                return;
116            }
117
118            ev.prevent_default();
119
120            match ServFn::from_event(&ev) {
121                Ok(new_input) => {
122                    action.dispatch(new_input);
123                }
124                Err(err) => {
125                    crate::logging::error!(
126                        "Error converting form field into server function \
127                         arguments: {err:?}"
128                    );
129                    value.set(Some(Err(ServerFnErrorErr::Serialization(
130                        err.to_string(),
131                    )
132                    .into_app_error())));
133                    version.update(|n| *n += 1);
134                }
135            }
136        }
137    };
138
139    let action_form = form()
140        .action(ServFn::url())
141        .method("post")
142        .on(submit, on_submit)
143        .child(children());
144    if let Some(node_ref) = node_ref {
145        Either::Left(action_form.node_ref(node_ref))
146    } else {
147        Either::Right(action_form)
148    }
149}
150
151/// Automatically turns a server [MultiAction](leptos_server::MultiAction) into an HTML
152/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
153/// progressively enhanced to use client-side routing.
154#[component]
155pub fn MultiActionForm<ServFn, OutputProtocol>(
156    /// The action from which to build the form.
157    action: ServerMultiAction<ServFn>,
158    /// A [`NodeRef`] in which the `<form>` element should be stored.
159    #[prop(optional)]
160    node_ref: Option<NodeRef<Form>>,
161    /// Component children; should include the HTML of the form elements.
162    children: Children,
163) -> impl IntoView
164where
165    ServFn: Send
166        + Sync
167        + Clone
168        + DeserializeOwned
169        + ServerFn<Protocol = Http<PostUrl, OutputProtocol>>
170        + 'static,
171    ServFn::Output: Send + Sync + 'static,
172    <<ServFn::Client as Client<ServFn::Error>>::Request as ClientReq<
173        ServFn::Error,
174    >>::FormData: From<FormData>,
175    ServFn::Error: Send + Sync + 'static,
176    <ServFn as ServerFn>::Client: Client<<ServFn as ServerFn>::Error>,
177{
178    // if redirect hook has not yet been set (by a router), defaults to a browser redirect
179    _ = server_fn::redirect::set_redirect_hook(|loc: &str| {
180        if let Some(url) = resolve_redirect_url(loc) {
181            _ = window().location().set_href(&url.href());
182        }
183    });
184
185    let on_submit = move |ev: SubmitEvent| {
186        if ev.default_prevented() {
187            return;
188        }
189
190        ev.prevent_default();
191
192        match ServFn::from_event(&ev) {
193            Ok(new_input) => {
194                action.dispatch(new_input);
195            }
196            Err(err) => {
197                action.dispatch_sync(Err(ServerFnErrorErr::Serialization(
198                    err.to_string(),
199                )
200                .into_app_error()));
201            }
202        }
203    };
204
205    let action_form = form()
206        .action(ServFn::url())
207        .method("post")
208        .attr("method", "post")
209        .on(submit, on_submit)
210        .child(children());
211    if let Some(node_ref) = node_ref {
212        Either::Left(action_form.node_ref(node_ref))
213    } else {
214        Either::Right(action_form)
215    }
216}
217
218/// Resolves a redirect location to an (absolute) URL.
219pub(crate) fn resolve_redirect_url(loc: &str) -> Option<web_sys::Url> {
220    let origin = match window().location().origin() {
221        Ok(origin) => origin,
222        Err(e) => {
223            leptos::logging::error!("Failed to get origin: {:#?}", e);
224            return None;
225        }
226    };
227
228    // TODO: Use server function's URL as base instead.
229    let base = origin;
230
231    match web_sys::Url::new_with_base(loc, &base) {
232        Ok(url) => Some(url),
233        Err(e) => {
234            leptos::logging::error!(
235                "Invalid redirect location: {}",
236                e.as_string().unwrap_or_default(),
237            );
238            None
239        }
240    }
241}
242
243/// Tries to deserialize a type from form data. This can be used for client-side
244/// validation during form submission.
245pub trait FromFormData
246where
247    Self: Sized + serde::de::DeserializeOwned,
248{
249    /// Tries to deserialize the data, given only the `submit` event.
250    fn from_event(ev: &web_sys::Event) -> Result<Self, FromFormDataError>;
251
252    /// Tries to deserialize the data, given the actual form data.
253    fn from_form_data(
254        form_data: &web_sys::FormData,
255    ) -> Result<Self, serde_qs::Error>;
256}
257
258/// Errors that can arise when converting from an HTML event or form into a Rust data type.
259#[derive(Error, Debug)]
260pub enum FromFormDataError {
261    /// Could not find a `<form>` connected to the event.
262    #[error("Could not find <form> connected to event.")]
263    MissingForm(Event),
264    /// Could not create `FormData` from the form.
265    #[error("Could not create FormData from <form>: {0:?}")]
266    FormData(JsValue),
267    /// Failed to deserialize this Rust type from the form data.
268    #[error("Deserialization error: {0:?}")]
269    Deserialization(serde_qs::Error),
270}
271
272impl<T> FromFormData for T
273where
274    T: serde::de::DeserializeOwned,
275{
276    fn from_event(ev: &Event) -> Result<Self, FromFormDataError> {
277        let submit_ev = ev.unchecked_ref();
278        let form_data = form_data_from_event(submit_ev)?;
279        Self::from_form_data(&form_data)
280            .map_err(FromFormDataError::Deserialization)
281    }
282
283    fn from_form_data(
284        form_data: &web_sys::FormData,
285    ) -> Result<Self, serde_qs::Error> {
286        let data =
287            web_sys::UrlSearchParams::new_with_str_sequence_sequence(form_data)
288                .unwrap_throw();
289        let data = data.to_string().as_string().unwrap_or_default();
290        serde_qs::Config::new(5, false).deserialize_str::<Self>(&data)
291    }
292}
293
294fn form_data_from_event(
295    ev: &SubmitEvent,
296) -> Result<FormData, FromFormDataError> {
297    let submitter = ev.submitter();
298    let mut submitter_name_value = None;
299    let opt_form = match &submitter {
300        Some(el) => {
301            if let Some(form) = el.dyn_ref::<HtmlFormElement>() {
302                Some(form.clone())
303            } else if let Some(input) = el.dyn_ref::<HtmlInputElement>() {
304                submitter_name_value = Some((input.name(), input.value()));
305                Some(ev.target().unwrap().unchecked_into())
306            } else if let Some(button) = el.dyn_ref::<HtmlButtonElement>() {
307                submitter_name_value = Some((button.name(), button.value()));
308                Some(ev.target().unwrap().unchecked_into())
309            } else {
310                None
311            }
312        }
313        None => ev.target().map(|form| form.unchecked_into()),
314    };
315    match opt_form.as_ref().map(FormData::new_with_form) {
316        None => Err(FromFormDataError::MissingForm(ev.clone().into())),
317        Some(Err(e)) => Err(FromFormDataError::FormData(e)),
318        Some(Ok(form_data)) => {
319            if let Some((name, value)) = submitter_name_value {
320                form_data
321                    .append_with_str(&name, &value)
322                    .map_err(FromFormDataError::FormData)?;
323            }
324            Ok(form_data)
325        }
326    }
327}