1use 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#[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 Password,
35 Textarea,
36 Checkbox,
37 Select,
38 Money,
39 Percentage,
41 Parts(&'static [Part]),
43 File,
45}
46
47#[must_use]
49pub fn percent_text(basis_points: i64) -> String {
50 crate::decimal::scaled_text(basis_points, 2)
51}
52
53#[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 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
187impl 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
203pub 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);
260present_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}