use tablo_ui::{table_cell, table_row};
use topcoat::{
context::Cx,
icon::{IconData, icon},
runtime::Event,
view::*,
};
use super::{
super::{RowActions, TableSignals, WiredTable},
CHROME_CELL_CLASS, Frame,
};
use crate::{
Color,
column::Column,
panel::url::{delete_action_url, row_action_url, row_edit_url, row_view_url},
t,
table::{GroupDef, TablePage, bulk_token, group_header_dom_id, row_dom_id},
};
mod actions;
mod edit;
pub(super) struct RowChrome {
pub(super) with_bulk: bool,
pub(super) with_actions: bool,
pub(super) header_colspan: usize,
pub(super) cells: Vec<CellLayout>,
pub(super) signals: TableSignals,
pub(super) form: String,
pub(super) action_menu: bool,
pub(super) id_scope: String,
}
impl RowChrome {
fn row_id(&self, key: &str) -> String {
row_dom_id(&self.id_scope, key)
}
}
#[derive(Clone)]
pub(super) struct CellLayout {
pub(super) class: Option<&'static str>,
pub(super) style: Option<String>,
pub(super) content: Option<&'static str>,
}
pub(super) struct RenderedRow<'a> {
pub(super) key: String,
pub(super) view: BoxView<'a>,
}
pub(super) fn selectable_keys(rows: &[RowView<'_>]) -> Vec<String> {
rows.iter()
.filter(|row| row.selectable)
.map(|row| row.key.clone())
.collect()
}
pub(super) fn render_rows<'a>(
cx: &'a Cx,
rows: Vec<RowView<'a>>,
chrome: &RowChrome,
) -> Vec<RenderedRow<'a>> {
rows.into_iter()
.map(|row| RenderedRow {
key: row.key.clone(),
view: render_row(cx, row, chrome),
})
.collect()
}
fn render_group_header<'a>(
cx: &'a Cx,
group: RowGroup,
run: usize,
chrome: &RowChrome,
) -> BoxView<'a> {
let colspan = chrome.header_colspan;
let id = group_header_dom_id(&chrome.id_scope, &format!("{}{run}", group.token));
let RowGroup { token, label } = group;
let (shown, toggled) = (
chrome.signals.collapsed.clone(),
chrome.signals.collapsed.clone(),
);
let (expanded_token, toggle_token) = (token.clone(), token);
view! {
table_row(
attrs: attributes! { id=(id) data-group=(label.clone()) },
table_cell(
attrs: attributes! {
colspan=(colspan)
class="bg-muted/60 px-3 py-2 text-sm font-medium text-foreground"
},
<button
type="button"
class="inline-flex items-center gap-2 [&[aria-expanded=false]>svg]:-rotate-90"
aria-expanded="true"
:aria-expanded=$({
let wire = shown.get();
if raw!(
"cx.hydrate(String(${wire}).includes(String(${expanded_token})))",
wire.contains(expanded_token.as_str()),
) {
"false"
} else {
"true"
}
})
@click=$(|_e: Event| {
let wire = toggled.get();
if raw!(
"cx.hydrate(String(${wire}).includes(String(${toggle_token})))",
wire.contains(toggle_token.as_str()),
) {
let current = toggled.get();
toggled.set(
raw!(
"cx.hydrate(String(${current}).replaceAll(String(${toggle_token}), ','))",
current.to_owned(),
),
);
} else {
toggled.push_str(toggle_token);
}
})
>
icon(
data: tablo_ui::icons::CHEVRON_DOWN,
attrs: attributes! { class="size-4 transition-transform" }
)
(label)
</button>
)
)
}
.boxed()
}
/// One row: its group header when it opens a group, the bulk checkbox, the
/// cells, and the actions cell. A grouped row hides while its group is collapsed.
fn render_row<'a>(cx: &'a Cx, mut row: RowView<'a>, chrome: &RowChrome) -> BoxView<'a> {
let header = row
.opens_group
.zip(row.group.clone())
.map(|(run, group)| render_group_header(cx, group, run, chrome));
let dom_id = chrome.row_id(&row.key);
let bulk_cell = chrome.with_bulk.then(|| {
if row.selectable {
let described = dom_id.clone();
let (bulk, all) = (chrome.signals.bulk.clone(), chrome.signals.all.clone());
let token = bulk_token(&row.key);
let value = row.key.clone();
view! {
table_cell(
attrs: attributes! { class=(CHROME_CELL_CLASS) },
<input
type="checkbox"
value=(value)
aria-label=(t!(cx, "tablo-list-select-row"))
data-row-select=""
aria-describedby=(described)
:checked=$({
let wire = bulk.get();
raw!(
"cx.hydrate(String(${wire}).includes(String(${token})))",
wire.contains(token.as_str()),
)
})
@change=$(|e: Event| {
all.set(false);
let wire = bulk.get();
if e.target.checked {
bulk.push_str(token);
} else {
bulk.set(
raw!(
"cx.hydrate(String(${wire}).replaceAll(String(${token}), ',').replace(/^,+$/, ''))",
wire.to_owned(),
),
);
}
})
>
)
}
.boxed()
} else {
view! { table_cell(attrs: attributes! { class=(CHROME_CELL_CLASS) }) }.boxed()
}
});
// The whole row opens its record: an empty link in the first cell stretches over the row, and
// the cells' own links and controls rise above it.
let record_url = row.view_url.clone().or_else(|| row.edit_url.clone());
let row_class = [
record_url.is_some().then_some(RECORD_ROW_CLASS),
row.color.map(crate::Color::row_class),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
let tint = row.color.map(crate::Color::name);
let cells: Vec<(BoxView<'a>, CellLayout)> = std::mem::take(&mut row.cells)
.into_iter()
.zip(chrome.cells.iter().cloned())
.enumerate()
.map(|(index, (cell, layout))| match record_url.as_deref() {
Some(url) if index == 0 => (with_record_link(cx, url, cell), layout),
_ => (cell, layout),
})
.collect();
let actions = chrome
.with_actions
.then(|| actions::render_actions(cx, &row, chrome));
let mut row_attrs = attributes! { id=(dom_id) class=(row_class) data-row-color=(tint) };
if let Some(group) = &row.group {
let (collapsed, token) = (chrome.signals.collapsed.clone(), group.token.clone());
row_attrs.extend(attributes! {
:hidden=$({
let wire = collapsed.get();
raw!(
"cx.hydrate(String(${wire}).includes(String(${token})))",
wire.contains(token.as_str()),
)
})
});
}
view! {
if let Some(header) = header {
(header)
}
table_row(
attrs: row_attrs,
if let Some(cell) = bulk_cell {
(cell)
}
for (cell, layout) in cells {
table_cell(
attrs: attributes! { class=(layout.class) style=(layout.style) },
if let Some(content) = layout.content {
<div class=(content)>(cell)</div>
} else {
(cell)
}
)
}
if let Some(actions) = actions {
(actions)
}
)
}
.boxed()
}
/// A row that opens its record: it positions the record link, and lifts every link and control
/// in its cells above that link, so they keep their own clicks. A switch is lifted by its wrapper
/// rather than its input: lifting the input would cover the thumb drawn over it.
const RECORD_ROW_CLASS: &str = "relative [&_:is(a,button,input:not([role=switch]),select,textarea,label,summary,:has(>[role=switch])):not([data-record-link])]:relative [&_:is(a,button,input:not([role=switch]),select,textarea,label,summary,:has(>[role=switch])):not([data-record-link])]:z-10";
/// `cell` after an empty link to the row's record that stretches over the whole row. The link
/// wraps no cell, so a cell's own link never nests in it. The View or Edit control is the
/// keyboard's and the screen reader's way to the record, so the link takes no tab stop and stays
/// out of the tree.
fn with_record_link<'a>(cx: &'a Cx, url: &str, cell: BoxView<'a>) -> BoxView<'a> {
let link = crate::navigation::runtime_link(cx, url);
view! {
<a
(link)
class="absolute inset-0"
tabindex="-1"
aria-hidden="true"
data-record-link=""
></a>
(cell)
}
.boxed()
}
/// Precompute per-row presentation for the table body in owned data the lazy view captures.
pub(super) struct RowView<'a> {
/// The record's primary key, driving keyed diffs, DOM ids, URLs and bulk values.
key: String,
/// One rendered cell per column, in column order.
cells: Vec<BoxView<'a>>,
view_url: Option<String>,
edit_url: Option<String>,
/// The row's delete POST target, which the confirmation dialog posts.
delete_action: Option<String>,
/// The row's [links](crate::Table::row_link), each to a URL it may link.
links: Vec<RowLinkView>,
/// The custom row actions this record allows.
custom: Vec<RowButton>,
/// Whether the row renders a bulk checkbox.
selectable: bool,
/// The tint the table's [`row_color`](crate::Table::row_color) gives the row.
color: Option<crate::Color>,
/// The row's group, when `?group_by=` names a declared grouping.
group: Option<RowGroup>,
/// When the row opens a run of its group on the page, rendering the group's header above
/// itself: how many runs of the group come before.
opens_group: Option<usize>,
}
/// The group a row falls in.
#[derive(Clone, PartialEq)]
struct RowGroup {
/// What its collapse writes, as [`GroupDef::token`] spells it.
token: String,
/// What its header shows.
label: String,
}
/// One of a row's [links](crate::Table::row_link).
struct RowLinkView {
label: String,
url: String,
icon: Option<IconData>,
new_tab: bool,
}
/// One custom action's button in a row.
struct RowButton {
name: &'static str,
label: String,
/// The action's POST target for the row's record.
url: String,
/// The question the confirmation dialog asks first, when it does.
confirm: Option<String>,
icon: Option<IconData>,
color: Color,
/// The action's name when it asks for input, which its dialog then does.
input: Option<&'static str>,
/// Why the button shows disabled, when the record's state refuses the action.
disabled: Option<String>,
/// The hint the button shows on hover, which a disabled button leaves to its reason.
tooltip: Option<String>,
}
impl<M> WiredTable<M> {
/// Whether `row`, whose policy allows `actions`, takes a bulk checkbox: bulk delete
/// allows deleting it, or a bulk custom action allows it.
fn selectable(&self, frame: &Frame<'_>, row: &M, actions: RowActions) -> bool {
(frame.bulk_delete && actions.delete)
|| self
.bulk_custom_actions()
.any(|action| (action.allowed)(row))
}
/// `row`'s cell in `col`: its control, in a form posting it to the record's edit route,
/// where the row may be edited and the user may change the column's field; else its value.
fn cell<'a>(
&self,
cx: &'a Cx,
frame: &Frame<'_>,
row: &M,
key: &str,
actions: RowActions,
col: &dyn Column<M>,
) -> BoxView<'a> {
let edit = col
.internals()
.edits()
.zip(frame.edit_prefix.filter(|_| actions.edit));
let edits = self
.cell_edits()
.filter(|edits| (edits.changes)(col.name()));
let (Some((editor, prefix)), Some(edits)) = (edit, edits) else {
return col.cell(cx, row);
};
let row_id = row_dom_id(self.prefix().unwrap_or("table"), key);
edit::cell_form(
cx,
edit::CellForm {
id: format!("{row_id}-edit-{}", col.name()),
action: frame.action_url(row_edit_url(prefix, key)),
column: col.name().to_string(),
version: (edits.version)(row),
saves_on_change: editor.saves_on_change(),
control: col
.internals()
.editor(cx, row)
.unwrap_or_else(|| col.cell(cx, row)),
},
)
}
/// Project the loaded page into the row presentation the template renders, wired as
/// `frame` says.
pub(super) fn row_views<'a>(
&self,
cx: &'a Cx,
frame: &Frame<'_>,
page: &TablePage<M>,
grouping: Option<&GroupDef<M>>,
) -> Vec<RowView<'a>>
where
M: toasty::schema::Model,
{
let gated = frame.delete_prefix.is_some()
|| frame.edit_prefix.is_some()
|| frame.view_prefix.is_some();
let mut row_data: Vec<RowView<'a>> = page
.rows
.iter()
.map(|row| {
let key = self.key_of(row);
let actions = if gated {
self.actions_for(row)
} else {
RowActions::ALL
};
let cells: Vec<BoxView<'a>> = self
.visible_columns()
.map(|col| self.cell(cx, frame, row, &key, actions, col.as_ref()))
.collect();
let edit_url = frame
.edit_prefix
.filter(|_| actions.edit)
.map(|prefix| frame.action_url(row_edit_url(prefix, &key)));
let view_url = frame
.view_prefix
.filter(|_| actions.view)
.map(|prefix| row_view_url(prefix, &key));
let delete_action = frame
.delete_prefix
.filter(|_| actions.delete)
.map(|prefix| frame.action_url(delete_action_url(prefix, &key)));
let custom = frame
.actions_prefix
.map(|prefix| {
self.row_custom_actions()
.filter_map(|action| {
let disabled = if (action.allowed)(row) {
None
} else {
Some((action.disabled)(row)?)
};
let url = row_action_url(prefix, &key, action.name);
let input = action.input.map(|_| action.name);
let (label, face_icon) = (action.record_face)(row);
let tooltip = match disabled {
Some(_) => None,
None => action.tooltip.clone(),
};
Some(RowButton {
name: action.name,
label,
url: frame.action_url(url),
confirm: (action.confirm && input.is_none())
.then(|| action.prompt.clone()),
icon: face_icon,
color: action.color,
input,
tooltip,
disabled,
})
})
.collect()
})
.unwrap_or_default();
let links = self
.row_links()
.iter()
.filter_map(|link| {
Some(RowLinkView {
label: link.label.localize(cx),
url: link.url_of(row)?,
icon: link.icon.clone(),
new_tab: link.new_tab,
})
})
.collect();
let selectable = frame.bulk_enabled() && self.selectable(frame, row, actions);
RowView {
key,
cells,
view_url,
edit_url,
delete_action,
links,
custom,
selectable,
color: self.color_of(row),
group: grouping.map(|group| {
let label = group.key()(row).map(|label| label.localize(cx));
RowGroup {
token: group.token(label.as_deref()),
label: label.unwrap_or_else(|| t!(cx, "tablo-list-group-empty")),
}
}),
opens_group: None,
}
})
.collect();
// The query orders the rows by the grouping's field, so a group's rows run together and
// a new group opens the next run.
let mut previous: Option<String> = None;
let mut runs: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for row in &mut row_data {
let token = row.group.as_ref().map(|group| group.token.clone());
if token.is_some() && token != previous {
let run = runs.entry(token.clone().unwrap_or_default()).or_default();
row.opens_group = Some(*run);
*run += 1;
}
previous = token;
}
row_data
}
}