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.
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 sits in a disabled fieldset, which the browser neither validates
6//! nor submits. The server reads the same submission: [`Schema::condition_hidden`] names the
7//! fields it hides, and the panel drops their keys before parsing, so an edit keeps their stored
8//! values and a create takes their blank answers.
9
10use std::collections::HashMap;
11
12use topcoat::{
13    context::Cx,
14    runtime::{Event, Signal, signal},
15    view::*,
16};
17
18use super::{
19    Schema,
20    fields::{ChoiceField, CustomField, Field, TextField},
21    tree::Node,
22};
23use crate::{DeclarationErrorKind, topcoat_compat::async_page};
24
25/// A field a condition can watch: a text, choice or custom field of the form `F`, passed to
26/// `visible_when` by reference.
27///
28/// A condition follows a toggle (`Field::toggle`, `Field::toggle_input`) through its checkbox, and
29/// any other control through its `value`: an app's own checkbox control posts the same `value`
30/// checked or not, so a condition cannot follow it.
31pub trait Watched<F> {
32    #[doc(hidden)]
33    fn key(&self) -> &str;
34
35    #[doc(hidden)]
36    fn checkbox(&self) -> bool {
37        false
38    }
39}
40
41impl<F> Watched<F> for TextField<F> {
42    fn key(&self) -> &str {
43        self.name()
44    }
45}
46
47impl<F> Watched<F> for ChoiceField<F> {
48    fn key(&self) -> &str {
49        self.name()
50    }
51}
52
53impl<F> Watched<F> for CustomField<F> {
54    fn key(&self) -> &str {
55        self.name()
56    }
57
58    fn checkbox(&self) -> bool {
59        self.is_checkbox()
60    }
61}
62
63/// Shows a field or a block while the field `watched` posts one of `values`.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub(crate) struct Condition {
66    watched: String,
67    /// Whether the watched field is a checkbox, which renders unchecked with no value.
68    checkbox: bool,
69    values: Vec<String>,
70}
71
72impl Condition {
73    pub(crate) fn new<F>(
74        watched: &impl Watched<F>,
75        values: impl IntoIterator<Item = impl Into<String>>,
76    ) -> Self {
77        Self {
78            watched: watched.key().to_string(),
79            checkbox: watched.checkbox(),
80            values: values.into_iter().map(Into::into).collect(),
81        }
82    }
83
84    /// Whether the submission `values` shows what the condition guards. A checkbox with no value
85    /// reads as `false`, as it renders and posts.
86    fn holds(&self, values: &HashMap<String, String>) -> bool {
87        let posted = match values.get(&self.watched) {
88            Some(value) => value.trim(),
89            None if self.checkbox => "false",
90            None => "",
91        };
92        self.values.iter().any(|value| value == posted)
93    }
94
95    /// Renders `inner` in a fieldset the browser hides and disables while the condition fails, so
96    /// a hidden required field never blocks the submit.
97    pub(crate) fn guard<'a>(
98        &self,
99        cx: &'a Cx,
100        scope: &str,
101        values: &HashMap<String, String>,
102        inner: BoxView<'a>,
103    ) -> BoxView<'a> {
104        let condition = self.clone();
105        let initial = self.holds(values);
106        let scope = scope.to_string();
107        async_page(async move {
108            let shown = shown(cx, &scope, &condition, initial);
109            let enabled = shown.clone();
110            Ok(view! {
111                cx =>
112                <fieldset
113                    class="contents"
114                    :disabled=$(!enabled.get())
115                    :hidden=$(!shown.get())
116                >
117                    (inner)
118                </fieldset>
119            })
120        })
121    }
122}
123
124/// The signal holding whether `condition` holds in the form `scope`, shared by its guard and its
125/// watched field.
126///
127/// Keyed by the page's path: navigation carries the values of signals two pages share, and
128/// another record's form starts from its own stored values. Keyed by the form's scope too: an
129/// action's input in a dialog may watch a key the page's own form watches.
130fn shown(cx: &Cx, scope: &str, condition: &Condition, initial: bool) -> Signal<bool> {
131    let page = topcoat::context::try_request_context::<http::request::Parts>(cx)
132        .map(|parts| parts.uri.path().to_string())
133        .unwrap_or_default();
134    let values = condition.values.join("\u{1f}");
135    signal(
136        &cx.keyed((
137            "tablo-condition",
138            page,
139            scope,
140            condition.watched.as_str(),
141            values,
142        )),
143        move || initial,
144    )
145}
146
147/// Renders the watched field `field` inside one change handler per condition reading it.
148///
149/// A handler reads only the event of the control posting the watched key: a searchable choice's
150/// filter box or an app control's own inputs change inside the same wrapper. A runtime expression
151/// has no `||`, so a condition naming several values stacks one handler per value: the innermost
152/// sets the signal from the first value, and each outer one, run after it as the event bubbles,
153/// sets it when its own value matches.
154pub(crate) fn watch<'a>(
155    cx: &'a Cx,
156    field: &Field,
157    conditions: &[Condition],
158    scope: &str,
159    values: &HashMap<String, String>,
160    control: BoxView<'a>,
161) -> BoxView<'a> {
162    let scope = scope.to_string();
163    let conditions: Vec<(Condition, bool)> = conditions
164        .iter()
165        .map(|condition| (condition.clone(), condition.holds(values)))
166        .collect();
167    let checkbox = field.is_checkbox();
168    let key = field.name().to_string();
169    async_page(async move {
170        let mut view = control;
171        for (condition, initial) in conditions {
172            let shown = shown(cx, &scope, &condition, initial);
173            if checkbox {
174                // A checkbox posts `true` when checked and its hidden `false` otherwise.
175                let on = condition.values.iter().any(|value| value == "true");
176                let off = condition.values.iter().any(|value| value == "false");
177                let name = key.clone();
178                view = view! {
179                    cx =>
180                    <div
181                        class="contents"
182                        @change=$(|e: Event| if e.target.name == name {
183                            shown.set(if e.target.checked { on } else { off })
184                        })
185                    >
186                        (view)
187                    </div>
188                }
189                .boxed();
190                continue;
191            }
192            let mut values = condition.values.into_iter();
193            let Some(first) = values.next() else {
194                continue;
195            };
196            let (set, name) = (shown.clone(), key.clone());
197            view = view! {
198                cx =>
199                <div
200                    class="contents"
201                    @change=$(|e: Event| if e.target.name == name {
202                        set.set(e.target.value.trim() == first)
203                    })
204                >
205                    (view)
206                </div>
207            }
208            .boxed();
209            for value in values {
210                let (set, name) = (shown.clone(), key.clone());
211                view = view! {
212                    cx =>
213                    <div
214                        class="contents"
215                        @change=$(|e: Event| if e.target.name == name {
216                            if e.target.value.trim() == value {
217                                set.set(true)
218                            }
219                        })
220                    >
221                        (view)
222                    </div>
223                }
224                .boxed();
225            }
226        }
227        Ok(view)
228    })
229}
230
231impl Node {
232    /// The condition on this node: a field's own, or a layout block's.
233    fn condition<'s>(&'s self, fields: &'s [Field]) -> Option<&'s Condition> {
234        match self {
235            Node::Field(index) => fields[*index].condition(),
236            Node::Section(s) => s.condition.as_ref(),
237            Node::Group(g) => g.condition.as_ref(),
238            Node::Grid(g) => g.condition.as_ref(),
239            Node::Embedded(_) | Node::Unbound(_) => None,
240        }
241    }
242}
243
244/// Where a schema's conditions sit.
245#[derive(Default)]
246struct Scan<'s> {
247    /// Each field slot with the conditions guarding it, outermost first and its own last.
248    fields: Vec<(usize, Vec<&'s Condition>)>,
249    /// Each condition with the conditions guarding the node it sits on.
250    conditions: Vec<(&'s Condition, Vec<&'s Condition>)>,
251}
252
253impl<'s> Scan<'s> {
254    fn of(nodes: &'s [Node], fields: &'s [Field]) -> Self {
255        let mut scan = Self::default();
256        scan.walk(nodes, fields, &mut Vec::new());
257        scan
258    }
259
260    fn walk(&mut self, nodes: &'s [Node], fields: &'s [Field], chain: &mut Vec<&'s Condition>) {
261        for node in nodes {
262            let own = node.condition(fields);
263            if let Some(condition) = own {
264                self.conditions.push((condition, chain.clone()));
265                chain.push(condition);
266            }
267            match node {
268                Node::Field(index) => self.fields.push((*index, chain.clone())),
269                Node::Embedded(embedded) => {
270                    embedded.visit_fields(&mut |index| self.fields.push((index, chain.clone())));
271                }
272                node => self.walk(node.children().unwrap_or_default(), fields, chain),
273            }
274            if own.is_some() {
275                chain.pop();
276            }
277        }
278    }
279
280    /// The conditions guarding the field named `key`, or `None` when the schema places none.
281    fn guarding(&self, fields: &[Field], key: &str) -> Option<&[&'s Condition]> {
282        self.fields
283            .iter()
284            .find(|(index, _)| fields[*index].name() == key)
285            .map(|(_, chain)| chain.as_slice())
286    }
287}
288
289impl<F> Schema<F> {
290    /// The keys of the fields a condition hides in the submission `values`.
291    pub(crate) fn condition_hidden(&self, values: &HashMap<String, String>) -> Vec<String> {
292        Scan::of(&self.nodes, &self.fields)
293            .fields
294            .into_iter()
295            .filter(|(_, chain)| chain.iter().any(|condition| !condition.holds(values)))
296            .map(|(index, _)| self.fields[index].name().to_string())
297            .collect()
298    }
299
300    /// The conditions each watched field's key drives, each once.
301    pub(crate) fn watched(&self) -> HashMap<String, Vec<Condition>> {
302        let mut out: HashMap<String, Vec<Condition>> = HashMap::new();
303        for (condition, _) in Scan::of(&self.nodes, &self.fields).conditions {
304            let driven = out.entry(condition.watched.clone()).or_default();
305            if !driven.contains(condition) {
306                driven.push(condition.clone());
307            }
308        }
309        out
310    }
311
312    /// What is wrong with the schema's conditions: a watched field the schema does not place, one
313    /// a condition hides while what it guards still shows, a value the watched field never posts,
314    /// and a hidden field with no blank answer, which a submission that hides it could not parse.
315    pub(crate) fn condition_errors(&self) -> Vec<DeclarationErrorKind> {
316        let scan = Scan::of(&self.nodes, &self.fields);
317        let mut errors = Vec::new();
318        for (condition, guarding) in &scan.conditions {
319            let watched = condition.watched.clone();
320            match scan.guarding(&self.fields, &condition.watched) {
321                None => errors.push(DeclarationErrorKind::UnplacedWatchedField {
322                    field: watched.clone(),
323                }),
324                // The browser follows the watched field's last change, the server its posted
325                // value: they agree only while every condition hiding the watched field also
326                // hides what it guards.
327                Some(chain)
328                    if !chain
329                        .iter()
330                        .all(|hiding| guarding.iter().any(|c| std::ptr::eq(*c, *hiding))) =>
331                {
332                    errors.push(DeclarationErrorKind::HiddenWatchedField {
333                        field: watched.clone(),
334                    });
335                }
336                Some(_) => {}
337            }
338            if let Some(field) = self.fields.iter().find(|f| f.name() == condition.watched) {
339                for value in &condition.values {
340                    if field.can_post(value) == Some(false) {
341                        errors.push(DeclarationErrorKind::UnpostedConditionValue {
342                            field: watched.clone(),
343                            value: value.clone(),
344                        });
345                    }
346                }
347            }
348        }
349        for (index, chain) in &scan.fields {
350            let field = &self.fields[*index];
351            if !chain.is_empty() && field.is_required() {
352                errors.push(DeclarationErrorKind::RequiredConditionalField {
353                    field: field.name().to_string(),
354                });
355            }
356        }
357        errors
358    }
359}
360
361#[cfg(test)]
362mod tests;