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},
};
use crate::{
form::{FieldErrors, FormValues},
policy::Ability,
resource::{Mounted, Places, PublicLink, Resource},
schema::Schema,
topcoat_compat::async_page,
};
pub(crate) struct FormChrome {
title: String,
submit_label: String,
destructive: bool,
public_link: Option<PublicLink>,
actions: ActionBar,
cancel: String,
hidden: Vec<(String, String)>,
options: Option<String>,
editing: bool,
}
impl FormChrome {
pub(super) fn create<R: Resource>(resource: &Mounted<R>) -> Self {
Self {
title: format!("Create {}", resource.label),
submit_label: "Create".to_string(),
destructive: false,
public_link: None,
actions: ActionBar::default(),
cancel: resource.url.clone(),
hidden: Vec::new(),
options: None,
editing: false,
}
}
pub(super) fn edit<R: Resource>(cx: &Cx, resource: &Mounted<R>, record: &R::Model) -> Self {
Self {
title: format!("Edit {}", resource.label),
submit_label: "Save".to_string(),
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: Vec::new(),
options: None,
editing: true,
}
}
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,
}
}
}
pub(crate) async fn render_form_page<'a>(
cx: &'a Cx,
schema: &Schema,
chrome: FormChrome,
values: &FormValues,
errors: &FieldErrors,
carried: &HashSet<String>,
) -> Result<BoxView<'a>> {
let FormChrome {
title,
submit_label,
destructive,
public_link,
actions,
cancel,
hidden,
options,
editing,
} = chrome;
let mut source = crate::schema::Source::form(values, errors).editing(editing);
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::table::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 = path.to_string();
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("This action cannot be undone.")
}
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))
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,
))
>
"Cancel"
</a>
</div>
</form>
)
)
}
.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(cx, Ability::Create) {
return Err(forbidden().into());
}
crate::csrf::ensure_token(cx);
let html = render_form_page(
cx,
&resource.form,
FormChrome::create(&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 seedable: Vec<&str> = schema
.fields()
.filter(|field| {
// A disabled control stores its default, which the form must show.
!field.is_disabled(false)
&& 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(&posted).unwrap_or_default());
values
}