Skip to main content

tablo_core/schema/
layouts.rs

1//! Holds the `Section`, `Group`, and `Grid` blocks that lay out a form's fields or a detail page's
2//! columns.
3
4use tablo_ui::{card_content, card_header, card_title, field_group as ui_field_group};
5use topcoat::{
6    Result,
7    context::Cx,
8    view::{StaticClass, class, *},
9};
10
11use super::{
12    Schema,
13    condition::{Condition, Watched},
14    fields::Field,
15    tree::{IntoSchema, Source, render_nodes},
16};
17use crate::detail::{Detail, IntoDetail};
18
19/// Renders a `Section`'s titled panel.
20const PANEL: StaticClass = class!(
21    "flex flex-col gap-5 rounded-xl border border-border bg-card py-6 text-card-foreground shadow-sm"
22);
23
24/// A titled block holding a form's fields ([`schema`](Self::schema)) or a detail page's columns
25/// ([`columns`](Self::columns)).
26#[derive(Debug)]
27pub struct Section<C = Schema> {
28    title: String,
29    pub(crate) children: C,
30    extra_class: Option<String>,
31    pub(crate) condition: Option<Condition>,
32}
33
34impl Section<()> {
35    pub fn new(title: impl Into<String>) -> Self {
36        Self {
37            title: title.into(),
38            children: (),
39            extra_class: None,
40            condition: None,
41        }
42    }
43
44    /// Holds a form's fields and blocks.
45    pub fn schema<F>(self, children: impl IntoSchema<F>) -> Section<Schema<F>> {
46        self.holding(children.into_schema())
47    }
48
49    /// Holds a detail page's columns and blocks.
50    pub fn columns<M>(self, children: impl IntoDetail<M>) -> Section<Detail<M>> {
51        self.holding(children.into_detail())
52    }
53}
54
55impl<C> Section<C> {
56    /// Adds an additive `class` hook on the panel container.
57    pub fn class(mut self, class: impl Into<String>) -> Self {
58        self.extra_class = Some(class.into());
59        self
60    }
61
62    pub(crate) fn holding<D>(self, children: D) -> Section<D> {
63        self.map(|_| children)
64    }
65
66    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Section<D> {
67        Section {
68            title: self.title,
69            children: f(self.children),
70            extra_class: self.extra_class,
71            condition: self.condition,
72        }
73    }
74
75    /// The titled panel around `body`, or the title alone when the section holds nothing.
76    pub(crate) fn chrome<'a>(&self, cx: &'a Cx, body: Option<BoxView<'a>>) -> BoxView<'a> {
77        let title = self.title.clone();
78        let extra = self.extra_class.clone();
79        match body {
80            Some(body) => view! {
81                cx =>
82                <div class=(class!(PANEL, extra.clone()))>
83                    card_header(card_title((title)))
84                    card_content(
85                        attrs: attributes! { class="flex flex-col gap-6" },
86                        (body)
87                    )
88                </div>
89            }
90            .boxed(),
91            None => view! {
92                cx =>
93                <div class=(class!(PANEL, extra.clone()))>
94                    card_header(card_title((title)))
95                </div>
96            }
97            .boxed(),
98        }
99    }
100}
101
102impl Section<Schema> {
103    pub(crate) async fn render<'a>(
104        &self,
105        cx: &'a Cx,
106        fields: &[Field],
107        source: &Source<'_>,
108    ) -> Result<BoxView<'a>> {
109        let body = if self.children.nodes.is_empty() {
110            None
111        } else {
112            Some(render_nodes(cx, &self.children.nodes, fields, source).await?)
113        };
114        Ok(self.chrome(cx, body))
115    }
116}
117
118/// An unlabelled block grouping a form's fields or a detail page's columns.
119#[derive(Debug)]
120pub struct Group<C = Schema> {
121    pub(crate) children: C,
122    pub(crate) condition: Option<Condition>,
123}
124
125impl Default for Group<()> {
126    fn default() -> Self {
127        Self {
128            children: (),
129            condition: None,
130        }
131    }
132}
133
134impl Group<()> {
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Holds a form's fields and blocks.
140    pub fn schema<F>(self, children: impl IntoSchema<F>) -> Group<Schema<F>> {
141        self.holding(children.into_schema())
142    }
143
144    /// Holds a detail page's columns and blocks.
145    pub fn columns<M>(self, children: impl IntoDetail<M>) -> Group<Detail<M>> {
146        self.holding(children.into_detail())
147    }
148}
149
150impl<C> Group<C> {
151    pub(crate) fn holding<D>(self, children: D) -> Group<D> {
152        self.map(|_| children)
153    }
154
155    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Group<D> {
156        Group {
157            children: f(self.children),
158            condition: self.condition,
159        }
160    }
161
162    /// The group around `body`.
163    pub(crate) fn chrome<'a>(&self, cx: &'a Cx, body: BoxView<'a>) -> BoxView<'a> {
164        view! { cx => ui_field_group((body)) }.boxed()
165    }
166}
167
168impl Group<Schema> {
169    pub(crate) async fn render<'a>(
170        &self,
171        cx: &'a Cx,
172        fields: &[Field],
173        source: &Source<'_>,
174    ) -> Result<BoxView<'a>> {
175        let body = render_nodes(cx, &self.children.nodes, fields, source).await?;
176        Ok(self.chrome(cx, body))
177    }
178}
179
180/// A block of `cols` columns, clamped to 1..12, laying out a form's fields or a detail page's
181/// columns.
182#[derive(Debug)]
183pub struct Grid<C = Schema> {
184    cols: u8,
185    pub(crate) children: C,
186    pub(crate) condition: Option<Condition>,
187}
188
189impl Grid<()> {
190    pub fn new(cols: u8) -> Self {
191        Self {
192            cols: cols.clamp(1, 12),
193            children: (),
194            condition: None,
195        }
196    }
197
198    /// Holds a form's fields and blocks.
199    pub fn schema<F>(self, children: impl IntoSchema<F>) -> Grid<Schema<F>> {
200        self.holding(children.into_schema())
201    }
202
203    /// Holds a detail page's columns and blocks.
204    pub fn columns<M>(self, children: impl IntoDetail<M>) -> Grid<Detail<M>> {
205        self.holding(children.into_detail())
206    }
207}
208
209impl<C> Grid<C> {
210    pub(crate) fn holding<D>(self, children: D) -> Grid<D> {
211        self.map(|_| children)
212    }
213
214    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Grid<D> {
215        Grid {
216            cols: self.cols,
217            children: f(self.children),
218            condition: self.condition,
219        }
220    }
221
222    /// The grid around `body`.
223    pub(crate) fn chrome<'a>(&self, cx: &'a Cx, body: BoxView<'a>) -> BoxView<'a> {
224        // Static literals for Tailwind scanner — `format!("grid grid-cols-{}")` would be
225        // purged because Tailwind only sees literal substrings.
226        let class: &'static str = match self.cols {
227            1 => "grid grid-cols-1 gap-4",
228            2 => "grid grid-cols-2 gap-4",
229            3 => "grid grid-cols-3 gap-4",
230            4 => "grid grid-cols-4 gap-4",
231            5 => "grid grid-cols-5 gap-4",
232            6 => "grid grid-cols-6 gap-4",
233            7 => "grid grid-cols-7 gap-4",
234            8 => "grid grid-cols-8 gap-4",
235            9 => "grid grid-cols-9 gap-4",
236            10 => "grid grid-cols-10 gap-4",
237            11 => "grid grid-cols-11 gap-4",
238            _ => "grid grid-cols-12 gap-4",
239        };
240        view! { cx => <div class=(class)>(body)</div> }.boxed()
241    }
242}
243
244impl Grid<Schema> {
245    pub(crate) async fn render<'a>(
246        &self,
247        cx: &'a Cx,
248        fields: &[Field],
249        source: &Source<'_>,
250    ) -> Result<BoxView<'a>> {
251        let body = render_nodes(cx, &self.children.nodes, fields, source).await?;
252        Ok(self.chrome(cx, body))
253    }
254}
255
256/// Generates `visible_when` for every layout block holding a form's fields.
257macro_rules! conditional_blocks {
258    ($($ty:ident),+ $(,)?) => {
259        $(
260            impl<F> $ty<Schema<F>> {
261                /// Shows the block and every field it holds only while `watched` posts one of
262                /// `values`, as [`TextField::visible_when`](crate::TextField::visible_when) shows a
263                /// field.
264                pub fn visible_when(
265                    mut self,
266                    watched: &impl Watched<F>,
267                    values: impl IntoIterator<Item = impl Into<String>>,
268                ) -> Self {
269                    self.condition = Some(Condition::new(watched, values));
270                    self
271                }
272            }
273        )+
274    };
275}
276
277conditional_blocks!(Section, Group, Grid);