Skip to main content

tablo_core/schema/layouts/
wizard.rs

1//! Holds the `Wizard` block, which walks a form's fields one step at a time.
2
3use topcoat::{
4    Result,
5    context::Cx,
6    view::{StaticClass, class, *},
7};
8
9use super::{Span, spanned, tab_id};
10use crate::schema::{
11    Schema,
12    condition::Condition,
13    fields::Field,
14    tree::{IntoSchema, Node, Retype, Source, render_nodes},
15};
16
17/// Renders the row of step triggers, which numbers them through a CSS counter: a step a condition
18/// hides takes no number.
19const STEP_LIST: StaticClass =
20    class!("flex flex-wrap items-center gap-x-6 gap-y-2 [counter-reset:tablo-step]");
21/// Renders one trigger, which the current step raises and an error marks.
22const STEP_TRIGGER: StaticClass = class!(
23    "group inline-flex items-center gap-2 rounded-md text-sm font-medium text-muted-foreground \
24     outline-none [counter-increment:tablo-step] focus-visible:ring-[3px] \
25     focus-visible:ring-ring/50 aria-[current=step]:text-foreground \
26     data-wizard-error:text-destructive"
27);
28/// Renders a trigger's number, filled on the current step and outlined on a passed one.
29const STEP_NUMBER: StaticClass = class!(
30    "flex size-7 shrink-0 items-center justify-center rounded-full border border-input text-xs \
31     before:content-[counter(tablo-step)] group-data-wizard-done:border-primary \
32     group-data-wizard-done:text-primary group-aria-[current=step]:border-primary \
33     group-aria-[current=step]:bg-primary group-aria-[current=step]:text-primary-foreground \
34     group-data-wizard-error:border-destructive group-data-wizard-error:text-destructive"
35);
36
37/// A block walking a form's fields one [`Step`] at a time, with a button to the next step and one
38/// back.
39///
40/// ```rust
41/// # #[derive(Debug, toasty::Model, tablo_core::ModelColumns)]
42/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String, email: String, bio: String }
43/// use tablo_core::{Field, Schema, Step, Wizard};
44///
45/// let user = User::fields();
46/// Schema::new(Wizard::new([
47///     Step::new("Account").schema((Field::text(user.name()), Field::text(user.email()))),
48///     Step::new("About").schema(Field::text(user.bio()).multiline(4)),
49/// ]));
50/// ```
51///
52/// Every step's fields render and post, as a [`Tabs`](crate::Tabs) block's do, and the form's
53/// own buttons save from any step. The next step opens once the browser's own checks pass on the
54/// current one's controls; a later step's trigger runs them on each step before it, and an earlier
55/// one's opens it without. Enter in a field moves to the next step rather than saving, until the
56/// last. The page opens on the first step holding a field error, else on the first step; the
57/// browser opens the step of a control it refuses on submit. A step whose every field the request
58/// may not see renders no trigger, and a block with no such step renders nothing.
59#[derive(Debug)]
60pub struct Wizard<C = Schema> {
61    /// The block's steps, each one node.
62    pub(crate) children: C,
63    pub(crate) condition: Option<Condition>,
64    pub(super) span: Option<Span>,
65}
66
67impl Wizard<()> {
68    /// Holds `steps`, in the order the form walks them.
69    pub fn new<F>(steps: impl IntoIterator<Item = Step<Schema<F>>>) -> Wizard<Schema<F>> {
70        let mut children = Schema::<F>::default();
71        for step in steps {
72            let mut fields = Vec::new();
73            let step = step.map(|mut children| {
74                fields = std::mem::take(&mut children.fields);
75                children.retype()
76            });
77            children.append(Schema::from_parts(vec![Node::Step(Box::new(step))], fields));
78        }
79        Wizard {
80            children,
81            condition: None,
82            span: None,
83        }
84    }
85}
86
87impl<C> Wizard<C> {
88    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Wizard<D> {
89        Wizard {
90            children: f(self.children),
91            condition: self.condition,
92            span: self.span,
93        }
94    }
95}
96
97impl<F> IntoSchema<F> for Wizard<Schema<F>> {
98    fn into_schema(self) -> Schema<F> {
99        let mut fields = Vec::new();
100        let block = self.map(|mut children| {
101            fields = std::mem::take(&mut children.fields);
102            children.retype()
103        });
104        Schema::from_parts(vec![Node::Wizard(Box::new(block))], fields)
105    }
106}
107
108/// One step of a [`Wizard`] block: a titled trigger and the fields it shows.
109#[derive(Debug)]
110pub struct Step<C = Schema> {
111    label: crate::Text,
112    pub(crate) children: C,
113    pub(crate) condition: Option<Condition>,
114}
115
116impl Step<()> {
117    pub fn new(label: impl Into<crate::Text>) -> Self {
118        Self {
119            label: label.into(),
120            children: (),
121            condition: None,
122        }
123    }
124
125    /// Holds a form's fields and blocks.
126    pub fn schema<F>(self, children: impl IntoSchema<F>) -> Step<Schema<F>> {
127        self.map(|_| children.into_schema())
128    }
129}
130
131impl<C> Step<C> {
132    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Step<D> {
133        Step {
134            label: self.label,
135            children: f(self.children),
136            condition: self.condition,
137        }
138    }
139}
140
141impl Wizard<Schema> {
142    pub(crate) async fn render<'a>(
143        &self,
144        cx: &'a Cx,
145        fields: &[Field],
146        source: &Source<'_>,
147    ) -> Result<BoxView<'a>> {
148        let steps: Vec<(&Node, &Step)> = self
149            .children
150            .nodes
151            .iter()
152            .filter_map(|node| match node {
153                Node::Step(step) if node.shows_any(fields, source) => Some((node, &**step)),
154                _ => None,
155            })
156            .collect();
157        let errored: Vec<bool> = steps
158            .iter()
159            .map(|(node, _)| node.has_error(fields, source))
160            .collect();
161        // The first step holding an error, else the first the page shows.
162        let shown: Vec<bool> = steps
163            .iter()
164            .map(|(_, step)| source.holds(step.condition.as_ref()))
165            .collect();
166        let active = errored
167            .iter()
168            .position(|e| *e)
169            .unwrap_or_else(|| shown.iter().position(|s| *s).unwrap_or(0));
170        let first = shown.iter().position(|s| *s);
171        let last = shown.iter().rposition(|s| *s);
172        let depth = source.tab_depth();
173        let mut triggers = Vec::with_capacity(steps.len());
174        let mut panels = Vec::with_capacity(steps.len());
175        for (index, (node, step)) in steps.iter().enumerate() {
176            let id = node
177                .first_field()
178                .map(|field| tab_id(&source.id(fields[field].name()), depth))
179                .unwrap_or_default();
180            source.set_tab_depth(depth + 1);
181            let body = render_nodes(cx, &step.children.nodes, fields, source).await;
182            source.set_tab_depth(depth);
183            let (trigger, panel) = step_view(
184                cx,
185                &id,
186                &step.label,
187                index.cmp(&active),
188                errored[index],
189                body?,
190            );
191            triggers.push(source.guard(cx, step.condition.as_ref(), trigger));
192            panels.push(source.guard(cx, step.condition.as_ref(), panel));
193        }
194        let view = view! {
195            <div data-wizard="" class="flex flex-col gap-6">
196                <div class=(STEP_LIST)>
197                    for trigger in triggers {
198                        (trigger)
199                    }
200                </div>
201                for panel in panels {
202                    (panel)
203                }
204                <div class="flex items-center justify-between gap-2">
205                    tablo_ui::button(
206                        variant: tablo_ui::ButtonVariant::Outline,
207                        attrs: attributes! {
208                            type="button"
209                            data-wizard-back=""
210                            hidden=(first.is_none_or(|first| active <= first))
211                        },
212                        (crate::t!(cx, "tablo-wizard-back"))
213                    )
214                    tablo_ui::button(
215                        variant: tablo_ui::ButtonVariant::Secondary,
216                        attrs: attributes! {
217                            type="button"
218                            data-wizard-next=""
219                            class="ms-auto"
220                            hidden=(last.is_none_or(|last| active >= last))
221                        },
222                        (crate::t!(cx, "tablo-wizard-next"))
223                    )
224                </div>
225            </div>
226        }
227        .boxed();
228        Ok(spanned(cx, self.span, view))
229    }
230}
231
232/// Renders the step `id`: a trigger reading `label`, and a panel holding `body`, shown only when
233/// `place` is the current step's. `errored` marks the trigger of a step holding a field error.
234fn step_view<'a>(
235    cx: &'a Cx,
236    id: &str,
237    label: &crate::Text,
238    place: std::cmp::Ordering,
239    errored: bool,
240    body: BoxView<'a>,
241) -> (BoxView<'a>, BoxView<'a>) {
242    let trigger_id = format!("{id}-step");
243    let panel_id = format!("{id}-steppanel");
244    let labelled_by = trigger_id.clone();
245    let controls = panel_id.clone();
246    let label = label.localize(cx);
247    let current = place.is_eq();
248    let trigger = view! {
249        <button
250            type="button"
251            id=(trigger_id)
252            aria-controls=(controls)
253            aria-current=(current.then_some("step"))
254            data-wizard-trigger=""
255            data-wizard-done=(place.is_lt())
256            data-wizard-error=(errored)
257            class=(STEP_TRIGGER)
258        >
259            <span aria-hidden="true" class=(STEP_NUMBER)></span>
260            (label)
261        </button>
262    }
263    .boxed();
264    let panel = view! {
265        <div
266            role="group"
267            id=(panel_id)
268            aria-labelledby=(labelled_by)
269            hidden=(!current)
270            data-wizard-step=""
271            class="flex flex-col gap-6"
272        >
273            (body)
274        </div>
275    }
276    .boxed();
277    (trigger, panel)
278}