use std::{any::TypeId, sync::Arc};
use topcoat::{
Result,
context::Cx,
view::{BoxView, ViewExt, view},
};
use crate::{
Page, RecordTable, Resource, Stat, Trend,
navigation::runtime_link,
page::MountedPage,
panel::list::{TableChrome, declared_chrome, wire_table_over},
resource::mounted,
table::include_relations,
topcoat_compat::async_page,
widget::{MountedWidget, Span, WidgetGrid},
};
pub fn widgets<P: Page>(cx: &Cx) -> BoxView<'_> {
match super::pages::mounted_admitted(cx, TypeId::of::<P>()) {
Some(page) => page_grid(cx, page),
None => ().boxed(),
}
}
pub(super) fn page_grid<'a>(cx: &'a Cx, page: &MountedPage) -> BoxView<'a> {
let cells = page
.widgets
.admitted(cx)
.map(|widget| (widget, async_page((widget.render)(cx))))
.collect();
grid(cx, page.widgets.columns, cells)
}
pub(crate) async fn rendered_grid<'a>(cx: &'a Cx, widgets: &WidgetGrid) -> Result<BoxView<'a>> {
let mut cells = Vec::new();
for widget in widgets.admitted(cx) {
cells.push((widget, (widget.render)(cx).await?));
}
Ok(grid(cx, widgets.columns, cells))
}
fn grid<'a>(cx: &'a Cx, columns: u8, cells: Vec<(&MountedWidget, BoxView<'a>)>) -> BoxView<'a> {
let cells: Vec<BoxView<'a>> = cells
.into_iter()
.map(|(widget, body)| {
let name = widget.name.clone();
let class = span_class(widget.span, columns);
view! { <div data-widget=(name) class=(class)>(body)</div> }.boxed()
})
.collect();
if cells.is_empty() {
return ().boxed();
}
view! {
<div data-widgets="" class=(grid_class(columns))>
for cell in cells {
(cell)
}
</div>
}
.boxed()
}
const fn grid_class(columns: u8) -> &'static str {
match columns {
0 | 1 => "grid gap-4",
2 => "grid gap-4 md:grid-cols-2",
3 => "grid gap-4 md:grid-cols-2 xl:grid-cols-3",
_ => "grid gap-4 md:grid-cols-2 xl:grid-cols-4",
}
}
const fn span_class(span: Span, columns: u8) -> &'static str {
let span = match span {
Span::Full => return "min-w-0 col-span-full",
Span::Columns(span) if span < columns => span,
Span::Columns(_) => columns,
};
match span {
0 | 1 => "min-w-0",
2 => "min-w-0 md:col-span-2",
3 => "min-w-0 md:col-span-2 xl:col-span-3",
_ => "min-w-0 md:col-span-2 xl:col-span-4",
}
}
pub fn stats(cx: &Cx, stats: impl IntoIterator<Item = Stat>) -> BoxView<'_> {
let stats: Vec<Stat> = stats.into_iter().collect();
if stats.is_empty() {
return ().boxed();
}
let class = if stats.len() > 3 && stats.len() % 3 == 1 {
"grid gap-4 @lg:grid-cols-2 @4xl:grid-cols-4"
} else {
"grid gap-4 @lg:grid-cols-2 @3xl:grid-cols-3"
};
let tiles: Vec<BoxView<'_>> = stats.into_iter().map(|stat| tile(cx, stat)).collect();
view! {
<div data-stats="" class="@container">
<div class=(class)>
for tile in tiles {
(tile)
}
</div>
</div>
}
.boxed()
}
fn tile(cx: &Cx, stat: Stat) -> BoxView<'_> {
let Stat {
label,
value,
detail,
icon,
href,
trend,
color,
chart,
} = stat;
let detail = detail.map(|detail| detail.localize(cx)).unwrap_or_default();
let (detail_icon, detail_icon_label) = match trend {
Some(Trend::Up) => (
Some(tablo_ui::icons::ARROW_UP),
crate::t!(cx, "tablo-stat-trend-up"),
),
Some(Trend::Down) => (
Some(tablo_ui::icons::ARROW_DOWN),
crate::t!(cx, "tablo-stat-trend-down"),
),
Some(Trend::Flat) => (
Some(tablo_ui::icons::MINUS),
crate::t!(cx, "tablo-stat-trend-flat"),
),
None => (None, String::new()),
};
let mut attrs = topcoat::view::attributes! { data-stat="" };
if let Some(href) = &href {
let mut link = runtime_link(cx, href);
link.remove("href");
attrs.extend(link);
}
view! {
tablo_ui::stat(
label: label.localize(cx),
value: value,
detail: detail,
detail_icon: detail_icon,
detail_icon_label: detail_icon_label,
accent: color.text_class(),
chart: chart,
icon: icon,
href: href,
attrs: attrs
)
}
.boxed()
}
impl Stat {
pub async fn records<R: Resource>(cx: &Cx) -> Result<Option<Self>> {
if !super::can_list::<R>(cx) {
return Ok(None);
}
let (Some(item), Some(url)) = (
super::navigation_item::<R>(cx),
super::url::resource::<R>(cx),
) else {
return Ok(None);
};
let count = crate::db::count(cx, crate::scoped_query::<R>(cx)?).await?;
let count = crate::display::NumberFormat::of(cx)
.spell(crate::display::Number::Int(count.into()), 0);
let mut stat = Self::new(item.label, count).href(url);
stat.icon = item.icon;
Ok(Some(stat))
}
}
pub async fn record_table<R: Resource>(
cx: &Cx,
table: RecordTable<R::Model>,
) -> Result<BoxView<'_>> {
let Some(resource) = mounted::<R>(cx).filter(|_| super::can_list::<R>(cx)) else {
return Ok(().boxed());
};
let RecordTable {
heading,
description,
columns,
filter,
order_by,
limit,
..
} = table;
let shown = resource.table_for(cx);
let shown = match &columns {
Some(names) => {
let declared = resource.table.declared_columns();
if let Some(name) = names
.iter()
.find(|name| !declared.iter().any(|column| column.name() == name.as_str()))
{
return Err(crate::error::declaration(format!(
"a record table of `{}` names `{name}`, a column its list does not declare",
resource.slug
)));
}
Arc::new(shown.only(names))
}
None => shown,
};
if shown.declared_columns().is_empty() {
return Ok(().boxed());
}
let mut query = resource.scoped_query(cx)?;
if let Some(filter) = filter {
query = query.filter(filter);
}
for order in order_by {
query = query.order_by(order);
}
let query = include_relations(query.limit(limit), shown.declared_columns());
let mut db = crate::db::db(cx)?;
let rows = query
.exec(&mut db)
.await
.map_err(crate::error::unavailable)?;
shown.restrict_relations(cx, &rows, &mut db).await?;
let declared = declared_chrome(cx, &resource);
let chrome = TableChrome {
delete: false,
actions: false,
..declared
};
let back = topcoat::router::request::uri(cx)
.path_and_query()
.map_or_else(String::new, ToString::to_string);
let heading = heading.map_or_else(
|| resource.plural_label.localize(cx),
|heading| heading.localize(cx),
);
let mut wired =
wire_table_over(cx, &resource, shown, chrome).prefixed(dom_scope(&resource.slug, &heading));
if !back.is_empty() {
wired = wired.returning_to(back);
}
let rows = wired.render_records(cx, rows).await?;
let description = description.map(|description| description.localize(cx));
let list = crate::navigation::runtime_link(cx, &resource.url);
let view_all = crate::t!(cx, "tablo-record-table-view-all");
let slug = resource.slug.clone();
Ok(view! {
<section
data-record-table=(slug)
class="relative overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-xs"
>
<div class="flex items-start justify-between gap-4 px-5 pt-5 pb-3">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="text-base font-semibold">(heading)</h2>
if let Some(description) = description {
<p class="text-sm text-muted-foreground">(description)</p>
}
</div>
<a
(list)
data-record-table-list=""
class="shrink-0 text-sm text-muted-foreground hover:text-foreground"
>
(view_all)
</a>
</div>
(rows)
</section>
}
.boxed())
}
fn dom_scope(slug: &str, heading: &str) -> String {
let heading: String = heading
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
format!("{slug}-{heading}")
}