use crate::{IntoView, children::Children, component, prelude::*};
use leptos_dom::helpers::window;
use leptos_server::{ServerAction, ServerMultiAction};
use serde::de::DeserializeOwned;
use server_fn::{
Http, ServerFn,
client::Client,
codec::PostUrl,
error::{IntoAppError, ServerFnErrorErr},
request::ClientReq,
};
use tachys::{
either::Either,
html::{
element::{Form, form},
event::submit,
},
reactive_graph::node_ref::NodeRef,
};
use thiserror::Error;
use wasm_bindgen::{JsCast, JsValue, UnwrapThrowExt, prelude::wasm_bindgen};
use web_sys::{Event, FormData, HtmlElement, HtmlFormElement, SubmitEvent};
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(extends = FormData, js_name = FormData)]
type FormDataWithSubmitter;
#[wasm_bindgen(constructor, catch, js_class = "FormData")]
fn new_with_form_and_submitter(
form: &HtmlFormElement,
submitter: &HtmlElement,
) -> Result<FormDataWithSubmitter, JsValue>;
}
pub fn form_data_with_submitter(
form: &HtmlFormElement,
submitter: Option<&HtmlElement>,
) -> Result<FormData, JsValue> {
match submitter {
Some(submitter) => {
FormDataWithSubmitter::new_with_form_and_submitter(form, submitter)
.map(|data| data.unchecked_into())
}
None => FormData::new_with_form(form),
}
}
#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip_all))]
#[component]
pub fn ActionForm<ServFn, OutputProtocol>(
action: ServerAction<ServFn>,
#[prop(optional)]
node_ref: Option<NodeRef<Form>>,
children: Children,
) -> impl IntoView
where
ServFn: DeserializeOwned
+ ServerFn<Protocol = Http<PostUrl, OutputProtocol>>
+ Clone
+ Send
+ Sync
+ 'static,
<<ServFn::Client as Client<ServFn::Error>>::Request as ClientReq<
ServFn::Error,
>>::FormData: From<FormData>,
ServFn: Send + Sync + 'static,
ServFn::Output: Send + Sync + 'static,
ServFn::Error: Send + Sync + 'static,
<ServFn as ServerFn>::Client: Client<<ServFn as ServerFn>::Error>,
{
_ = server_fn::redirect::set_redirect_hook(|loc: &str| {
if let Some(url) = resolve_redirect_url(loc) {
_ = window().location().set_href(&url.href());
}
});
let version = action.version();
let value = action.value();
let on_submit = {
move |ev: SubmitEvent| {
if ev.default_prevented() {
return;
}
ev.prevent_default();
match ServFn::from_event(&ev) {
Ok(new_input) => {
action.dispatch(new_input);
}
Err(err) => {
crate::logging::error!(
"Error converting form field into server function \
arguments: {err:?}"
);
value.set(Some(Err(ServerFnErrorErr::Serialization(
err.to_string(),
)
.into_app_error())));
version.update(|n| *n += 1);
}
}
}
};
let action_form = form()
.action(ServFn::url())
.method("post")
.on(submit, on_submit)
.child(children());
if let Some(node_ref) = node_ref {
Either::Left(action_form.node_ref(node_ref))
} else {
Either::Right(action_form)
}
}
#[component]
pub fn MultiActionForm<ServFn, OutputProtocol>(
action: ServerMultiAction<ServFn>,
#[prop(optional)]
node_ref: Option<NodeRef<Form>>,
children: Children,
) -> impl IntoView
where
ServFn: Send
+ Sync
+ Clone
+ DeserializeOwned
+ ServerFn<Protocol = Http<PostUrl, OutputProtocol>>
+ 'static,
ServFn::Output: Send + Sync + 'static,
<<ServFn::Client as Client<ServFn::Error>>::Request as ClientReq<
ServFn::Error,
>>::FormData: From<FormData>,
ServFn::Error: Send + Sync + 'static,
<ServFn as ServerFn>::Client: Client<<ServFn as ServerFn>::Error>,
{
_ = server_fn::redirect::set_redirect_hook(|loc: &str| {
if let Some(url) = resolve_redirect_url(loc) {
_ = window().location().set_href(&url.href());
}
});
let on_submit = move |ev: SubmitEvent| {
if ev.default_prevented() {
return;
}
ev.prevent_default();
match ServFn::from_event(&ev) {
Ok(new_input) => {
action.dispatch(new_input);
}
Err(err) => {
action.dispatch_sync(Err(ServerFnErrorErr::Serialization(
err.to_string(),
)
.into_app_error()));
}
}
};
let action_form = form()
.action(ServFn::url())
.method("post")
.attr("method", "post")
.on(submit, on_submit)
.child(children());
if let Some(node_ref) = node_ref {
Either::Left(action_form.node_ref(node_ref))
} else {
Either::Right(action_form)
}
}
pub(crate) fn resolve_redirect_url(loc: &str) -> Option<web_sys::Url> {
let origin = match window().location().origin() {
Ok(origin) => origin,
Err(e) => {
leptos::logging::error!("Failed to get origin: {:#?}", e);
return None;
}
};
let base = origin;
match web_sys::Url::new_with_base(loc, &base) {
Ok(url) => Some(url),
Err(e) => {
leptos::logging::error!(
"Invalid redirect location: {}",
e.as_string().unwrap_or_default(),
);
None
}
}
}
pub trait FromFormData
where
Self: Sized + serde::de::DeserializeOwned,
{
fn from_event(ev: &web_sys::Event) -> Result<Self, FromFormDataError>;
fn from_form_data(
form_data: &web_sys::FormData,
) -> Result<Self, serde_qs::Error>;
}
#[derive(Error, Debug)]
pub enum FromFormDataError {
#[error("Could not find <form> connected to event.")]
MissingForm(Event),
#[error("Could not create FormData from <form>: {0:?}")]
FormData(JsValue),
#[error("Deserialization error: {0:?}")]
Deserialization(serde_qs::Error),
}
impl<T> FromFormData for T
where
T: serde::de::DeserializeOwned,
{
fn from_event(ev: &Event) -> Result<Self, FromFormDataError> {
let submit_ev = ev.unchecked_ref();
let form_data = form_data_from_event(submit_ev)?;
Self::from_form_data(&form_data)
.map_err(FromFormDataError::Deserialization)
}
fn from_form_data(
form_data: &web_sys::FormData,
) -> Result<Self, serde_qs::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_qs::Config::new()
.use_form_encoding(true)
.deserialize_str::<Self>(&data)
}
}
pub fn form_data_from_event(
ev: &SubmitEvent,
) -> Result<FormData, FromFormDataError> {
let form = ev
.target()
.and_then(|target| target.dyn_into::<HtmlFormElement>().ok())
.ok_or_else(|| FromFormDataError::MissingForm(ev.clone().into()))?;
form_data_with_submitter(&form, ev.submitter().as_ref())
.map_err(FromFormDataError::FormData)
}