use std::collections::HashSet;
use topcoat::{
Result,
context::Cx,
icon::icon,
router::{Body, error::forbidden},
view::{BoxView, ViewExt, attributes, view},
};
use super::super::{
bar::{ActionBar, record_bar},
gate::{gate, return_target},
};
pub(super) use crate::form::VERSION_FIELD;
use crate::{
form::{FieldErrors, FormValues},
resource::{Mounted, Places, PublicLink, Resource},
schema::{FormMask, Schema},
t,
topcoat_compat::async_page,
};
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,
relations: Vec<BoxView<'a>>,
stale: bool,
mask: FormMask,
}
impl<'a> FormChrome<'a> {
pub(super) fn create<R: Resource>(cx: &Cx, resource: &Mounted<R>) -> Self {
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,
relations: Vec::new(),
stale: false,
mask: resource.form_mask(cx),
}
}
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,
relations: Vec::new(),
stale: false,
mask: resource.form_mask(cx),
}
}
pub(super) fn stale(mut self) -> Self {
self.stale = true;
self
}
pub(super) fn with_relations(mut self, relations: Vec<BoxView<'a>>) -> Self {
self.relations = relations;
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,
relations: Vec::new(),
stale: false,
mask: FormMask::default(),
}
}
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,
relations: Vec::new(),
stale: false,
mask: FormMask::default(),
}
}
}
pub(crate) async fn render_form_page<'a>(
cx: &'a Cx,
schema: &Schema,
chrome: FormChrome<'a>,
values: &FormValues,
errors: &FieldErrors,
carried: &HashSet<String>,
) -> Result<BoxView<'a>> {
let FormChrome {
title,
submit_label,
destructive,
public_link,
actions,
cancel,
hidden,
options,
editing,
relations,
stale,
mask,
} = 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);
if let Some(url) = options {
source = source.options_at(url);
}
let form_html = schema.render(cx, source).await?;
let path = topcoat::router::request::uri(cx).path();
let return_to = return_target(cx);
let action = match &return_to {
Some(target) => crate::panel::url::with_return(path, target),
None => path.to_string(),
};
let cancel = return_to.unwrap_or(cancel);
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();
let mut carried_names: Vec<&String> = carried.iter().collect();
carried_names.sort();
for name in carried_names {
let Some(path) = values.text(name) else {
continue;
};
let control = format!("keep_{name}");
let path = crate::upload::carry::seal(cx, name, path);
carried_fields
.push(view! { cx => <input type="hidden" name=(control) value=(path)> }.boxed());
}
for (name, value) in hidden {
carried_fields
.push(view! { cx => <input type="hidden" name=(name) value=(value)> }.boxed());
}
let submit_variant = if destructive {
tablo_ui::ButtonVariant::Destructive
} else {
tablo_ui::ButtonVariant::Primary
};
Ok(view! {
cx =>
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)
</a>
}
(actions)
)
}
)
tablo_ui::page_content(
<form
method="post"
action=(action)
enctype=(enctype)
class="flex flex-col gap-4"
>
(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 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)
<div class="flex items-center gap-2">
tablo_ui::button(
variant: submit_variant,
attrs: attributes! { type="submit" },
(submit_label)
)
<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>
</div>
</form>
for relation in relations {
(relation)
}
)
)
}
.boxed())
}
/// Renders the create page, seeding only relationship controls from query parameters.
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 html = render_form_page(
cx,
&resource.form,
FormChrome::create(cx, &resource),
&seeded_values(cx, &resource),
&FieldErrors::new(),
&HashSet::new(),
)
.await?;
Ok(html)
})
}
/// The create form's values: each field's default, under what the query parameters post for a
/// relationship control, read as the form posts it: a multiple choice's every value, a single
/// one's last.
fn seeded_values<R: Resource>(cx: &Cx, resource: &Mounted<R>) -> FormValues {
let schema = &resource.form;
let mask = resource.form_mask(cx);
let seedable: Vec<&str> = schema
.fields()
.filter(|field| {
// A disabled control stores its default, which the form must show, and a masked one
// takes what the user may not choose.
!field.is_disabled(false)
&& mask.writes(field.name()).is_none()
&& field
.as_choice()
.is_some_and(|choice| choice.is_relationship())
})
.map(|field| field.name())
.collect();
let query = topcoat::router::request::uri(cx)
.query()
.unwrap_or_default();
let posted: Vec<(String, String)> = form_urlencoded::parse(query.as_bytes())
.filter(|(name, _)| seedable.contains(&name.as_ref()))
.map(|(name, value)| (name.into_owned(), value.into_owned()))
.collect();
let mut values = schema.defaults();
// Only relationship choices post here, and only a repeater's key refuses a read.
values.extend(schema.read(cx, &posted).unwrap_or_default());
values
}