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, form::FormValues, 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    /// Whether a submission that hides what this guards still parses, as an embedded shared
71    /// column's does: the codec reads that column only for the variants that declare it.
72    parses_when_hidden: bool,
73}
74
75impl Condition {
76    pub(crate) fn new<F>(
77        watched: &impl Watched<F>,
78        values: impl IntoIterator<Item = impl Into<String>>,
79    ) -> Self {
80        Self {
81            watched: watched.key().to_string(),
82            checkbox: watched.checkbox(),
83            values: values.into_iter().map(Into::into).collect(),
84            parses_when_hidden: false,
85        }
86    }
87
88    /// A condition watching the field named `key`, which posts a value rather than a checkbox's
89    /// checked state.
90    pub(crate) fn watching(
91        key: impl Into<String>,
92        values: impl IntoIterator<Item = impl Into<String>>,
93    ) -> Self {
94        Self {
95            watched: key.into(),
96            checkbox: false,
97            values: values.into_iter().map(Into::into).collect(),
98            parses_when_hidden: true,
99        }
100    }
101
102    /// Whether the submission `values` shows what the condition guards. A checkbox with no value
103    /// reads as `false`, as it renders and posts.
104    fn holds(&self, values: &FormValues) -> bool {
105        let posted = match values.text(&self.watched) {
106            Some(value) => value.trim(),
107            None if self.checkbox => "false",
108            None => "",
109        };
110        self.values.iter().any(|value| value == posted)
111    }
112
113    /// Renders `inner` in a fieldset the browser hides and disables while the condition fails, so
114    /// a hidden required field never blocks the submit.
115    pub(crate) fn guard<'a>(
116        &self,
117        cx: &'a Cx,
118        scope: &str,
119        values: &FormValues,
120        inner: BoxView<'a>,
121    ) -> BoxView<'a> {
122        let condition = self.clone();
123        let initial = self.holds(values);
124        let scope = scope.to_string();
125        async_page(async move {
126            let shown = shown(cx, &scope, &condition, initial);
127            let enabled = shown.clone();
128            Ok(view! {
129                cx =>
130                <fieldset
131                    class="contents"
132                    :disabled=$(!enabled.get())
133                    :hidden=$(!shown.get())
134                >
135                    (inner)
136                </fieldset>
137            })
138        })
139    }
140}
141
142/// The signal holding whether `condition` holds in the form `scope`, shared by its guard and its
143/// watched field.
144///
145/// Keyed by the page's path: navigation carries the values of signals two pages share, and
146/// another record's form starts from its own stored values. Keyed by the form's scope too: an
147/// action's input in a dialog may watch a key the page's own form watches.
148fn shown(cx: &Cx, scope: &str, condition: &Condition, initial: bool) -> Signal<bool> {
149    let page = topcoat::context::try_request_context::<http::request::Parts>(cx)
150        .map(|parts| parts.uri.path().to_string())
151        .unwrap_or_default();
152    let values = condition.values.join("\u{1f}");
153    signal(
154        &cx.keyed((
155            "tablo-condition",
156            page,
157            scope,
158            condition.watched.as_str(),
159            values,
160        )),
161        move || initial,
162    )
163}
164
165/// Renders the watched field `field` inside one change handler per condition reading it.
166///
167/// A handler reads only the event of the control posting the watched key: a searchable choice's
168/// filter box or an app control's own inputs change inside the same wrapper. A runtime expression
169/// has no `||`, so a condition naming several values stacks one handler per value: the innermost
170/// sets the signal from the first value, and each outer one, run after it as the event bubbles,
171/// sets it when its own value matches.
172pub(crate) fn watch<'a>(
173    cx: &'a Cx,
174    field: &Field,
175    conditions: &[Condition],
176    scope: &str,
177    values: &FormValues,
178    control: BoxView<'a>,
179) -> BoxView<'a> {
180    let scope = scope.to_string();
181    let conditions: Vec<(Condition, bool)> = conditions
182        .iter()
183        .map(|condition| (condition.clone(), condition.holds(values)))
184        .collect();
185    let checkbox = field.is_checkbox();
186    let key = field.name().to_string();
187    async_page(async move {
188        let mut view = control;
189        for (condition, initial) in conditions {
190            let shown = shown(cx, &scope, &condition, initial);
191            if checkbox {
192                // A checkbox posts `true` when checked and its hidden `false` otherwise.
193                let on = condition.values.iter().any(|value| value == "true");
194                let off = condition.values.iter().any(|value| value == "false");
195                let name = key.clone();
196                view = view! {
197                    cx =>
198                    <div
199                        class="contents"
200                        @change=$(|e: Event| if e.target.name == name {
201                            shown.set(if e.target.checked { on } else { off })
202                        })
203                    >
204                        (view)
205                    </div>
206                }
207                .boxed();
208                continue;
209            }
210            let mut values = condition.values.into_iter();
211            let Some(first) = values.next() else {
212                continue;
213            };
214            let (set, name) = (shown.clone(), key.clone());
215            view = view! {
216                cx =>
217                <div
218                    class="contents"
219                    @change=$(|e: Event| if e.target.name == name {
220                        set.set(e.target.value.trim() == first)
221                    })
222                >
223                    (view)
224                </div>
225            }
226            .boxed();
227            for value in values {
228                let (set, name) = (shown.clone(), key.clone());
229                view = view! {
230                    cx =>
231                    <div
232                        class="contents"
233                        @change=$(|e: Event| if e.target.name == name {
234                            if e.target.value.trim() == value {
235                                set.set(true)
236                            }
237                        })
238                    >
239                        (view)
240                    </div>
241                }
242                .boxed();
243            }
244        }
245        Ok(view)
246    })
247}
248
249impl Node {
250    /// The condition on this node: a field's own, or a layout block's.
251    fn condition<'s>(&'s self, fields: &'s [Field]) -> Option<&'s Condition> {
252        match self {
253            Node::Field(index) => fields[*index].condition(),
254            Node::Section(s) => s.condition.as_ref(),
255            Node::Group(g) => g.condition.as_ref(),
256            Node::Grid(g) => g.condition.as_ref(),
257            Node::Embedded(_) | Node::Unbound(_) => None,
258        }
259    }
260}
261
262/// Where a schema's conditions sit.
263#[derive(Default)]
264struct Scan<'s> {
265    /// Each field slot with the conditions guarding it, outermost first and its own last.
266    fields: Vec<(usize, Vec<&'s Condition>)>,
267    /// Each condition with the conditions guarding the node it sits on.
268    conditions: Vec<(&'s Condition, Vec<&'s Condition>)>,
269}
270
271impl<'s> Scan<'s> {
272    fn of(nodes: &'s [Node], fields: &'s [Field]) -> Self {
273        let mut scan = Self::default();
274        scan.walk(nodes, fields, &mut Vec::new());
275        scan
276    }
277
278    fn walk(&mut self, nodes: &'s [Node], fields: &'s [Field], chain: &mut Vec<&'s Condition>) {
279        for node in nodes {
280            let own = node.condition(fields);
281            if let Some(condition) = own {
282                self.conditions.push((condition, chain.clone()));
283                chain.push(condition);
284            }
285            match node {
286                Node::Field(index) => self.fields.push((*index, chain.clone())),
287                Node::Embedded(embedded) => {
288                    // An embedded value's leaves are placed by its node, and each carries its own
289                    // condition as a top-level field does.
290                    embedded.visit_fields(&mut |index| {
291                        let own = fields[index].condition();
292                        if let Some(condition) = own {
293                            self.conditions.push((condition, chain.clone()));
294                            chain.push(condition);
295                        }
296                        self.fields.push((index, chain.clone()));
297                        if own.is_some() {
298                            chain.pop();
299                        }
300                    });
301                }
302                node => self.walk(node.children().unwrap_or_default(), fields, chain),
303            }
304            if own.is_some() {
305                chain.pop();
306            }
307        }
308    }
309
310    /// The conditions guarding the field named `key`, or `None` when the schema places none.
311    fn guarding(&self, fields: &[Field], key: &str) -> Option<&[&'s Condition]> {
312        self.fields
313            .iter()
314            .find(|(index, _)| fields[*index].name() == key)
315            .map(|(_, chain)| chain.as_slice())
316    }
317}
318
319impl<F> Schema<F> {
320    /// The keys of the fields a condition hides in the submission `values`.
321    pub(crate) fn condition_hidden(&self, values: &FormValues) -> Vec<String> {
322        Scan::of(&self.nodes, &self.fields)
323            .fields
324            .into_iter()
325            .filter(|(_, chain)| chain.iter().any(|condition| !condition.holds(values)))
326            .map(|(index, _)| self.fields[index].name().to_string())
327            .collect()
328    }
329
330    /// The conditions each watched field's key drives, each once.
331    pub(crate) fn watched(&self) -> HashMap<String, Vec<Condition>> {
332        let mut out: HashMap<String, Vec<Condition>> = HashMap::new();
333        for (condition, _) in Scan::of(&self.nodes, &self.fields).conditions {
334            let driven = out.entry(condition.watched.clone()).or_default();
335            if !driven.contains(condition) {
336                driven.push(condition.clone());
337            }
338        }
339        out
340    }
341
342    /// What is wrong with the schema's conditions: a watched field the schema does not place, one
343    /// a condition hides while what it guards still shows, a value the watched field never posts,
344    /// and a hidden field with no blank answer, which a submission that hides it could not parse.
345    pub(crate) fn condition_errors(&self) -> Vec<DeclarationErrorKind> {
346        let scan = Scan::of(&self.nodes, &self.fields);
347        let mut errors = Vec::new();
348        for (condition, guarding) in &scan.conditions {
349            let watched = condition.watched.clone();
350            match scan.guarding(&self.fields, &condition.watched) {
351                None => errors.push(DeclarationErrorKind::UnplacedWatchedField {
352                    field: watched.clone(),
353                }),
354                // The browser follows the watched field's last change, the server its posted
355                // value: they agree only while every condition hiding the watched field also
356                // hides what it guards.
357                Some(chain)
358                    if !chain
359                        .iter()
360                        .all(|hiding| guarding.iter().any(|c| std::ptr::eq(*c, *hiding))) =>
361                {
362                    errors.push(DeclarationErrorKind::HiddenWatchedField {
363                        field: watched.clone(),
364                    });
365                }
366                Some(_) => {}
367            }
368            if let Some(field) = self.fields.iter().find(|f| f.name() == condition.watched) {
369                for value in &condition.values {
370                    if field.can_post(value) == Some(false) {
371                        errors.push(DeclarationErrorKind::UnpostedConditionValue {
372                            field: watched.clone(),
373                            value: value.clone(),
374                        });
375                    }
376                }
377            }
378        }
379        for (index, chain) in &scan.fields {
380            let field = &self.fields[*index];
381            // A condition hiding a field the codec reads nothing of there needs no blank answer.
382            let hides_unparsed = chain.iter().any(|condition| !condition.parses_when_hidden);
383            if hides_unparsed && field.is_required() {
384                errors.push(DeclarationErrorKind::RequiredConditionalField {
385                    field: field.name().to_string(),
386                });
387            }
388        }
389        errors
390    }
391}
392
393#[cfg(test)]
394mod tests;