Skip to main content

ravenrs_report_viewer/
parameters.rs

1//! Asks for a report's parameters. Values are applied together, so a report is
2//! not rendered again at each keystroke.
3
4use std::collections::HashMap;
5
6use ravenrs_app_i18n::{Language, Texts};
7use ravenrs_core::Insets;
8use ravenrs_layout::Align;
9use ravenrs_reactive::{Binding, State};
10use ravenrs_report::{Parameter, ParameterKind};
11use ravenrs_style::{SPACING, Semantic};
12use ravenrs_widgets::{
13    AnyView, BuildCx, Button, ButtonSize, ButtonStyle, Checkbox, Date, DatePicker, FormField, HStack, NodeId,
14    TextField, View, ViewExt,
15};
16use serde_json::{Value as Json, json};
17
18/// Parameter values by name, as [`ravenrs_report::RenderOptions`] takes them.
19pub type ParameterValues = HashMap<String, Json>;
20
21/// The text a parameter panel shows.
22#[derive(Clone, Debug)]
23#[non_exhaustive]
24pub struct ParameterLabels {
25    /// Applies the values.
26    pub apply: String,
27    /// Goes back to the defaults.
28    pub reset: String,
29}
30
31impl Default for ParameterLabels {
32    fn default() -> Self {
33        Self::from_texts(&Texts::new(Language::English))
34    }
35}
36
37impl ParameterLabels {
38    /// The texts of a catalog, in its language. What it does not have comes from English.
39    pub fn from_texts(texts: &Texts) -> Self {
40        Self { apply: texts.get("viewer.parameters.apply"), reset: texts.get("viewer.parameters.reset") }
41    }
42
43    /// The labels in Brazilian Portuguese.
44    pub fn pt_br() -> Self {
45        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
46    }
47}
48
49/// Asks for values of parameters, with a control for each one's kind.
50#[derive(Debug)]
51pub struct ParameterPanel {
52    parameters: Vec<Parameter>,
53    applied: State<ParameterValues>,
54    labels: ParameterLabels,
55    today: Date,
56}
57
58impl ParameterPanel {
59    /// A panel that writes to `applied` when the user applies the values.
60    pub fn new(parameters: Vec<Parameter>, applied: State<ParameterValues>, today: Date) -> Self {
61        Self { parameters, applied, labels: ParameterLabels::default(), today }
62    }
63
64    /// Sets the text the panel shows.
65    pub fn labels(mut self, labels: ParameterLabels) -> Self {
66        self.labels = labels;
67        self
68    }
69}
70
71/// A date as parameters take it.
72fn date_text(date: Date) -> String {
73    format!("{:04}-{:02}-{:02}", date.year(), date.month(), date.day())
74}
75
76/// The date in a value such as `2026-11-01` or `2026-11-01T10:00:00`.
77fn parse_date(value: &Json) -> Option<Date> {
78    let text = value.as_str()?;
79    let year = text.get(0..4)?.parse().ok()?;
80    let month = text.get(5..7)?.parse().ok()?;
81    let day = text.get(8..10)?.parse().ok()?;
82    Date::new(year, month, day)
83}
84
85/// What a person typed as a number, with either decimal separator; `None` while it is not one.
86pub(crate) fn parse_number(text: &str) -> Option<f64> {
87    let text = text.trim();
88    // "1.234,50" is thousands and decimals; "1,5" and "1.5" are decimals.
89    let plain = if text.contains(',') { text.replace('.', "").replace(',', ".") } else { text.to_owned() };
90    plain.parse().ok().filter(|n: &f64| n.is_finite())
91}
92
93/// A value as a text field shows it.
94fn value_text(value: Option<&Json>) -> String {
95    match value {
96        Some(Json::String(text)) => text.clone(),
97        Some(Json::Null) | None => String::new(),
98        Some(other) => other.to_string(),
99    }
100}
101
102impl View for ParameterPanel {
103    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
104        // What is being edited; it becomes the applied values on Apply.
105        let draft: State<ParameterValues> = cx.use_state(|| self.applied.get());
106        let mut items: Vec<AnyView> = Vec::with_capacity(self.parameters.len() + 2);
107        for parameter in &self.parameters {
108            let name = parameter.name.clone();
109            let caption = parameter.label.clone().unwrap_or_else(|| name.clone());
110            let default = parameter.default.clone();
111            let current = {
112                let (draft, name, default) = (draft.clone(), name.clone(), default.clone());
113                move || draft.with(|values| values.get(&name).cloned()).or_else(|| default.clone())
114            };
115            let store = {
116                let (draft, name) = (draft.clone(), name.clone());
117                move |value: Json| {
118                    draft.update(|values| {
119                        values.insert(name.clone(), value);
120                    });
121                }
122            };
123            let control: AnyView = match parameter.kind {
124                ParameterKind::Boolean => {
125                    let read = current.clone();
126                    Box::new(Checkbox::new(
127                        caption.clone(),
128                        Binding::new(
129                            move || read().is_some_and(|v| v.as_bool().unwrap_or(false)),
130                            move |checked: bool| store(json!(checked)),
131                        ),
132                    ))
133                }
134                ParameterKind::Date => {
135                    let read = current.clone();
136                    let month = current().as_ref().and_then(parse_date).unwrap_or(self.today);
137                    Box::new(
138                        DatePicker::new(
139                            Binding::new(
140                                move || read().as_ref().and_then(parse_date),
141                                move |date: Option<Date>| {
142                                    store(date.map_or(Json::Null, |d| json!(date_text(d))));
143                                },
144                            ),
145                            month,
146                        )
147                        .today(self.today)
148                        .width(150.0)
149                        .label(caption.clone()),
150                    )
151                }
152                _ => {
153                    // The text as typed is kept apart, so "1," can become "1,5".
154                    let typed: State<Option<String>> = cx.use_state(|| None);
155                    let is_number = parameter.kind == ParameterKind::Number;
156                    let (read, read_typed, write_typed) = (current.clone(), typed.clone(), typed);
157                    Box::new(
158                        TextField::new(Binding::new(
159                            move || read_typed.get().unwrap_or_else(|| value_text(read().as_ref())),
160                            move |text: String| {
161                                let value = if is_number {
162                                    parse_number(&text).map_or(Json::Null, |n| json!(n))
163                                } else {
164                                    json!(text)
165                                };
166                                store(value);
167                                write_typed.set(Some(text));
168                            },
169                        ))
170                        .compact()
171                        .width(if is_number { 110.0 } else { 180.0 }),
172                    )
173                }
174            };
175            items.push(if parameter.kind == ParameterKind::Boolean {
176                control
177            } else {
178                Box::new(FormField::new(caption, ControlBox(control)))
179            });
180        }
181        let (write, source) = (self.applied.clone(), draft.clone());
182        items.push(Box::new(
183            Button::new(self.labels.apply.clone())
184                .size(ButtonSize::Small)
185                .on_click(move || write.set(source.get())),
186        ));
187        let (write, source) = (self.applied.clone(), draft);
188        items.push(Box::new(
189            Button::new(self.labels.reset.clone())
190                .style(ButtonStyle::Ghost)
191                .size(ButtonSize::Small)
192                .on_click(move || {
193                    source.set(HashMap::new());
194                    write.set(HashMap::new());
195                }),
196        ));
197        cx.child(
198            &HStack::new(items)
199                .spacing(SPACING.s3)
200                .align(Align::End)
201                .wrap()
202                .padding(Insets::symmetric(SPACING.s2, SPACING.s4))
203                .background(Semantic::Surface),
204        )
205    }
206}
207
208/// Lets a boxed control sit in a form field.
209struct ControlBox(AnyView);
210
211impl View for ControlBox {
212    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
213        cx.child(&self.0)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn numbers_read_with_either_separator() {
223        assert_eq!(parse_number("1,5"), Some(1.5));
224        assert_eq!(parse_number("1.5"), Some(1.5));
225        assert_eq!(parse_number("1.234,50"), Some(1234.5));
226        assert_eq!(parse_number(" 10 "), Some(10.0));
227        assert_eq!(parse_number("abc"), None);
228        assert_eq!(parse_number(""), None);
229        assert_eq!(parse_number("inf"), None);
230    }
231
232    #[test]
233    fn dates_read_with_or_without_a_time() {
234        let date = parse_date(&json!("2026-11-01T10:00:00")).map(date_text);
235        assert_eq!(date.as_deref(), Some("2026-11-01"));
236        assert_eq!(parse_date(&json!("2026-02-30")), None);
237        assert_eq!(parse_date(&json!("ontem")), None);
238        assert_eq!(parse_date(&json!(5)), None);
239    }
240}