use topcoat::{
Result,
context::Cx,
icon::icon,
router::{Body, error::forbidden},
view::{BoxView, StaticClass, ViewExt, attributes, class, view},
};
use super::{
super::{
bar::{ActionBar, record_bar},
gate::{gate, return_target},
},
related::{open_related, render_related},
};
pub(super) use crate::form::VERSION_FIELD;
use crate::{
form::{FieldErrors, FormValues},
panel::record_nav::{RecordNavLink, record_navigation, render_record_navigation},
resource::{Mounted, Places, PublicLink, Resource},
schema::{FormMask, Schema},
t,
topcoat_compat::async_page,
};
const FOOTER: StaticClass = class!("flex items-center gap-2");
const PICK_FOOTER: StaticClass = class!(
"sticky bottom-0 z-10 -mx-6 -mb-6 flex items-center gap-2 bg-card px-6 pt-2 pb-6",
"before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-4",
"before:bg-linear-to-t before:from-card before:to-transparent",
);
pub(crate) struct FormChrome<'a> {
title: String,
submit_label: String,
destructive: bool,
public_link: Option<PublicLink>,
actions: ActionBar,
cancel: String,
hidden: Vec<(String, String)>,
options: Option<String>,
editing: bool,
another: bool,
seed: Vec<(String, String)>,
relations: Vec<BoxView<'a>>,
navigation: Vec<RecordNavLink>,
related: Vec<BoxView<'a>>,
stale: bool,
declined: Option<String>,
mask: FormMask,
picking: bool,
}
impl<'a> FormChrome<'a> {
pub(super) fn create<R: Resource>(cx: &Cx, resource: &Mounted<R>) -> Self {
let picking = crate::panel::url::picking(cx);
Self {
title: t!(
cx,
"tablo-form-create-title",
label = resource.label.clone()
),
submit_label: t!(cx, "tablo-form-create"),
destructive: false,
public_link: None,
actions: ActionBar::default(),
cancel: resource.url.clone(),
hidden: Vec::new(),
options: None,
editing: false,
another: !picking,
seed: seed_pairs(cx, resource),
relations: Vec::new(),
navigation: Vec::new(),
related: Vec::new(),
stale: false,
declined: None,
mask: resource.form_mask(cx),
picking,
}
}
pub(super) fn edit<R: Resource>(cx: &Cx, resource: &Mounted<R>, record: &R::Model) -> Self {
use crate::ModelColumns as _;
let hidden = record
.version()
.map(|version| (VERSION_FIELD.to_string(), version.to_string()))
.into_iter()
.collect();
Self {
title: t!(cx, "tablo-form-edit-title", label = resource.label.clone()),
submit_label: t!(cx, "tablo-form-save"),
destructive: false,
public_link: resource.public_link(cx, record),
actions: {
let uri = topcoat::router::request::uri(cx);
let here = uri
.path_and_query()
.map_or(uri.path(), |full| full.as_str());
record_bar(cx, resource, record, Places::EDIT, here)
},
cancel: resource.url.clone(),
hidden,
options: None,
editing: true,
another: false,
seed: Vec::new(),
relations: Vec::new(),
navigation: record_navigation(cx, resource, record),
related: Vec::new(),
stale: false,
declined: None,
mask: resource.form_mask(cx),
picking: false,
}
}
pub(super) fn stale(mut self) -> Self {
self.stale = true;
self
}
pub(super) fn declined(mut self, message: String) -> Self {
self.declined = Some(message);
self
}
pub(super) fn with_relations(mut self, relations: Vec<BoxView<'a>>) -> Self {
self.relations = relations;
self
}
pub(super) fn with_related(mut self, related: Vec<BoxView<'a>>) -> Self {
self.related = related;
self
}
pub(crate) fn settings(cx: &Cx, title: String, home: String) -> Self {
Self {
title,
submit_label: t!(cx, "tablo-form-save"),
destructive: false,
public_link: None,
actions: ActionBar::default(),
cancel: home,
hidden: Vec::new(),
options: None,
editing: true,
another: false,
seed: Vec::new(),
relations: Vec::new(),
navigation: Vec::new(),
related: Vec::new(),
stale: false,
declined: None,
mask: FormMask::default(),
picking: false,
}
}
pub(crate) fn with_actions(mut self, actions: ActionBar) -> Self {
self.actions = actions;
self
}
pub(crate) fn with_hidden(mut self, name: &str, value: String) -> Self {
self.hidden.push((name.to_string(), value));
self
}
pub(crate) fn action(
title: String,
label: String,
destructive: bool,
hidden: Vec<(String, String)>,
cancel: String,
options: String,
) -> Self {
Self {
title,
submit_label: label,
destructive,
public_link: None,
actions: ActionBar::default(),
cancel,
hidden,
options: Some(options),
editing: false,
another: false,
seed: Vec::new(),
relations: Vec::new(),
navigation: Vec::new(),
related: Vec::new(),
stale: false,
declined: None,
mask: FormMask::default(),
picking: false,
}
}
}
pub(crate) type Carried = std::collections::BTreeSet<(String, String)>;
pub(crate) async fn render_form_page<'a>(
cx: &'a Cx,
schema: &Schema,
chrome: FormChrome<'a>,
values: &FormValues,
errors: &FieldErrors,
carried: &Carried,
) -> Result<BoxView<'a>> {
let FormChrome {
title,
submit_label,
destructive,
public_link,
actions,
cancel,
hidden,
options,
editing,
another,
seed,
relations,
related,
stale,
declined,
mask,
picking,
navigation,
} = chrome;
let unseen = errors.iter().any(|error| mask.hides(&error.key));
let values = &mask.shown(values);
let mut source = crate::schema::Source::form(values, errors)
.editing(editing)
.masked(mask)
.flat(picking);
if let Some(url) = options {
source = source.options_at(url);
}
let form_html = schema.render(cx, source).await?;
let action = form_action(cx, &seed, picking);
let cancel = return_target(cx).unwrap_or(cancel);
let dirty = !errors.is_empty() || stale || declined.is_some();
let has_actions = public_link.is_some() || !actions.is_empty();
let actions = actions.render(cx);
let enctype: Option<String> = schema
.fields()
.any(|field| field.is_file())
.then(|| "multipart/form-data".to_string());
let csrf = crate::csrf::current_token(cx);
let mut carried_fields: Vec<BoxView<'a>> = Vec::new();
for (name, path) in carried {
let control = format!("keep_{name}");
let path = crate::upload::carry::seal(cx, name, path);
carried_fields.push(view! { <input type="hidden" name=(control) value=(path)> }.boxed());
}
for (name, value) in hidden {
carried_fields.push(view! { <input type="hidden" name=(name) value=(value)> }.boxed());
}
let submit_variant = if destructive {
tablo_ui::ButtonVariant::Destructive
} else {
tablo_ui::ButtonVariant::Primary
};
let footer = if picking { PICK_FOOTER } else { FOOTER };
let form = view! {
<form
method="post"
action=(action)
enctype=(enctype)
class="flex flex-col gap-4"
data-form-guard=(t!(cx, "tablo-form-unsaved"))
data-form-dirty=(dirty)
>
(crate::csrf::field(cx, &csrf))
if stale {
tablo_ui::alert(
variant: tablo_ui::AlertVariant::Destructive,
attrs: attributes! { role="alert" data-stale="" },
tablo_ui::alert_title((t!(cx, "tablo-form-stale")))
tablo_ui::alert_description(
(t!(cx, "tablo-form-stale-description"))
)
)
}
if let Some(message) = declined {
tablo_ui::alert(
variant: tablo_ui::AlertVariant::Destructive,
attrs: attributes! { role="alert" data-declined="" },
tablo_ui::alert_description((message))
)
}
if unseen {
tablo_ui::alert(
variant: tablo_ui::AlertVariant::Destructive,
attrs: attributes! { role="alert" data-unseen-error="" },
tablo_ui::alert_title((t!(cx, "tablo-form-unseen")))
tablo_ui::alert_description(
(t!(cx, "tablo-form-unseen-description"))
)
)
}
for carried in carried_fields {
(carried)
}
(form_html)
for related in related {
(related)
}
<footer class=(footer)>
tablo_ui::button(
variant: submit_variant,
attrs: attributes! { type="submit" },
(submit_label)
)
if another {
tablo_ui::button(
variant: tablo_ui::ButtonVariant::Outline,
attrs: attributes! {
type="submit"
name=(crate::form::ANOTHER_FIELD)
value="1"
},
(t!(cx, "tablo-form-create-another"))
)
}
if picking {
tablo_ui::button(
variant: tablo_ui::ButtonVariant::Outline,
attrs: attributes! { type="button" data-pick-cancel="" },
(t!(cx, "tablo-cancel"))
)
} else {
<a
(crate::navigation::runtime_link(cx, &cancel))
class=(tablo_ui::button_variants(
tablo_ui::ButtonVariant::Outline,
tablo_ui::ButtonSize::Md,
))
>
(t!(cx, "tablo-cancel"))
</a>
}
</footer>
</form>
}
.boxed();
if picking {
return Ok(form);
}
Ok(view! {
tablo_ui::page(
tablo_ui::page_header(
tablo_ui::page_title((title.clone()))
if destructive {
tablo_ui::page_description((t!(cx, "tablo-confirm-irreversible")))
}
if has_actions {
tablo_ui::page_actions(
if let Some(link) = public_link {
<a
href=(link.url)
class=(tablo_ui::button_variants(
tablo_ui::ButtonVariant::Outline,
tablo_ui::ButtonSize::Md,
))
>
icon(data: tablo_ui::icons::EXTERNAL_LINK)
(link.label.localize(cx))
</a>
}
(actions)
)
}
)
(render_record_navigation(cx, navigation))
tablo_ui::page_content(
(form)
for relation in relations {
(relation)
}
)
)
}
.boxed())
}
pub(super) fn form_action(cx: &Cx, seed: &[(String, String)], picking: bool) -> String {
let path = topcoat::router::request::uri(cx).path();
let mut query = form_urlencoded::Serializer::new(String::new());
query.extend_pairs(seed);
if picking {
query.append_pair(crate::panel::url::PICK_PARAM, "1");
}
if let Some(target) = return_target(cx) {
query.append_pair(crate::panel::url::RETURN_PARAM, &target);
}
let query = query.finish();
if query.is_empty() {
path.to_string()
} else {
format!("{path}?{query}")
}
}
pub(crate) fn resource_create<R: Resource>(cx: &Cx, _body: Body) -> BoxView<'_> {
async_page(async move {
let resource = gate::<R>(cx)?;
if !resource.can_create(cx) {
return Err(forbidden().into());
}
crate::csrf::ensure_token(cx);
let related = open_related(cx, &resource, None, &mut crate::db::db(cx)?).await?;
let html = render_form_page(
cx,
&resource.form,
FormChrome::create(cx, &resource).with_related(render_related(cx, &related)),
&seeded_values(cx, &resource),
&FieldErrors::new(),
&Carried::new(),
)
.await?;
Ok(html)
})
}
fn seeded_values<R: Resource>(cx: &Cx, resource: &Mounted<R>) -> FormValues {
let schema = &resource.form;
let mut values = schema.defaults();
values.extend(
schema
.read(cx, &seed_pairs(cx, resource))
.unwrap_or_default(),
);
values
}
pub(super) fn seed_pairs<R: Resource>(cx: &Cx, resource: &Mounted<R>) -> Vec<(String, String)> {
let mask = resource.form_mask(cx);
let seedable: Vec<&str> = resource
.form
.fields()
.filter(|field| {
!field.may_post_nothing_on_create()
&& mask.writes(field.name()).is_none()
&& field
.as_choice()
.is_some_and(|choice| choice.is_relationship())
})
.map(|field| field.name())
.collect();
query_pairs(cx, &seedable)
}
fn query_pairs(cx: &Cx, names: &[&str]) -> Vec<(String, String)> {
let query = topcoat::router::request::uri(cx)
.query()
.unwrap_or_default();
form_urlencoded::parse(query.as_bytes())
.filter(|(name, _)| names.contains(&name.as_ref()))
.map(|(name, value)| (name.into_owned(), value.into_owned()))
.collect()
}