nova_forms/components/
nova_form.rs

1use ev::SubmitEvent;
2use leptos::*;
3use leptos_i18n::*;
4use leptos_meta::Style;
5use leptos_router::*;
6use serde::{de::DeserializeOwned, Deserialize, Serialize};
7use server_fn::{
8    client::Client, codec::PostUrl, error::NoCustomError, request::ClientReq, ServerFn,
9};
10use strum::Display;
11use time::UtcOffset;
12use ustr::Ustr;
13use std::{fmt::Debug, marker::PhantomData, path::{Path, PathBuf}, str::FromStr};
14use thiserror::Error;
15
16use crate::{
17    local_utc_offset, qs, use_translation, BaseGroupContext, Data, DialogKind, FormData, Group, Modal, QueryString, QueryStringPart, APP_CSS, PRINT_CSS, VARIABLES_CSS
18};
19
20/// Can be used to provide custom translations.
21/// If not provided, the default english translations will be used.
22#[derive(Clone, Copy, Debug, Display)]
23pub enum Translation {
24    Submit,
25    Preview,
26    Edit,
27    Language,
28    Menu,
29}
30
31/// Possible errors that can occur when submitting a form.
32#[derive(Error, Debug, Clone)]
33pub enum SubmitError {
34    #[error("the form contains errors")]
35    ValidationError,
36    #[error("the form contains errors")]
37    ParseError,
38    #[error("a server error occurred: {0}")]
39    ServerError(ServerFnError),
40}
41
42/// The current state of the form submission.
43#[derive(Clone, Display)]
44pub enum SubmitState {
45    Initial,
46    Pending,
47    Error(SubmitError),
48    Success,
49}
50
51/// The context that is used to render the form.
52/// This context is only available in the backend.
53#[derive(Debug, Clone)]
54pub struct RenderContext {
55    form_data: Data,
56    meta_data: MetaData,
57}
58
59impl RenderContext {
60    pub fn new<F>(form_data: &F, meta_data: MetaData) -> Self
61    where
62        F: Serialize,
63    {
64        Self {
65            meta_data,
66            form_data: Data::from(form_data),
67        }
68    }
69
70    /// The form data is used to fill the form with data.
71    pub fn data(&self) -> &Data {
72        &self.form_data
73    }
74
75    /// The meta data is used to set the locale of the form.
76    pub fn meta_data(&self) -> &MetaData {
77        &self.meta_data
78    }
79}
80
81/// The base context provides general information about the environment.
82#[derive(Debug, Clone)]
83pub struct AppContext {
84    base_url: PathBuf,
85}
86
87impl AppContext {
88    pub fn new(base_url: PathBuf) -> Self {
89        Self { base_url }
90    }
91
92    /// The base context is used to resolve paths.
93    pub fn base_url(&self) -> &PathBuf {
94        &self.base_url
95    }
96
97    pub fn resolve_path<P: AsRef<Path>>(&self, path: P) -> String {
98        let mut path = path.as_ref().to_owned();
99        if path.is_absolute() {
100           path = path.strip_prefix("/").unwrap().to_owned();
101        }
102        if use_context::<RenderContext>().is_some() {
103            format!("{}", expect_context::<SiteRoot>().0.join(path).display())
104        } else {
105            format!("{}", self.base_url.join(path).display())
106        }
107    }
108}
109
110#[test]
111fn test_base_context_resolve_path() {
112    let base_context = AppContext::new(PathBuf::from("/"));
113    assert_eq!(base_context.resolve_path("/pkg/app.css"), "/pkg/app.css");
114    assert_eq!(base_context.resolve_path("pkg/app.css"), "/pkg/app.css");
115    assert_eq!(base_context.resolve_path("app.css"), "/app.css");
116    assert_eq!(base_context.resolve_path("/app.css"), "/app.css");
117
118    let base_context = AppContext::new(PathBuf::from("/site"));
119    assert_eq!(base_context.resolve_path("/pkg/app.css"), "/site/pkg/app.css");
120    assert_eq!(base_context.resolve_path("pkg/app.css"), "/site/pkg/app.css");
121    assert_eq!(base_context.resolve_path("app.css"), "/site/app.css");
122    assert_eq!(base_context.resolve_path("/app.css"), "/site/app.css");
123}
124
125#[derive(Debug, Clone)]
126pub struct SiteRoot(PathBuf);
127
128impl From<PathBuf> for SiteRoot {
129    fn from(path: PathBuf) -> Self {
130        Self(path)
131    }
132}
133
134#[derive(Debug, Clone, Copy)]
135pub struct FormContext {
136    form_id: Ustr,
137    preview: RwSignal<bool>,
138}
139
140impl FormContext {
141    pub fn new(form_id: &'static str) -> Self {
142        Self {
143            form_id: Ustr::from(form_id),
144            preview: create_rw_signal(false),
145        }
146    }
147
148    pub fn is_render_mode(&self) -> bool {
149        self.preview.get() || use_context::<RenderContext>().is_some()
150    }
151
152    pub fn is_preview_mode(&self) -> bool {
153        self.preview.get()
154    }
155
156    pub fn is_edit_mode(&self) -> bool {
157        !self.is_preview_mode()
158    }
159
160    pub fn preview_mode(&self) {
161        self.preview.set(true);
162    }
163
164    pub fn edit_mode(&self) {
165        self.preview.set(false);
166    }
167
168    pub fn form_id(&self) -> &str {
169        self.form_id.as_str()
170    }
171}
172
173/// Creates a new nova form.
174/// The form will automatically handle validation, serialization, and submission.
175/// This implicitly creates a HTML form tag that contains your entire form.
176/// It also provides a toolbar with a page select, locale select, preview button, and submit button.
177#[component]
178pub fn NovaForm<ServFn, L, K>(
179    /// The server function that will be called when the form is submitted.
180    on_submit: Action<ServFn, Result<(), ServerFnError>>,
181    /// The query string that binds the form to the form data.
182    #[prop(into)] bind: QueryStringPart,
183    /// The query string that binds the form to the metadata.
184    #[prop(into)] bind_meta_data: QueryString,
185    /// The i18n context.
186    /// This is used to set the locale of the form in the toolbar.
187    i18n: I18nContext<L, K>,
188    /// The content of the form.
189    children: Children,
190    #[prop(optional)] _arg: PhantomData<ServFn>,
191) -> impl IntoView
192where
193    ServFn: DeserializeOwned + Serialize
194        + ServerFn<InputEncoding = PostUrl, Error = NoCustomError, Output = ()>
195        + 'static,
196    <<ServFn::Client as Client<ServFn::Error>>::Request as ClientReq<ServFn::Error>>::FormData:
197        From<web_sys::FormData>,
198    L: Locale + 'static,
199    <L as FromStr>::Err: Debug,
200    K: LocaleKeys<Locale = L> + 'static,
201{
202    if cfg!(debug_assertions) {
203        logging::log!("debug mode enabled, prefilling input fields with valid data");
204    }
205
206    let render_context = use_context::<RenderContext>();
207    let group = BaseGroupContext::new();
208    provide_context(group);
209    provide_context(group.to_group_context());
210
211    let form_data = if let Some(render_context) = render_context {
212        FormData::from_data(render_context.data().clone())
213    } else {
214        FormData::new()
215    };
216
217    create_effect(move |_| {
218        logging::log!("form data changed: {}", form_data.get(qs!()).get().unwrap().to_urlencoded());
219    });
220
221    provide_context(form_data);
222
223    let preview = create_rw_signal(false);
224    let form_id = Ustr::from("nova-form");
225    let nova_form_context = FormContext { preview, form_id };
226    provide_context(nova_form_context);
227
228    let (submit_state, set_submit_state) = create_signal(SubmitState::Initial);
229
230    let on_submit_value = on_submit.value();
231    create_effect(move |_| match on_submit_value.get() {
232        Some(Ok(_)) => set_submit_state.set(SubmitState::Success),
233        Some(Err(err)) => set_submit_state.set(SubmitState::Error(SubmitError::ServerError(err))),
234        None => {}
235    });
236
237    let value = on_submit.value();
238    
239    let on_submit_inner = {
240        move |ev: SubmitEvent| {
241            if ev.default_prevented() {
242                return;
243            }
244            ev.prevent_default();
245
246            // <button formmethod="dialog"> should *not* dispatch the action, but should be allowed to
247            // just bubble up and close the <dialog> naturally
248            let is_dialog = ev
249                .submitter()
250                .and_then(|el| el.get_attribute("formmethod"))
251                .as_deref()
252                == Some("dialog");
253            if is_dialog {
254                return;
255            }
256
257            // Do not submit the form if the submit button is not the one that was clicked.
258            let do_submit = ev
259                .submitter()
260                .unwrap()
261                .get_attribute("type")
262                .map(|attr| attr == "submit")
263                .unwrap_or(false);
264            if !do_submit {
265                return;
266            }
267
268            //trigger_validation.set(Instant::now());
269            /*if let Some(input_data) = inputs_context.get().has_errors() {
270                set_submit_state.set(SubmitState::Error(SubmitError::ValidationError));
271
272                if let Some(pages_context) = use_context::<RwSignal<PagesContext>>() {
273                    pages_context.update(|pages_context| pages_context.select(input_data.page_id.clone()));
274                }
275                return;
276            }*/
277
278            group.validate();
279            if group.error().get_untracked() {
280                set_submit_state.set(SubmitState::Error(SubmitError::ValidationError));
281                return;
282            }
283
284
285
286            match ServFn::from_event(&ev) {
287                Ok(new_input) => {
288                    set_submit_state.set(SubmitState::Pending);
289                    on_submit.dispatch(new_input);
290                }
291                Err(err) => {
292                    set_submit_state.set(SubmitState::Error(SubmitError::ParseError));
293                    logging::error!(
294                        "Error converting form field into server function \
295                         arguments: {err:?}"
296                    );
297                    batch(move || {
298                        value.set(Some(Err(ServerFnError::Serialization(err.to_string()))));
299                    });
300                }
301            }
302        }
303    };
304    view! {
305        <Style>{VARIABLES_CSS}</Style>
306        <Style>{APP_CSS}</Style>
307        {if use_context::<RenderContext>().is_some() {
308            view! { <Style>{PRINT_CSS}</Style> }.into_view()
309        } else {
310            View::default()
311        }}
312
313        <form
314            id=form_id.as_str()
315            novalidate
316            action=""
317            on:submit=on_submit_inner
318            class=move || if preview.get() { "hidden" } else { "visible" }
319        >
320            <Group bind=bind>
321                {children()}
322            </Group>
323
324            // Add the metadata using hidden fields.
325            <input
326                type="hidden"
327                name=bind_meta_data.add_key("locale")
328                value=move || i18n.get_locale().to_string()
329            />
330            <input
331                type="hidden"
332                name=bind_meta_data.add_key("local_utc_offset")
333                value=move || local_utc_offset().to_string()
334            />
335        </form> 
336
337    
338        <Modal
339            id="submit-pending"
340            open=Signal::derive(move || matches!(submit_state.get(), SubmitState::Pending))
341            kind=DialogKind::Info
342            title={use_translation(Translation::Submit)}
343            msg={use_translation::<SubmitState, _>(submit_state)}
344        />
345
346        <Modal
347            id="submit-error"
348            open=Signal::derive(move || matches!(submit_state.get(), SubmitState::Error(_)))
349            kind=DialogKind::Error
350            title={use_translation(Translation::Submit)}
351            msg={use_translation::<SubmitState, _>(submit_state)}
352            close=move |()| set_submit_state.set(SubmitState::Initial)
353        />
354    
355        <Modal
356            id="submit-success"
357            open=Signal::derive(move || matches!(submit_state.get(), SubmitState::Success))
358            kind=DialogKind::Success
359            title={use_translation(Translation::Submit)}
360            msg={use_translation::<SubmitState, _>(submit_state)}
361            close=move |()| set_submit_state.set(SubmitState::Initial)
362        />
363    }
364}
365
366/// The metadata of the form.
367/// This contains useful information about the client environment.
368/// The `locale` identifies the language of the client.
369/// The `local_utc_offset` identifies the timezone of the client.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct MetaData {
372    pub locale: String,
373    pub local_utc_offset: UtcOffset,
374}
375
376/// Initializes the Nova Forms `AppContextProvider` and `RenderContextProvider`.
377#[macro_export]
378macro_rules! init_nova_forms {
379    ( $( $base_url:literal )? ) => {
380        // Initializes the locales for the form.
381        leptos_i18n::load_locales!();
382        use i18n::*;
383
384        #[component]
385        pub fn AppContextProvider(
386            //#[prop(into, optional)] base_url: Option<String>,
387            children: leptos::Children,
388        ) -> impl leptos::IntoView {
389            use std::str::FromStr;
390            use std::path::PathBuf;
391            use leptos::*;
392            use leptos_meta::*;
393
394            // Provides context that manages stylesheets, titles, meta tags, etc.
395            provide_meta_context();
396
397            #[allow(unused_mut)]
398            let mut base_url = PathBuf::from("/");
399            $( base_url = PathBuf::from($base_url); )?
400
401            let base_context = $crate::AppContext::new(base_url.clone());
402            provide_context(base_context.clone());
403
404            view! {
405
406                <I18nContextProvider>
407                    {
408                        view! {
409                            {children()}
410                        }
411                    }
412                </I18nContextProvider>
413
414                // Injects a stylesheet into the document <head>.
415                // id=leptos means cargo-leptos will hot-reload this stylesheet.
416                // Preload the stylesheet to make sure it is loaded before the page is rendered.
417                <Link rel="preload" as_="style" href=base_context.resolve_path("pkg/app.css") />
418                <Link
419                    rel="preload"
420                    as_="style"
421                    href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,1,0"
422                />
423                <Stylesheet id="leptos" href=base_context.resolve_path("pkg/app.css") />
424                <Link
425                    rel="stylesheet"
426                    href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,1,0"
427                />
428            }
429        }
430
431        #[component]
432        pub fn RenderContextProvider<F>(
433            form_data: F,
434            meta_data: MetaData,
435            children: leptos::Children,
436        ) -> impl leptos::IntoView
437        where
438            F: serde::Serialize + 'static,
439        {
440            use std::str::FromStr;
441            use leptos::*;
442            use leptos_meta::*;
443
444            let locale = meta_data.locale.clone();
445
446            // Adds the render context.
447            provide_context($crate::RenderContext::new(&form_data, meta_data));
448                        
449            view! {
450                <AppContextProvider>
451                    {
452                        // Sets the locale from the meta data.
453                        let i18n = use_i18n();
454                        i18n.set_locale(i18n::Locale::from_str(&locale).unwrap());
455                        
456                        let base_context = expect_context::<$crate::AppContext>();
457                      
458                        view! {
459                            {children()}
460
461                            <Stylesheet href=base_context.resolve_path("print.css") />
462                        }
463                    }
464                </AppContextProvider>
465            }
466        }
467    };
468}