1use std::{any::TypeId, sync::Arc};
5
6use topcoat::{
7 Result,
8 context::Cx,
9 view::{BoxView, ViewExt, view},
10};
11
12use crate::{
13 Page, RecordTable, Resource, Stat, Trend,
14 navigation::runtime_link,
15 page::MountedPage,
16 panel::list::{TableChrome, declared_chrome, wire_table_over},
17 resource::mounted,
18 table::include_relations,
19 topcoat_compat::async_page,
20 widget::{MountedWidget, Span, WidgetGrid},
21};
22
23pub fn widgets<P: Page>(cx: &Cx) -> BoxView<'_> {
33 match super::pages::mounted_admitted(cx, TypeId::of::<P>()) {
34 Some(page) => page_grid(cx, page),
35 None => ().boxed(),
36 }
37}
38
39pub(super) fn page_grid<'a>(cx: &'a Cx, page: &MountedPage) -> BoxView<'a> {
42 let cells = page
43 .widgets
44 .admitted(cx)
45 .map(|widget| (widget, async_page((widget.render)(cx))))
46 .collect();
47 grid(cx, page.widgets.columns, cells)
48}
49
50pub(crate) async fn rendered_grid<'a>(cx: &'a Cx, widgets: &WidgetGrid) -> Result<BoxView<'a>> {
54 let mut cells = Vec::new();
55 for widget in widgets.admitted(cx) {
56 cells.push((widget, (widget.render)(cx).await?));
57 }
58 Ok(grid(cx, widgets.columns, cells))
59}
60
61fn grid<'a>(cx: &'a Cx, columns: u8, cells: Vec<(&MountedWidget, BoxView<'a>)>) -> BoxView<'a> {
63 let cells: Vec<BoxView<'a>> = cells
64 .into_iter()
65 .map(|(widget, body)| {
66 let name = widget.name.clone();
67 let class = span_class(widget.span, columns);
68 view! { <div data-widget=(name) class=(class)>(body)</div> }.boxed()
69 })
70 .collect();
71 if cells.is_empty() {
72 return ().boxed();
73 }
74 view! {
75 <div data-widgets="" class=(grid_class(columns))>
76 for cell in cells {
77 (cell)
78 }
79 </div>
80 }
81 .boxed()
82}
83
84const fn grid_class(columns: u8) -> &'static str {
87 match columns {
88 0 | 1 => "grid gap-4",
89 2 => "grid gap-4 md:grid-cols-2",
90 3 => "grid gap-4 md:grid-cols-2 xl:grid-cols-3",
91 _ => "grid gap-4 md:grid-cols-2 xl:grid-cols-4",
92 }
93}
94
95const fn span_class(span: Span, columns: u8) -> &'static str {
97 let span = match span {
98 Span::Full => return "min-w-0 col-span-full",
99 Span::Columns(span) if span < columns => span,
100 Span::Columns(_) => columns,
101 };
102 match span {
103 0 | 1 => "min-w-0",
104 2 => "min-w-0 md:col-span-2",
105 3 => "min-w-0 md:col-span-2 xl:col-span-3",
106 _ => "min-w-0 md:col-span-2 xl:col-span-4",
107 }
108}
109
110pub fn stats(cx: &Cx, stats: impl IntoIterator<Item = Stat>) -> BoxView<'_> {
124 let stats: Vec<Stat> = stats.into_iter().collect();
125 if stats.is_empty() {
126 return ().boxed();
127 }
128 let class = if stats.len() > 3 && stats.len() % 3 == 1 {
129 "grid gap-4 @lg:grid-cols-2 @4xl:grid-cols-4"
130 } else {
131 "grid gap-4 @lg:grid-cols-2 @3xl:grid-cols-3"
132 };
133 let tiles: Vec<BoxView<'_>> = stats.into_iter().map(|stat| tile(cx, stat)).collect();
134 view! {
135 <div data-stats="" class="@container">
136 <div class=(class)>
137 for tile in tiles {
138 (tile)
139 }
140 </div>
141 </div>
142 }
143 .boxed()
144}
145
146fn tile(cx: &Cx, stat: Stat) -> BoxView<'_> {
147 let Stat {
148 label,
149 value,
150 detail,
151 icon,
152 href,
153 trend,
154 color,
155 chart,
156 } = stat;
157 let detail = detail.map(|detail| detail.localize(cx)).unwrap_or_default();
158 let (detail_icon, detail_icon_label) = match trend {
159 Some(Trend::Up) => (
160 Some(tablo_ui::icons::ARROW_UP),
161 crate::t!(cx, "tablo-stat-trend-up"),
162 ),
163 Some(Trend::Down) => (
164 Some(tablo_ui::icons::ARROW_DOWN),
165 crate::t!(cx, "tablo-stat-trend-down"),
166 ),
167 Some(Trend::Flat) => (
168 Some(tablo_ui::icons::MINUS),
169 crate::t!(cx, "tablo-stat-trend-flat"),
170 ),
171 None => (None, String::new()),
172 };
173 let mut attrs = topcoat::view::attributes! { data-stat="" };
174 if let Some(href) = &href {
175 let mut link = runtime_link(cx, href);
176 link.remove("href");
177 attrs.extend(link);
178 }
179 view! {
180 tablo_ui::stat(
181 label: label.localize(cx),
182 value: value,
183 detail: detail,
184 detail_icon: detail_icon,
185 detail_icon_label: detail_icon_label,
186 accent: color.text_class(),
187 chart: chart,
188 icon: icon,
189 href: href,
190 attrs: attrs
191 )
192 }
193 .boxed()
194}
195
196impl Stat {
197 pub async fn records<R: Resource>(cx: &Cx) -> Result<Option<Self>> {
208 if !super::can_list::<R>(cx) {
209 return Ok(None);
210 }
211 let (Some(item), Some(url)) = (
212 super::navigation_item::<R>(cx),
213 super::url::resource::<R>(cx),
214 ) else {
215 return Ok(None);
216 };
217 let count = crate::db::count(cx, crate::scoped_query::<R>(cx)?).await?;
218 let count = crate::display::NumberFormat::of(cx)
219 .spell(crate::display::Number::Int(count.into()), 0);
220 let mut stat = Self::new(item.label, count).href(url);
221 stat.icon = item.icon;
222 Ok(Some(stat))
223 }
224}
225
226pub async fn record_table<R: Resource>(
259 cx: &Cx,
260 table: RecordTable<R::Model>,
261) -> Result<BoxView<'_>> {
262 let Some(resource) = mounted::<R>(cx).filter(|_| super::can_list::<R>(cx)) else {
263 return Ok(().boxed());
264 };
265 let RecordTable {
266 heading,
267 description,
268 columns,
269 filter,
270 order_by,
271 limit,
272 ..
273 } = table;
274 let shown = resource.table_for(cx);
275 let shown = match &columns {
276 Some(names) => {
277 let declared = resource.table.declared_columns();
278 if let Some(name) = names
279 .iter()
280 .find(|name| !declared.iter().any(|column| column.name() == name.as_str()))
281 {
282 return Err(crate::error::declaration(format!(
283 "a record table of `{}` names `{name}`, a column its list does not declare",
284 resource.slug
285 )));
286 }
287 Arc::new(shown.only(names))
288 }
289 None => shown,
290 };
291 if shown.declared_columns().is_empty() {
292 return Ok(().boxed());
293 }
294 let mut query = resource.scoped_query(cx)?;
295 if let Some(filter) = filter {
296 query = query.filter(filter);
297 }
298 for order in order_by {
299 query = query.order_by(order);
300 }
301 let query = include_relations(query.limit(limit), shown.declared_columns());
302 let mut db = crate::db::db(cx)?;
303 let rows = query
304 .exec(&mut db)
305 .await
306 .map_err(crate::error::unavailable)?;
307 shown.restrict_relations(cx, &rows, &mut db).await?;
308 let declared = declared_chrome(cx, &resource);
309 let chrome = TableChrome {
310 delete: false,
311 actions: false,
312 ..declared
313 };
314 let back = topcoat::router::request::uri(cx)
315 .path_and_query()
316 .map_or_else(String::new, ToString::to_string);
317 let heading = heading.map_or_else(
318 || resource.plural_label.localize(cx),
319 |heading| heading.localize(cx),
320 );
321 let mut wired =
322 wire_table_over(cx, &resource, shown, chrome).prefixed(dom_scope(&resource.slug, &heading));
323 if !back.is_empty() {
325 wired = wired.returning_to(back);
326 }
327 let rows = wired.render_records(cx, rows).await?;
328 let description = description.map(|description| description.localize(cx));
329 let list = crate::navigation::runtime_link(cx, &resource.url);
330 let view_all = crate::t!(cx, "tablo-record-table-view-all");
331 let slug = resource.slug.clone();
332 Ok(view! {
333 <section
334 data-record-table=(slug)
335 class="relative overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-xs"
336 >
337 <div class="flex items-start justify-between gap-4 px-5 pt-5 pb-3">
338 <div class="flex min-w-0 flex-col gap-1">
339 <h2 class="text-base font-semibold">(heading)</h2>
340 if let Some(description) = description {
341 <p class="text-sm text-muted-foreground">(description)</p>
342 }
343 </div>
344 <a
345 (list)
346 data-record-table-list=""
347 class="shrink-0 text-sm text-muted-foreground hover:text-foreground"
348 >
349 (view_all)
350 </a>
351 </div>
352 (rows)
353 </section>
354 }
355 .boxed())
356}
357
358fn dom_scope(slug: &str, heading: &str) -> String {
361 let heading: String = heading
362 .chars()
363 .map(|c| {
364 if c.is_ascii_alphanumeric() {
365 c.to_ascii_lowercase()
366 } else {
367 '-'
368 }
369 })
370 .collect();
371 format!("{slug}-{heading}")
372}