use ev::SubmitEvent;
use leptos::*;
use leptos_i18n::*;
use leptos_meta::Style;
use leptos_router::*;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use server_fn::{
client::Client, codec::PostUrl, error::NoCustomError, request::ClientReq, ServerFn,
};
use strum::Display;
use time::UtcOffset;
use ustr::Ustr;
use std::{fmt::Debug, marker::PhantomData, path::{Path, PathBuf}, str::FromStr};
use thiserror::Error;
use crate::{
local_utc_offset, use_translation, DialogKind, FormData, GroupContext, Modal, QueryString, APP_CSS, PRINT_CSS, VARIABLES_CSS
};
#[derive(Clone, Copy, Debug, Display)]
pub enum Translation {
Submit,
Preview,
Edit,
Language,
Menu,
}
#[derive(Error, Debug, Clone)]
pub enum SubmitError {
#[error("the form contains errors")]
ValidationError,
#[error("the form contains errors")]
ParseError,
#[error("a server error occurred: {0}")]
ServerError(ServerFnError),
}
#[derive(Clone, Display)]
pub enum SubmitState {
Initial,
Pending,
Error(SubmitError),
Success,
}
#[derive(Debug, Clone)]
pub struct RenderContext {
form_data: FormData,
meta_data: MetaData,
}
impl RenderContext {
pub fn new<F>(form_data: &F, meta_data: MetaData) -> Self
where
F: Serialize,
{
Self {
meta_data,
form_data: FormData::serialize(form_data),
}
}
pub fn form_data(&self) -> &FormData {
&self.form_data
}
pub fn meta_data(&self) -> &MetaData {
&self.meta_data
}
}
#[derive(Debug, Clone)]
pub struct AppContext {
base_url: PathBuf,
}
impl AppContext {
pub fn new(base_url: PathBuf) -> Self {
Self { base_url }
}
pub fn base_url(&self) -> &PathBuf {
&self.base_url
}
pub fn resolve_path<P: AsRef<Path>>(&self, path: P) -> String {
let mut path = path.as_ref().to_owned();
if path.is_absolute() {
path = path.strip_prefix("/").unwrap().to_owned();
}
if use_context::<RenderContext>().is_some() {
format!("{}", expect_context::<SiteRoot>().0.join(path).display())
} else {
format!("{}", self.base_url.join(path).display())
}
}
}
#[test]
fn test_base_context_resolve_path() {
let base_context = AppContext::new(PathBuf::from("/"));
assert_eq!(base_context.resolve_path("/pkg/app.css"), "/pkg/app.css");
assert_eq!(base_context.resolve_path("pkg/app.css"), "/pkg/app.css");
assert_eq!(base_context.resolve_path("app.css"), "/app.css");
assert_eq!(base_context.resolve_path("/app.css"), "/app.css");
let base_context = AppContext::new(PathBuf::from("/site"));
assert_eq!(base_context.resolve_path("/pkg/app.css"), "/site/pkg/app.css");
assert_eq!(base_context.resolve_path("pkg/app.css"), "/site/pkg/app.css");
assert_eq!(base_context.resolve_path("app.css"), "/site/app.css");
assert_eq!(base_context.resolve_path("/app.css"), "/site/app.css");
}
#[derive(Debug, Clone)]
pub struct SiteRoot(PathBuf);
impl From<PathBuf> for SiteRoot {
fn from(path: PathBuf) -> Self {
Self(path)
}
}
#[derive(Debug, Clone, Copy)]
pub struct FormContext {
form_id: Ustr,
preview: RwSignal<bool>,
}
impl FormContext {
pub fn is_render_mode(&self) -> bool {
self.preview.get() || use_context::<RenderContext>().is_some()
}
pub fn is_preview_mode(&self) -> bool {
self.preview.get()
}
pub fn is_edit_mode(&self) -> bool {
!self.is_preview_mode()
}
pub fn preview_mode(&self) {
self.preview.set(true);
}
pub fn edit_mode(&self) {
self.preview.set(false);
}
pub fn form_id(&self) -> &str {
self.form_id.as_str()
}
}
#[component]
pub fn NovaForm<ServFn, L, K>(
on_submit: Action<ServFn, Result<(), ServerFnError>>,
#[prop(into)] bind: QueryString,
#[prop(into)] bind_meta_data: QueryString,
i18n: I18nContext<L, K>,
children: Children,
#[prop(optional)] _arg: PhantomData<ServFn>,
) -> impl IntoView
where
ServFn: DeserializeOwned + Serialize
+ ServerFn<InputEncoding = PostUrl, Error = NoCustomError, Output = ()>
+ 'static,
<<ServFn::Client as Client<ServFn::Error>>::Request as ClientReq<ServFn::Error>>::FormData:
From<web_sys::FormData>,
L: Locale + 'static,
<L as FromStr>::Err: Debug,
K: LocaleKeys<Locale = L> + 'static,
{
if cfg!(debug_assertions) {
logging::log!("debug mode enabled, prefilling input fields with valid data");
}
let render_context = use_context::<RenderContext>();
let form_data_serialized = if let Some(render_context) = &render_context {
render_context.form_data().clone()
} else {
FormData::default()
};
let group = GroupContext::new(bind);
provide_context(group);
provide_context(form_data_serialized.clone());
let preview = create_rw_signal(false);
let form_id = Ustr::from("nova-form");
let nova_form_context = FormContext { preview, form_id };
provide_context(nova_form_context);
let (submit_state, set_submit_state) = create_signal(SubmitState::Initial);
let on_submit_value = on_submit.value();
create_effect(move |_| match on_submit_value.get() {
Some(Ok(_)) => set_submit_state.set(SubmitState::Success),
Some(Err(err)) => set_submit_state.set(SubmitState::Error(SubmitError::ServerError(err))),
None => {}
});
let value = on_submit.value();
let on_submit_inner = {
move |ev: SubmitEvent| {
if ev.default_prevented() {
return;
}
ev.prevent_default();
let is_dialog = ev
.submitter()
.and_then(|el| el.get_attribute("formmethod"))
.as_deref()
== Some("dialog");
if is_dialog {
return;
}
let do_submit = ev
.submitter()
.unwrap()
.get_attribute("type")
.map(|attr| attr == "submit")
.unwrap_or(false);
if !do_submit {
return;
}
group.validate();
if group.error() {
set_submit_state.set(SubmitState::Error(SubmitError::ValidationError));
return;
}
match ServFn::from_event(&ev) {
Ok(new_input) => {
set_submit_state.set(SubmitState::Pending);
on_submit.dispatch(new_input);
}
Err(err) => {
set_submit_state.set(SubmitState::Error(SubmitError::ParseError));
logging::error!(
"Error converting form field into server function \
arguments: {err:?}"
);
batch(move || {
value.set(Some(Err(ServerFnError::Serialization(err.to_string()))));
});
}
}
}
};
view! {
<Style>{VARIABLES_CSS}</Style>
<Style>{APP_CSS}</Style>
{if use_context::<RenderContext>().is_some() {
view! { <Style>{PRINT_CSS}</Style> }.into_view()
} else {
View::default()
}}
<form
id=form_id.as_str()
novalidate
action=""
on:submit=on_submit_inner
class=move || if preview.get() { "hidden" } else { "visible" }
>
{children()}
<input
type="hidden"
name=bind_meta_data.clone().add_key("locale")
value=move || i18n.get_locale().to_string()
/>
<input
type="hidden"
name=bind_meta_data.clone().add_key("local_utc_offset")
value=move || local_utc_offset().to_string()
/>
</form>
<Modal
id="submit-pending"
open=Signal::derive(move || matches!(submit_state.get(), SubmitState::Pending))
kind=DialogKind::Info
title={use_translation(Translation::Submit)}
msg={use_translation::<SubmitState, _>(submit_state)}
/>
<Modal
id="submit-error"
open=Signal::derive(move || matches!(submit_state.get(), SubmitState::Error(_)))
kind=DialogKind::Error
title={use_translation(Translation::Submit)}
msg={use_translation::<SubmitState, _>(submit_state)}
close=move |()| set_submit_state.set(SubmitState::Initial)
/>
<Modal
id="submit-success"
open=Signal::derive(move || matches!(submit_state.get(), SubmitState::Success))
kind=DialogKind::Success
title={use_translation(Translation::Submit)}
msg={use_translation::<SubmitState, _>(submit_state)}
close=move |()| set_submit_state.set(SubmitState::Initial)
/>
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaData {
pub locale: String,
pub local_utc_offset: UtcOffset,
}
#[macro_export]
macro_rules! init_nova_forms {
( $( $base_url:literal )? ) => {
leptos_i18n::load_locales!();
use i18n::*;
#[component]
pub fn AppContextProvider(
children: leptos::Children,
) -> impl leptos::IntoView {
use std::str::FromStr;
use std::path::PathBuf;
use leptos::*;
use leptos_meta::*;
provide_meta_context();
#[allow(unused_mut)]
let mut base_url = PathBuf::from("/");
$( base_url = PathBuf::from($base_url); )?
let base_context = $crate::AppContext::new(base_url.clone());
provide_context(base_context.clone());
view! {
<I18nContextProvider>
{
view! {
{children()}
}
}
</I18nContextProvider>
<Link rel="preload" as_="style" href=base_context.resolve_path("pkg/app.css") />
<Link
rel="preload"
as_="style"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,1,0"
/>
<Stylesheet id="leptos" href=base_context.resolve_path("pkg/app.css") />
<Link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,1,0"
/>
}
}
#[component]
pub fn RenderContextProvider<F>(
form_data: F,
meta_data: MetaData,
children: leptos::Children,
) -> impl leptos::IntoView
where
F: serde::Serialize + 'static,
{
use std::str::FromStr;
use leptos::*;
use leptos_meta::*;
let locale = meta_data.locale.clone();
provide_context($crate::RenderContext::new(&form_data, meta_data));
view! {
<AppContextProvider>
{
let i18n = use_i18n();
i18n.set_locale(i18n::Locale::from_str(&locale).unwrap());
let base_context = expect_context::<$crate::AppContext>();
view! {
{children()}
<Stylesheet href=base_context.resolve_path("print.css") />
}
}
</AppContextProvider>
}
}
};
}