Skip to main content

sofie_values/
present.rs

1//! A field's type alone decides its control, text and CSV cell, so no screen switches on types.
2
3use sofie_core::{Constraint, Instant};
4
5use crate::{Currency, Email, Money, Secret};
6
7pub trait Present {
8    const INPUT: Input;
9    const SEARCH: Search;
10
11    fn csv(&self) -> String;
12
13    #[cfg(feature = "web")]
14    fn write_localized(&self, out: &mut String, _conventions: &dyn Conventions) {
15        out.push_str(&self.csv());
16    }
17}
18
19/// How full-text search treats a value: words of `Text` are matched, an `Exact`
20/// value only whole, `Number` and `Time` by range, and a `Skipped` one never indexed.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum Search {
23    Text,
24    Exact,
25    Number,
26    Time,
27    Skipped,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Control {
32    Input,
33    /// Its value is never sent back to the page.
34    Password,
35    Textarea,
36    Checkbox,
37    Select,
38    Money,
39    /// Basis points shown and typed as a percent with up to two decimals.
40    Percentage,
41    /// One control per part, posted as `<field>.<part>`, for a value read as an object.
42    Parts(&'static [Part]),
43    /// Its form is sent as `multipart/form-data`; nothing is ever shown in it.
44    File,
45}
46
47/// Basis points as the percent a `Percentage` control holds, `12.50` for 1250.
48#[must_use]
49pub fn percent_text(basis_points: i64) -> String {
50    crate::decimal::scaled_text(basis_points, 2)
51}
52
53/// A part with options is a select.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub struct Part {
56    pub name: &'static str,
57    pub attributes: &'static [(&'static str, &'static str)],
58    pub options: &'static [&'static str],
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct Input {
63    pub control: Control,
64    pub attributes: &'static [(&'static str, &'static str)],
65    pub options: &'static [&'static str],
66}
67
68impl Input {
69    #[must_use]
70    pub const fn new(control: Control) -> Self {
71        Self {
72            control,
73            attributes: &[],
74            options: &[],
75        }
76    }
77
78    #[must_use]
79    pub const fn typed(attributes: &'static [(&'static str, &'static str)]) -> Self {
80        Self {
81            attributes,
82            ..Self::new(Control::Input)
83        }
84    }
85
86    #[must_use]
87    pub const fn select(options: &'static [&'static str]) -> Self {
88        Self {
89            options,
90            ..Self::new(Control::Select)
91        }
92    }
93}
94
95#[cfg(feature = "web")]
96pub trait Conventions {
97    fn write_number(&self, out: &mut String, plain: &str);
98    fn write_money(&self, out: &mut String, amount: Money);
99    fn write_date(&self, out: &mut String, iso: &str);
100    /// The variant's label in the `enumeration` the translations register.
101    fn write_variant(&self, out: &mut String, enumeration: &str, variant: &str);
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub struct FormField {
106    pub name: &'static str,
107    pub input: Input,
108    pub required: bool,
109    pub constraints: &'static [Constraint],
110}
111
112pub trait FormFields {
113    const FIELDS: &'static [FormField];
114}
115
116pub(crate) const TEXT: Input = Input::typed(&[("type", "text")]);
117pub(crate) const INTEGER: Input = Input::typed(&[("type", "number"), ("step", "1")]);
118
119impl Present for bool {
120    const INPUT: Input = Input::new(Control::Checkbox);
121    const SEARCH: Search = Search::Exact;
122
123    fn csv(&self) -> String {
124        self.to_string()
125    }
126}
127
128macro_rules! present_integer {
129    ($($integer:ty),+) => {$(
130        impl Present for $integer {
131            const INPUT: Input = INTEGER;
132            const SEARCH: Search = Search::Number;
133
134            fn csv(&self) -> String {
135                self.to_string()
136            }
137
138            #[cfg(feature = "web")]
139            fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
140                conventions.write_number(out, &self.to_string());
141            }
142        }
143    )+};
144}
145
146present_integer!(i16, i32, i64, u16, u32);
147
148impl<T: Present> Present for Option<T> {
149    const INPUT: Input = T::INPUT;
150    const SEARCH: Search = T::SEARCH;
151
152    fn csv(&self) -> String {
153        self.as_ref().map(Present::csv).unwrap_or_default()
154    }
155
156    #[cfg(feature = "web")]
157    fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
158        if let Some(value) = self {
159            value.write_localized(out, conventions);
160        }
161    }
162}
163
164impl Present for Money {
165    const INPUT: Input = Input::new(Control::Money);
166    const SEARCH: Search = Search::Number;
167
168    fn csv(&self) -> String {
169        format!("{} {}", self.format_major(), self.currency())
170    }
171
172    #[cfg(feature = "web")]
173    fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
174        conventions.write_money(out, *self);
175    }
176}
177
178impl<T> Present for Secret<T> {
179    const INPUT: Input = Input::new(Control::Password);
180    const SEARCH: Search = Search::Skipped;
181
182    fn csv(&self) -> String {
183        "***".to_owned()
184    }
185}
186
187/// The control shows and reads the app's display zone (`SOFIE_TIME_ZONE`); a time a user types
188/// on their own wall clock belongs in `LocalDateTime`.
189impl Present for Instant {
190    const INPUT: Input = Input::typed(&[("type", "datetime-local")]);
191    const SEARCH: Search = Search::Time;
192
193    fn csv(&self) -> String {
194        self.to_string()
195    }
196
197    #[cfg(feature = "web")]
198    fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
199        conventions.write_date(out, &self.to_string());
200    }
201}
202
203/// A marker the derive implements; a value overriding a surface implements that trait instead.
204pub trait SurfacesFromInner: sofie_core::Value<Inner: Present> {
205    const SENSITIVE: bool = false;
206}
207
208impl<V: SurfacesFromInner> Present for V {
209    const INPUT: Input = if <V as crate::SurfacesFromInner>::SENSITIVE {
210        Input::new(Control::Password)
211    } else {
212        V::Inner::INPUT
213    };
214    const SEARCH: Search = if <V as crate::SurfacesFromInner>::SENSITIVE {
215        Search::Skipped
216    } else {
217        V::Inner::SEARCH
218    };
219
220    fn csv(&self) -> String {
221        self.inner().csv()
222    }
223
224    #[cfg(feature = "web")]
225    fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
226        self.inner().write_localized(out, conventions);
227    }
228}
229
230macro_rules! present_text {
231    ($value:ty, $input:expr, $search:ident) => {
232        $crate::present::present_text!($value, $input, $search, |value| value.as_str().to_owned());
233    };
234    ($value:ty, $input:expr, $search:ident, |$self_:ident| $text:expr) => {
235        impl $crate::Present for $value {
236            const INPUT: $crate::Input = $input;
237            const SEARCH: $crate::Search = $crate::Search::$search;
238
239            fn csv(&self) -> String {
240                let $self_ = self;
241                $text
242            }
243        }
244    };
245}
246
247pub(crate) use present_text;
248
249present_text!(
250    Currency,
251    Input::typed(&[("type", "text"), ("maxlength", "3")]),
252    Exact
253);
254present_text!(
255    Email,
256    Input::typed(&[("type", "email"), ("autocomplete", "email")]),
257    Exact
258);
259present_text!(String, TEXT, Text);
260// Hidden: an id is carried by a form, never typed.
261present_text!(
262    sofie_core::Uuid,
263    Input::typed(&[("type", "hidden")]),
264    Exact,
265    |id| id.to_string()
266);
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::testing::euros;
272
273    #[cfg(feature = "web")]
274    struct Marked;
275
276    #[cfg(feature = "web")]
277    impl Conventions for Marked {
278        fn write_number(&self, out: &mut String, plain: &str) {
279            out.push_str("number:");
280            out.push_str(plain);
281        }
282
283        fn write_money(&self, out: &mut String, amount: Money) {
284            out.push_str("money:");
285            out.push_str(&amount.csv());
286        }
287
288        fn write_date(&self, out: &mut String, iso: &str) {
289            out.push_str("date:");
290            out.push_str(iso);
291        }
292
293        fn write_variant(&self, out: &mut String, enumeration: &str, variant: &str) {
294            out.push_str(enumeration);
295            out.push(':');
296            out.push_str(variant);
297        }
298    }
299
300    #[cfg(feature = "web")]
301    fn localized(value: &impl Present) -> String {
302        let mut out = String::new();
303        value.write_localized(&mut out, &Marked);
304        out
305    }
306
307    #[test]
308    fn money_is_typed_shown_and_exported_as_an_amount() {
309        assert_eq!(Money::INPUT.control, Control::Money);
310        assert_eq!(euros(14990).csv(), "149.90 EUR");
311        #[cfg(feature = "web")]
312        assert_eq!(localized(&euros(14990)), "money:149.90 EUR");
313    }
314
315    #[test]
316    fn an_option_is_presented_like_its_value() {
317        assert_eq!(<Option<Money>>::INPUT, Money::INPUT);
318        assert_eq!(None::<i64>.csv(), "");
319        #[cfg(feature = "web")]
320        assert_eq!(localized(&Some(7_i64)), "number:7");
321    }
322
323    #[test]
324    fn a_secret_never_shows_its_value() {
325        let secret = Secret::new("hunter22".to_owned());
326        assert_eq!(<Secret<String>>::INPUT.control, Control::Password);
327        assert_eq!(secret.csv(), "***");
328        #[cfg(feature = "web")]
329        assert_eq!(localized(&secret), "***");
330    }
331
332    #[test]
333    fn text_values_are_typed_by_their_attributes() {
334        assert_eq!(
335            Email::INPUT.attributes,
336            &[("type", "email"), ("autocomplete", "email")]
337        );
338        assert_eq!(bool::INPUT.control, Control::Checkbox);
339        let email = Email::parse("Jane@Example.com").unwrap();
340        assert_eq!(email.csv(), "jane@example.com");
341    }
342
343    #[derive(Debug)]
344    struct Quantity(i64);
345
346    impl sofie_core::Value for Quantity {
347        type Inner = i64;
348
349        fn inner(&self) -> &i64 {
350            &self.0
351        }
352
353        fn try_from_inner(inner: i64) -> Result<Self, impl std::fmt::Display> {
354            Ok::<_, std::convert::Infallible>(Self(inner))
355        }
356    }
357
358    impl SurfacesFromInner for Quantity {}
359
360    #[test]
361    fn search_reads_words_of_text_and_never_a_secret() {
362        assert_eq!(String::SEARCH, Search::Text);
363        assert_eq!(Email::SEARCH, Search::Exact);
364        assert_eq!(<Option<Money>>::SEARCH, Search::Number);
365        assert_eq!(Instant::SEARCH, Search::Time);
366        assert_eq!(<Secret<String>>::SEARCH, Search::Skipped);
367        assert_eq!(Quantity::SEARCH, Search::Number);
368    }
369
370    #[test]
371    fn a_derived_value_is_presented_as_the_value_it_wraps() {
372        assert_eq!(Quantity::INPUT, i64::INPUT);
373        #[cfg(feature = "web")]
374        assert_eq!(localized(&Quantity(3)), "number:3");
375    }
376
377    #[cfg(all(feature = "web", feature = "time"))]
378    #[test]
379    fn a_time_formats_as_a_date_and_a_weekday_as_its_enum_s_label() {
380        assert_eq!(
381            localized(&crate::TimeOfDay::constant("09:30")),
382            "date:09:30"
383        );
384        assert_eq!(localized(&crate::Weekday::Monday), "weekday:MONDAY");
385    }
386
387    #[cfg(feature = "web")]
388    #[test]
389    fn instants_format_as_dates() {
390        let instant = Instant::parse("2026-10-11T09:00:00+03:00").unwrap();
391        assert_eq!(localized(&instant), "date:2026-10-11T06:00:00Z");
392    }
393}