Skip to main content

tablo_core/schema/
condition.rs

1//! Conditions: a field or a layout block that shows only while another field posts one of the
2//! values its condition names, and a field whose control is disabled while it does.
3//!
4//! The browser follows the watched field through one signal per condition, so the form needs no
5//! rerun. A hidden field or block, and a disabled control, sit in a disabled fieldset, which the
6//! browser neither validates nor submits. The server reads the same submission:
7//! [`Schema::condition_hidden`] and [`Schema::condition_disabled`] name the fields they leave out,
8//! and the panel drops their keys before parsing, so an edit keeps their stored values and a
9//! create takes their blank answers, or a disabled field's default.
10
11use std::collections::HashMap;
12
13use topcoat::{
14    context::Cx,
15    runtime::{Event, Signal, signal},
16    view::*,
17};
18
19use super::{
20    Schema,
21    fields::{ChoiceField, CustomField, Field, TextField},
22    tree::Node,
23};
24use crate::{DeclarationErrorKind, topcoat_compat::async_page, values::FormValues};
25
26/// A field a condition can watch: a text, choice or custom field of the form `F`, passed to
27/// `visible_when` by reference.
28///
29/// A condition follows a toggle (`Field::toggle`, `Field::toggle_input`) through its checkbox, and
30/// any other control through its `value`: an app's own checkbox control posts the same `value`
31/// checked or not, so a condition cannot follow it.
32pub trait Watched<F> {
33    #[doc(hidden)]
34    fn key(&self) -> &str;
35
36    #[doc(hidden)]
37    fn checkbox(&self) -> bool {
38        false
39    }
40}
41
42impl<F> Watched<F> for TextField<F> {
43    fn key(&self) -> &str {
44        self.name()
45    }
46}
47
48impl<F> Watched<F> for ChoiceField<F> {
49    fn key(&self) -> &str {
50        self.name()
51    }
52}
53
54impl<F> Watched<F> for CustomField<F> {
55    fn key(&self) -> &str {
56        self.name()
57    }
58
59    fn checkbox(&self) -> bool {
60        self.is_checkbox()
61    }
62}
63
64/// Shows a field or a block, or disables a field's control, while the field `watched` posts one
65/// of `values`.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub(crate) struct Condition {
68    watched: String,
69    /// Whether the watched field is a checkbox, which renders unchecked with no value.
70    checkbox: bool,
71    values: Vec<String>,
72    /// Whether a submission that hides what this guards still parses, as an embedded shared
73    /// column's does: the codec reads that column only for the variants that declare it.
74    parses_when_hidden: bool,
75}
76
77impl Condition {
78    pub(crate) fn new<F>(
79        watched: &impl Watched<F>,
80        values: impl IntoIterator<Item = impl Into<String>>,
81    ) -> Self {
82        Self {
83            watched: watched.key().to_string(),
84            checkbox: watched.checkbox(),
85            values: values.into_iter().map(Into::into).collect(),
86            parses_when_hidden: false,
87        }
88    }
89
90    /// A condition watching the field named `key`, which posts a value rather than a checkbox's
91    /// checked state.
92    pub(crate) fn watching(
93        key: impl Into<String>,
94        values: impl IntoIterator<Item = impl Into<String>>,
95    ) -> Self {
96        Self {
97            watched: key.into(),
98            checkbox: false,
99            values: values.into_iter().map(Into::into).collect(),
100            parses_when_hidden: true,
101        }
102    }
103
104    /// Whether the submission `values` shows what the condition guards. A checkbox with no value
105    /// reads as `false`, as it renders and posts.
106    pub(crate) fn holds(&self, values: &FormValues) -> bool {
107        let posted = match values.text(&self.watched) {
108            Some(value) => value.trim(),
109            None if self.checkbox => "false",
110            None => "",
111        };
112        self.values.iter().any(|value| value == posted)
113    }
114
115    /// Renders `inner` in a fieldset the browser hides and disables while the condition fails, so
116    /// a hidden required field never blocks the submit.
117    pub(crate) fn guard<'a>(
118        &self,
119        cx: &'a Cx,
120        scope: &str,
121        values: &FormValues,
122        inner: BoxView<'a>,
123    ) -> BoxView<'a> {
124        let condition = self.clone();
125        let initial = self.holds(values);
126        let scope = scope.to_string();
127        async_page(async move {
128            let shown = shown(cx, &scope, &condition, initial);
129            let enabled = shown.clone();
130            Ok(view! {
131                <fieldset
132                    class="contents"
133                    :disabled=$(!enabled.get())
134                    :hidden=$(!shown.get())
135                >
136                    (inner)
137                </fieldset>
138            })
139        })
140    }
141}
142
143/// Renders `inner` in a fieldset the browser disables while `condition` holds, so the control
144/// neither validates nor posts.
145pub(crate) fn disable<'a>(
146    cx: &'a Cx,
147    condition: &Condition,
148    scope: &str,
149    values: &FormValues,
150    inner: BoxView<'a>,
151) -> BoxView<'a> {
152    let condition = condition.clone();
153    let initial = condition.holds(values);
154    let scope = scope.to_string();
155    async_page(async move {
156        let holds = shown(cx, &scope, &condition, initial);
157        Ok(view! {
158            <fieldset class="contents" :disabled=$(holds.get())>(inner)</fieldset>
159        })
160    })
161}
162
163/// The signal holding whether `condition` holds in the form `scope`, shared by its guard and its
164/// watched field.
165///
166/// Keyed by the page's path: navigation carries the values of signals two pages share, and
167/// another record's form starts from its own stored values. Keyed by the form's scope too: an
168/// action's input in a dialog may watch a key the page's own form watches.
169fn shown(cx: &Cx, scope: &str, condition: &Condition, initial: bool) -> Signal<bool> {
170    let page = topcoat::context::try_context::<http::request::Parts>(cx)
171        .map(|parts| parts.uri.path().to_string())
172        .unwrap_or_default();
173    signal(&cx.keyed(signal_key(&page, scope, condition)), move || {
174        initial
175    })
176}
177
178/// What names the signal of `condition` in the form `scope` on the page at `page`.
179fn signal_key(
180    page: &str,
181    scope: &str,
182    condition: &Condition,
183) -> (&'static str, String, String, String, String) {
184    (
185        "tablo-condition",
186        page.to_string(),
187        scope.to_string(),
188        condition.watched.clone(),
189        condition.values.join("\u{1f}"),
190    )
191}
192
193/// Renders the watched field `field`, posting under `posted`, inside one change handler per
194/// condition reading it.
195///
196/// A handler reads only the event of the control posting the watched key: a searchable choice's
197/// filter box or an app control's own inputs change inside the same wrapper. A runtime expression
198/// has no `||`, so a condition naming several values stacks one handler per value: the innermost
199/// sets the signal from the first value, and each outer one, run after it as the event bubbles,
200/// sets it when its own value matches.
201pub(crate) fn watch<'a>(
202    cx: &'a Cx,
203    field: &Field,
204    posted: &str,
205    conditions: &[Condition],
206    scope: &str,
207    values: &FormValues,
208    control: BoxView<'a>,
209) -> BoxView<'a> {
210    let scope = scope.to_string();
211    let conditions: Vec<(Condition, bool)> = conditions
212        .iter()
213        .map(|condition| (condition.clone(), condition.holds(values)))
214        .collect();
215    let checkbox = field.is_checkbox();
216    let key = posted.to_string();
217    async_page(async move {
218        let mut view = control;
219        for (condition, initial) in conditions {
220            let shown = shown(cx, &scope, &condition, initial);
221            if checkbox {
222                // A checkbox posts `true` when checked and its hidden `false` otherwise.
223                let on = condition.values.iter().any(|value| value == "true");
224                let off = condition.values.iter().any(|value| value == "false");
225                let name = key.clone();
226                view = view! {
227                    <div
228                        class="contents"
229                        @change=$(|e: Event| if e.target.name == name {
230                            shown.set(if e.target.checked { on } else { off })
231                        })
232                    >
233                        (view)
234                    </div>
235                }
236                .boxed();
237                continue;
238            }
239            let mut values = condition.values.into_iter();
240            let Some(first) = values.next() else {
241                continue;
242            };
243            let (set, name) = (shown.clone(), key.clone());
244            view = view! {
245                <div
246                    class="contents"
247                    @change=$(|e: Event| if e.target.name == name {
248                        set.set(e.target.value.trim() == first)
249                    })
250                >
251                    (view)
252                </div>
253            }
254            .boxed();
255            for value in values {
256                let (set, name) = (shown.clone(), key.clone());
257                view = view! {
258                    <div
259                        class="contents"
260                        @change=$(|e: Event| if e.target.name == name {
261                            if e.target.value.trim() == value {
262                                set.set(true)
263                            }
264                        })
265                    >
266                        (view)
267                    </div>
268                }
269                .boxed();
270            }
271        }
272        Ok(view)
273    })
274}
275
276impl Node {
277    /// The condition on this node: a field's own, or a layout block's.
278    fn condition<'s>(&'s self, fields: &'s [Field]) -> Option<&'s Condition> {
279        match self {
280            Node::Field(index) => fields[*index].condition(),
281            Node::Section(s) => s.condition.as_ref(),
282            Node::Group(g) => g.condition.as_ref(),
283            Node::Grid(g) => g.condition.as_ref(),
284            Node::Tabs(t) => t.condition.as_ref(),
285            Node::Tab(t) => t.condition.as_ref(),
286            Node::Wizard(w) => w.condition.as_ref(),
287            Node::Step(s) => s.condition.as_ref(),
288            Node::Embedded(_) => None,
289        }
290    }
291}
292
293/// Where a schema's conditions sit.
294#[derive(Default)]
295struct Scan<'s> {
296    /// Each field slot with the conditions guarding it, outermost first and its own last.
297    fields: Vec<(usize, Vec<&'s Condition>)>,
298    /// Each condition with the conditions guarding the node it sits on.
299    conditions: Vec<(&'s Condition, Vec<&'s Condition>)>,
300    /// Each field slot a condition disables, with that condition.
301    disabling: Vec<(usize, &'s Condition)>,
302}
303
304impl<'s> Scan<'s> {
305    fn of(nodes: &'s [Node], fields: &'s [Field]) -> Self {
306        let mut scan = Self::default();
307        scan.walk(nodes, fields, &mut Vec::new());
308        // A field's control sits inside every block guarding it, so its disabling condition
309        // shows exactly while the field does.
310        for (index, chain) in &scan.fields {
311            if let Some(condition) = fields[*index].disabling() {
312                scan.disabling.push((*index, condition));
313                scan.conditions.push((condition, chain.clone()));
314            }
315        }
316        scan
317    }
318
319    fn walk(&mut self, nodes: &'s [Node], fields: &'s [Field], chain: &mut Vec<&'s Condition>) {
320        for node in nodes {
321            let own = node.condition(fields);
322            if let Some(condition) = own {
323                self.conditions.push((condition, chain.clone()));
324                chain.push(condition);
325            }
326            match node {
327                Node::Field(index) => self.fields.push((*index, chain.clone())),
328                Node::Embedded(embedded) => {
329                    // An embedded value's leaves are placed by its node, and each carries its own
330                    // condition as a top-level field does.
331                    embedded.visit_fields(&mut |index| {
332                        let own = fields[index].condition();
333                        if let Some(condition) = own {
334                            self.conditions.push((condition, chain.clone()));
335                            chain.push(condition);
336                        }
337                        self.fields.push((index, chain.clone()));
338                        if own.is_some() {
339                            chain.pop();
340                        }
341                    });
342                }
343                node => self.walk(node.children().unwrap_or_default(), fields, chain),
344            }
345            if own.is_some() {
346                chain.pop();
347            }
348        }
349    }
350
351    /// The conditions guarding the field named `key`, or `None` when the schema places none.
352    fn guarding(&self, fields: &[Field], key: &str) -> Option<&[&'s Condition]> {
353        self.fields
354            .iter()
355            .find(|(index, _)| fields[*index].name() == key)
356            .map(|(_, chain)| chain.as_slice())
357    }
358}
359
360impl<F> Schema<F> {
361    /// The keys of the fields a condition hides in the submission `values`.
362    pub(crate) fn condition_hidden(&self, values: &FormValues) -> Vec<String> {
363        Scan::of(&self.nodes, &self.fields)
364            .fields
365            .into_iter()
366            .filter(|(_, chain)| chain.iter().any(|condition| !condition.holds(values)))
367            .map(|(index, _)| self.fields[index].name().to_string())
368            .collect()
369    }
370
371    /// The keys of the fields a condition disables in the submission `values`, leaving out the
372    /// ones a condition hides: those take the blank answer, not the `default`.
373    pub(crate) fn condition_disabled(&self, values: &FormValues) -> Vec<String> {
374        let scan = Scan::of(&self.nodes, &self.fields);
375        let hidden: Vec<usize> = scan
376            .fields
377            .iter()
378            .filter(|(_, chain)| chain.iter().any(|condition| !condition.holds(values)))
379            .map(|(index, _)| *index)
380            .collect();
381        scan.disabling
382            .into_iter()
383            .filter(|(index, condition)| !hidden.contains(index) && condition.holds(values))
384            .map(|(index, _)| self.fields[index].name().to_string())
385            .collect()
386    }
387
388    /// Each key a condition watches, with the key of a field it guards or disables, or `None`
389    /// when it guards none.
390    pub(crate) fn condition_watchers(&self) -> Vec<(String, Option<String>)> {
391        let scan = Scan::of(&self.nodes, &self.fields);
392        let mut watchers = Vec::new();
393        for (condition, _) in &scan.conditions {
394            let disabled = scan
395                .disabling
396                .iter()
397                .filter(|(_, c)| std::ptr::eq(*c, *condition))
398                .map(|(index, _)| *index);
399            let mut guards = scan
400                .fields
401                .iter()
402                .filter(|(_, chain)| chain.iter().any(|c| std::ptr::eq(*c, *condition)))
403                .map(|(index, _)| *index)
404                .chain(disabled)
405                .map(|index| self.fields[index].name().to_string())
406                .peekable();
407            if guards.peek().is_none() {
408                watchers.push((condition.watched.clone(), None));
409            }
410            watchers.extend(guards.map(|key| (condition.watched.clone(), Some(key))));
411        }
412        watchers
413    }
414
415    /// The conditions each watched field's key drives, each once.
416    pub(crate) fn watched(&self) -> HashMap<String, Vec<Condition>> {
417        let mut out: HashMap<String, Vec<Condition>> = HashMap::new();
418        for (condition, _) in Scan::of(&self.nodes, &self.fields).conditions {
419            let driven = out.entry(condition.watched.clone()).or_default();
420            if !driven.contains(condition) {
421                driven.push(condition.clone());
422            }
423        }
424        out
425    }
426
427    /// What is wrong with the schema's conditions: a watched field the schema does not place, one
428    /// a condition hides while what it guards still shows, one a condition disables, a value the
429    /// watched field never posts, and a hidden field with no blank answer, which a submission
430    /// that hides it could not parse.
431    pub(crate) fn condition_errors(&self) -> Vec<DeclarationErrorKind> {
432        let scan = Scan::of(&self.nodes, &self.fields);
433        let mut errors = Vec::new();
434        for (condition, guarding) in &scan.conditions {
435            let watched = condition.watched.clone();
436            match scan.guarding(&self.fields, &condition.watched) {
437                None => errors.push(DeclarationErrorKind::UnplacedWatchedField {
438                    field: watched.clone(),
439                }),
440                // The browser follows the watched field's last change, the server its posted
441                // value: they agree only while every condition hiding the watched field also
442                // hides what it guards.
443                Some(chain) if !chain.iter().all(|hiding| guarding.contains(hiding)) => {
444                    errors.push(DeclarationErrorKind::HiddenWatchedField {
445                        field: watched.clone(),
446                    });
447                }
448                Some(_) => {}
449            }
450            if let Some(field) = self.fields.iter().find(|f| f.name() == condition.watched)
451                && field.disabling().is_some()
452            {
453                errors.push(DeclarationErrorKind::DisabledWatchedField {
454                    field: watched.clone(),
455                });
456            }
457            if let Some(field) = self.fields.iter().find(|f| f.name() == condition.watched) {
458                for value in &condition.values {
459                    if field.can_post(value) == Some(false) {
460                        errors.push(DeclarationErrorKind::UnpostedConditionValue {
461                            field: watched.clone(),
462                            value: value.clone(),
463                        });
464                    }
465                }
466            }
467        }
468        for (index, chain) in &scan.fields {
469            let field = &self.fields[*index];
470            // A condition hiding a field the codec reads nothing of there needs no blank answer.
471            let hides_unparsed = chain.iter().any(|condition| !condition.parses_when_hidden);
472            if hides_unparsed && field.is_required() {
473                errors.push(DeclarationErrorKind::RequiredConditionalField {
474                    field: field.name().to_string(),
475                });
476            }
477        }
478        errors
479    }
480}
481
482#[cfg(test)]
483mod tests;