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#[derive(Clone, Copy, Debug, Display)]
23pub enum Translation {
24 Submit,
25 Preview,
26 Edit,
27 Language,
28 Menu,
29}
30
31#[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#[derive(Clone, Display)]
44pub enum SubmitState {
45 Initial,
46 Pending,
47 Error(SubmitError),
48 Success,
49}
50
51#[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 pub fn data(&self) -> &Data {
72 &self.form_data
73 }
74
75 pub fn meta_data(&self) -> &MetaData {
77 &self.meta_data
78 }
79}
80
81#[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 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#[component]
178pub fn NovaForm<ServFn, L, K>(
179 on_submit: Action<ServFn, Result<(), ServerFnError>>,
181 #[prop(into)] bind: QueryStringPart,
183 #[prop(into)] bind_meta_data: QueryString,
185 i18n: I18nContext<L, K>,
188 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 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 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 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 <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#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct MetaData {
372 pub locale: String,
373 pub local_utc_offset: UtcOffset,
374}
375
376#[macro_export]
378macro_rules! init_nova_forms {
379 ( $( $base_url:literal )? ) => {
380 leptos_i18n::load_locales!();
382 use i18n::*;
383
384 #[component]
385 pub fn AppContextProvider(
386 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 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 <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 provide_context($crate::RenderContext::new(&form_data, meta_data));
448
449 view! {
450 <AppContextProvider>
451 {
452 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}