use std::sync::Arc;
use toasty::stmt::Expr;
use topcoat::{
context::Cx,
icon::icon,
router::path_param_segment,
view::{BoxView, ViewExt, view},
};
use super::{
bar::ActionBar,
gate::enforce_tenant,
list::{choose_columns, declared_chrome, load_scoped_page, table_error_view, wire_table},
state::current,
};
use crate::{
Color,
navigation::runtime_link,
panel::{
list::TableAction,
url::{
RETURN_PARAM, action_options_url, bulk_action_url, create_page_url, relation_url,
with_return,
},
},
policy::Ability,
resource::{InputSpec, Links, Mounted, Relation, RelationKind, Resource, Trashed, mounted},
topcoat_compat::async_page,
};
pub(crate) const ATTACH: &str = "attach";
pub(crate) const DETACH: &str = "detach";
pub(crate) struct Child {
pub(crate) slug: String,
pub(crate) label: crate::Text,
pub(crate) plural_label: crate::Text,
pub(crate) render: for<'a> fn(&'a Cx, BoundRelation) -> BoxView<'a>,
pub(crate) listable: fn(&Cx) -> bool,
pub(crate) hidden: fn(&Cx) -> Option<Vec<String>>,
pub(crate) form: super::forms::ChildForm,
}
pub(crate) struct BoundRelation {
pub(crate) key: String,
pub(crate) label: crate::Text,
pub(crate) scope: Expr<bool>,
pub(crate) seed: Option<(String, String)>,
pub(crate) creates: bool,
pub(crate) linking: Option<Linking>,
pub(crate) page: String,
}
pub(crate) struct Linking {
pub(crate) url: String,
pub(crate) input: InputSpec,
}
pub(crate) fn render_relations<'a, R: Resource>(
cx: &'a Cx,
resource: &Mounted<R>,
owner: &R::Model,
) -> Vec<BoxView<'a>> {
resource
.relations
.iter()
.filter(|relation| !relation.tab)
.filter_map(|relation| render_relation(cx, resource, owner, relation))
.collect()
}
pub(crate) fn relation_tab<'r, R: Resource>(
cx: &'r Cx,
resource: &'r Mounted<R>,
slug: &str,
) -> Option<(&'r Relation<R::Model>, &'r Child)> {
let panel = current(cx)?;
resource.relations.iter().find_map(|relation| {
let child = panel.children.get(&relation.child)?;
(relation.tab && child.slug == slug).then_some((relation, child))
})
}
pub(crate) fn render_relation<'a, R: Resource>(
cx: &'a Cx,
resource: &Mounted<R>,
owner: &R::Model,
relation: &Relation<R::Model>,
) -> Option<BoxView<'a>> {
let panel = current(cx)?;
let child = panel.children.get(&relation.child)?;
let page = topcoat::router::request::uri(cx).path().to_string();
let live = !resource.is_trashed(owner);
let (scope, value) = relation.bind(owner);
let (seed, linking) = match &relation.kind {
RelationKind::HasMany { foreign_key } => (Some((foreign_key.clone(), value)), None),
RelationKind::HasOne { .. } => return None,
RelationKind::ManyToMany(Links { input, .. }) => (
None,
(live
&& resource.table.is_addressable()
&& resource.can(cx, Ability::View(owner))
&& resource.can(cx, Ability::Update(owner)))
.then(|| Linking {
url: relation_url(&resource.url, &resource.table.key_of(owner), &child.slug),
input: *input,
}),
),
};
Some((child.render)(
cx,
BoundRelation {
key: child.slug.clone(),
label: relation
.label
.clone()
.unwrap_or_else(|| child.plural_label.clone()),
scope,
seed,
creates: live,
linking,
page,
},
))
}
pub(crate) fn linked_relation<'r, R: Resource>(
cx: &Cx,
resource: &'r Mounted<R>,
) -> Option<&'r Links<R::Model>> {
let panel = current(cx)?;
let slug = path_param_segment(cx, "relation");
resource.relations.iter().find_map(|relation| {
let child = panel.children.get(&relation.child)?;
(child.slug == slug).then_some(())?;
relation.links()
})
}
pub(crate) fn relation_table<C: Resource>(cx: &Cx, relation: BoundRelation) -> BoxView<'_> {
async_page(async move {
let Some(resource) = mounted::<C>(cx) else {
return Ok(().boxed());
};
if enforce_tenant(cx, &resource).is_err() || !resource.can(cx, Ability::ViewAny) {
return Ok(().boxed());
}
let BoundRelation {
key,
label,
scope,
seed,
creates,
linking,
page,
} = relation;
let mut chrome = declared_chrome(cx, &resource);
chrome.actions = seed.is_some();
let mut table = choose_columns(
cx,
&resource,
wire_table(cx, &resource, chrome).prefixed(&key),
)
.await;
if let Some(linking) = &linking {
table =
table.with_custom_actions(linking.url.clone(), vec![detach_action(cx, &resource)]);
}
let (signals, state) = table.browser_state(cx);
let back = state.list_url(&page);
let table = table
.taking(resource.list_trashed(&state))
.returning_to(back.clone());
let create_url = seed
.filter(|_| creates && resource.pages.create && resource.can_create(cx))
.map(|(column, value)| {
let query = form_urlencoded::Serializer::new(String::new())
.append_pair(&column, &value)
.append_pair(RETURN_PARAM, &back)
.finish();
format!("{}?{query}", create_page_url(&resource.url))
});
let attach = linking.map(|linking| {
ActionBar::input(
crate::t!(
cx,
"tablo-relation-attach-title",
label = resource.label.clone()
),
with_return(&bulk_action_url(&linking.url, ATTACH), &back),
linking.input,
action_options_url(&linking.url, ATTACH),
)
});
let body = match load_scoped_page(cx, &resource, &table, &state, scope).await {
Ok(rows) => table.render_page(cx, rows, &state, &page, &signals).await?,
Err(error) => table_error_view(
cx,
&resource.slug,
&resource.plural_label,
&state,
&error,
&page,
),
};
Ok(relation_section(
cx,
key,
label,
&resource.label,
create_url,
attach.map(|bar| bar.render(cx)),
body,
))
})
}
fn detach_action<C: Resource>(cx: &Cx, resource: &Arc<Mounted<C>>) -> TableAction<C::Model> {
let (policy_cx, policy) = (cx.clone(), Arc::clone(resource));
let label = crate::t!(cx, "tablo-relation-detach");
TableAction {
name: DETACH,
label: label.clone(),
row: true,
bulk: true,
confirm: false,
prompt: String::new(),
bulk_prompt: String::new(),
icon: None,
record_face: Arc::new(move |_| (label.clone(), None)),
tooltip: None,
color: Color::Gray,
input: None,
trashed: Trashed::With,
allowed: Arc::new(move |record: &C::Model| policy.can(&policy_cx, Ability::View(record))),
disabled: Arc::new(|_| None),
}
}
fn relation_section<'a>(
cx: &'a Cx,
key: String,
label: crate::Text,
child_label: &crate::Text,
create_url: Option<String>,
attach: Option<BoxView<'a>>,
body: BoxView<'a>,
) -> BoxView<'a> {
let create_label = crate::t!(cx, "tablo-relation-new", label = child_label);
view! {
<section class="flex flex-col gap-3" data-relation=(key)>
<div class="flex items-center justify-between gap-4">
<h2 class="text-lg font-semibold tracking-tight text-foreground">
(label)
</h2>
<div class="flex items-center gap-2">
if let Some(attach) = attach {
(attach)
}
if let Some(url) = create_url {
<a
(runtime_link(cx, &url))
class=(tablo_ui::button_variants(
tablo_ui::ButtonVariant::Outline,
tablo_ui::ButtonSize::Sm,
))
>
icon(data: tablo_ui::icons::PLUS)
(create_label)
</a>
}
</div>
</div>
(body)
</section>
}
.boxed()
}