use std::{
any::Any,
collections::{HashMap, HashSet},
sync::Arc,
};
use toasty::stmt::{Expr, List, Query};
use topcoat::{
Result,
context::Cx,
router::error::bad_request,
view::{BoxView, ViewExt, attributes, view},
};
use super::{
common::FormParts,
render::VERSION_FIELD,
submit::{
Unposted, complete, named_fields, parse_form, prepare_submission, recheck_relationships,
refusal,
},
unique::check_unique,
};
use crate::{
BoxFuture, ModelColumns as _,
form::{FieldErrors, FormValue, FormValues, Posted, RecordForm},
panel::{gate::enforce_tenant, state::current},
policy::Ability,
resource::{Change, Mounted, Resource, WriteError, mounted, require_mounted},
schema::Source,
t,
toasty_compat::pk::{pk_filter, pk_text},
topcoat_compat::async_page,
};
pub(crate) type AfterCommit = Box<dyn for<'c> FnOnce(&'c Cx) -> BoxFuture<'c, ()> + Send>;
#[derive(Clone, Copy)]
pub(crate) struct ChildForm {
open: OpenFn,
prepare: for<'a> fn(&'a Cx, &'a Opened, FormParts) -> BoxFuture<'a, Result<Prepared>>,
write: WriteFn,
render: for<'a> fn(&'a Cx, Shown) -> BoxView<'a>,
}
type OpenFn = for<'a> fn(
&'a Cx,
Option<Expr<bool>>,
&'a str,
&'a mut dyn toasty::Executor,
) -> BoxFuture<'a, Result<Option<Opened>>>;
type WriteFn = for<'a> fn(
&'a Cx,
Prepared,
Expr<bool>,
&'a str,
String,
&'a mut dyn toasty::Executor,
) -> BoxFuture<'a, Result<Written>>;
impl ChildForm {
pub(crate) fn of<C: Resource>() -> Self {
Self {
open: |cx, scope, foreign_key, ex| Box::pin(open::<C>(cx, scope, foreign_key, ex)),
prepare: |cx, opened, parts| Box::pin(prepare::<C>(cx, opened, parts)),
write: |cx, prepared, scope, foreign_key, owner, ex| {
Box::pin(write::<C>(cx, prepared, scope, foreign_key, owner, ex))
},
render: render::<C>,
}
}
}
pub(crate) struct Opened {
record: Option<Box<dyn Any + Send + Sync>>,
stored: FormValues,
id: Option<String>,
version: Option<u64>,
}
pub(crate) struct Prepared {
values: FormValues,
errors: FieldErrors,
named: HashSet<String>,
against: Option<String>,
version: Option<u64>,
}
pub(crate) enum Written {
Done(AfterCommit),
Refused(Prepared),
Declined(Prepared, crate::Text),
Stale(Prepared, Option<Opened>),
}
pub(crate) enum RelatedWrite {
Done(Vec<AfterCommit>),
Refused,
Declined(crate::Text),
Stale,
}
pub(crate) struct Shown {
key: String,
label: crate::Text,
foreign_key: String,
editing: bool,
version: Option<u64>,
values: Option<FormValues>,
errors: FieldErrors,
}
pub(crate) struct Related {
index: usize,
key: String,
label: crate::Text,
foreign_key: String,
form: ChildForm,
opened: Option<Opened>,
posted: Option<Prepared>,
}
impl Related {
fn prefix(&self) -> String {
format!("{}.", self.key)
}
}
pub(crate) async fn open_related<R: Resource>(
cx: &Cx,
resource: &Mounted<R>,
owner: Option<&R::Model>,
ex: &mut dyn toasty::Executor,
) -> Result<Vec<Related>> {
let Some(panel) = current(cx) else {
return Ok(Vec::new());
};
let mut related = Vec::new();
for (index, relation) in resource.relations.iter().enumerate() {
let (Some(foreign_key), Some(child)) =
(relation.has_one_key(), panel.children.get(&relation.child))
else {
continue;
};
let scope = owner.map(|owner| relation.bind(owner).0);
let opened = (child.form.open)(cx, scope, foreign_key, &mut *ex).await?;
related.push(Related {
index,
key: child.slug.clone(),
label: relation
.label
.clone()
.unwrap_or_else(|| child.label.clone()),
foreign_key: foreign_key.to_string(),
form: child.form,
opened,
posted: None,
});
}
Ok(related)
}
pub(crate) async fn read_related(
cx: &Cx,
parts: &mut FormParts,
related: &mut [Related],
) -> Result<()> {
for related in related.iter_mut() {
let prefix = related.prefix();
let (posted, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut parts.pairs)
.into_iter()
.partition(|(name, _)| name.starts_with(&prefix));
parts.pairs = kept;
if posted.is_empty() {
continue;
}
let Some(opened) = &related.opened else {
return Err(bad_request(format!("`{}` takes no submission", related.key)).into());
};
let mut version = None;
let pairs: Vec<(String, String)> = posted
.into_iter()
.map(|(name, value)| (name[prefix.len()..].to_string(), value))
.filter(|(name, value)| {
let posts_version = name == VERSION_FIELD;
if posts_version {
version = Some(value.clone());
}
!posts_version
})
.collect();
let version = version
.map(|version| version.parse::<u64>())
.transpose()
.map_err(|error| bad_request(error.to_string()))?;
if pairs.iter().any(|(name, _)| *name == related.foreign_key) {
return Err(bad_request(format!(
"`{prefix}{}` is filled by the record the form saves",
related.foreign_key
))
.into());
}
let parts = FormParts {
pairs,
files: HashMap::new(),
file_part_names: HashSet::new(),
};
let mut prepared = (related.form.prepare)(cx, opened, parts).await?;
prepared.against = opened.id.clone();
prepared.version = version;
related.posted = Some(prepared);
}
Ok(())
}
pub(crate) fn related_valid(related: &[Related]) -> bool {
related
.iter()
.filter_map(|related| related.posted.as_ref())
.all(|posted| posted.errors.is_empty())
}
pub(crate) async fn write_related<R: Resource>(
cx: &Cx,
resource: &Mounted<R>,
related: &mut [Related],
owner: &R::Model,
ex: &mut dyn toasty::Executor,
) -> Result<RelatedWrite> {
let mut after = Vec::new();
for related in related.iter_mut() {
let Some(prepared) = related.posted.take() else {
continue;
};
let (scope, key) = resource.relations[related.index].bind(owner);
match (related.form.write)(cx, prepared, scope, &related.foreign_key, key, &mut *ex).await?
{
Written::Done(hook) => after.push(hook),
Written::Refused(prepared) => {
related.posted = Some(prepared);
return Ok(RelatedWrite::Refused);
}
Written::Declined(prepared, message) => {
related.posted = Some(prepared);
return Ok(RelatedWrite::Declined(message));
}
Written::Stale(prepared, opened) => {
related.posted = Some(prepared);
related.opened = opened;
return Ok(RelatedWrite::Stale);
}
}
}
Ok(RelatedWrite::Done(after))
}
pub(crate) fn render_related<'a>(cx: &'a Cx, related: &[Related]) -> Vec<BoxView<'a>> {
related
.iter()
.filter_map(|related| {
let opened = related.opened.as_ref()?;
let shown = Shown {
key: related.key.clone(),
label: related.label.clone(),
foreign_key: related.foreign_key.clone(),
editing: opened.record.is_some(),
version: opened.version,
values: match (&related.posted, &opened.record) {
(Some(posted), _) => Some(posted.values.clone()),
(None, Some(_)) => Some(opened.stored.clone()),
(None, None) => None,
},
errors: related
.posted
.as_ref()
.map(|posted| posted.errors.clone())
.unwrap_or_default(),
};
Some((related.form.render)(cx, shown))
})
.collect()
}
async fn open<C: Resource>(
cx: &Cx,
scope: Option<Expr<bool>>,
foreign_key: &str,
ex: &mut dyn toasty::Executor,
) -> Result<Option<Opened>> {
let Some(child) = mounted::<C>(cx) else {
return Ok(None);
};
let mask = child.form_mask(cx);
if enforce_tenant(cx, &child).is_err() || mask.hides(foreign_key) || mask.locks(foreign_key) {
return Ok(None);
}
let creates = || {
(child.pages.create && child.can_create(cx)).then(|| Opened {
record: None,
stored: FormValues::new(),
id: None,
version: None,
})
};
let Some(scope) = scope else {
return Ok(creates());
};
let held = Query::<List<C::Model>>::all()
.filter(scope)
.exec(&mut *ex)
.await
.map_err(crate::error::unavailable)?;
let held = match held.as_slice() {
[] => return Ok(creates()),
[held] => held,
_ => return Ok(None),
};
let Ok(query) = child.scoped_query(cx) else {
return Ok(None);
};
let record = <C::Form as RecordForm>::includes()
.into_vec()
.into_iter()
.fold(query.filter(pk_filter(held)), |query, include| {
query.include(include)
})
.first()
.exec(&mut *ex)
.await
.map_err(crate::error::unavailable)?;
Ok(record
.filter(|record| {
child.can(cx, Ability::View(record)) && child.can(cx, Ability::Update(record))
})
.map(|record| Opened {
stored: <C::Form as RecordForm>::hydrate(cx, &record),
id: Some(pk_text(&record)),
version: record.version(),
record: Some(Box::new(record)),
}))
}
async fn prepare<C: Resource>(cx: &Cx, opened: &Opened, parts: FormParts) -> Result<Prepared> {
let child = require_mounted::<C>(cx)?;
let record = opened
.record
.as_ref()
.map(|record| child_record::<C>(record.as_ref()));
let submission = prepare_submission(cx, &child, parts, record, Unposted::Missing).await?;
Ok(Prepared {
values: submission.values,
errors: submission.errors,
named: submission.named,
against: None,
version: None,
})
}
async fn write<C: Resource>(
cx: &Cx,
prepared: Prepared,
scope: Expr<bool>,
foreign_key: &str,
owner: String,
ex: &mut dyn toasty::Executor,
) -> Result<Written> {
let child = require_mounted::<C>(cx)?;
let opened = match open::<C>(cx, Some(scope), foreign_key, &mut *ex).await? {
Some(opened)
if opened.id == prepared.against
&& (prepared.version.is_none() || opened.version == prepared.version) =>
{
opened
}
reopened => return Ok(Written::Stale(prepared, reopened)),
};
let Prepared {
mut values,
mut errors,
named,
..
} = prepared;
let schema = Arc::clone(&child.form);
complete(&schema, &mut values, &named, &opened.stored);
values.insert(foreign_key, FormValue::Text(owner.clone()));
if opened.record.is_some() {
schema
.keep_unseen_links(cx, &mut values, &opened.stored, &mut *ex)
.await;
}
errors.extend(check_unique(cx, &child, &schema, &values, &opened.stored, &mut *ex).await?);
let form = parse_form(cx, &child, &values, &mut errors)?;
let mut linked = opened.stored.clone();
linked.insert(foreign_key, FormValue::Text(owner));
recheck_relationships(cx, &schema, &values, &linked, &mut errors, &mut *ex).await;
let refused = |values, errors| {
Written::Refused(Prepared {
values,
errors,
named: HashSet::new(),
against: None,
version: None,
})
};
let declined = |values, message| {
Written::Declined(
Prepared {
values,
errors: FieldErrors::new(),
named: HashSet::new(),
against: None,
version: None,
},
message,
)
};
let Some(form) = form.filter(|_| errors.is_empty()) else {
return Ok(refused(values, errors));
};
let change = match opened.record {
None => match child.create(cx, form, &mut *ex).await {
Ok(record) => Change::Create(record),
Err(WriteError::Refused(errors)) => {
return Ok(refused(values, refusal(&child, "create_record", errors)?));
}
Err(WriteError::Declined(message)) => {
return Ok(declined(values, message));
}
Err(WriteError::Failed(error)) => return Err(error),
},
Some(record) => {
let record = *record
.downcast::<C::Model>()
.expect("`open::<C>` holds a `C::Model`");
let before = record.clone();
let posted = Posted::new(form, named_fields(&child, &named));
match child.update(cx, record, posted, &mut *ex).await {
Ok(after) => Change::Update { before, after },
Err(WriteError::Refused(errors)) => {
return Ok(refused(values, refusal(&child, "update_record", errors)?));
}
Err(WriteError::Declined(message)) => {
return Ok(declined(values, message));
}
Err(WriteError::Failed(error)) => return Err(error),
}
}
};
crate::panel::commit_hook::before_commit::<C>(cx, &change, &mut *ex).await?;
crate::panel::write::end_changed_credentials(cx, &mut *ex, &change).await?;
Ok(Written::Done(Box::new(move |cx| {
Box::pin(crate::resource::run_after_commit::<C>(cx, change))
})))
}
fn render<C: Resource>(cx: &Cx, shown: Shown) -> BoxView<'_> {
async_page(async move {
let Some(child) = mounted::<C>(cx) else {
return Ok(().boxed());
};
let Shown {
key,
label,
foreign_key,
editing,
version,
values,
errors,
} = shown;
let mask = child.form_mask(cx).hiding(&foreign_key);
let unseen = errors.iter().any(|error| mask.hides(&error.key));
let version = version.filter(|_| editing).map(|version| {
let name = format!("{key}.{VERSION_FIELD}");
view! { <input type="hidden" name=(name) value=(version.to_string())> }
});
let values = mask.shown(&values.unwrap_or_else(|| child.form.defaults()));
let source = Source::form(&values, &errors)
.editing(editing)
.masked(mask)
.scoped(key.clone())
.posting_under(format!("{key}."));
let body = child.form.render(cx, source).await?;
Ok(view! {
<div class="contents" data-related-form=(key)>
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"))
)
)
}
if let Some(version) = version {
(version)
}
(crate::schema::titled(cx, label, body))
</div>
}
.boxed())
})
}
fn child_record<C: Resource>(record: &(dyn Any + Send + Sync)) -> &C::Model {
record
.downcast_ref::<C::Model>()
.expect("`open::<C>` holds a `C::Model`")
}