Skip to main content

sofie_engine/catalog/
field.rs

1use std::borrow::Cow;
2
3use sofie_core::{DisplayZone, Instant, Show, Shown};
4use sofie_values::{Constraint, Control, Currency, FormField, Input, Part};
5
6use super::form::Submitted;
7use super::money::money_of;
8use super::props::{Props, check_path, check_single, is_element_id, is_field_name};
9use crate::escape::push_escaped;
10use crate::label::humanize;
11use crate::language::label;
12use crate::render::{
13    Renderer, Resolved, field_of, items_of, push_display, str_of, text_of, write_escaped,
14    write_plain,
15};
16use crate::syntax::{Attribute, Expression, LocatedError, Position};
17
18const TEXT: Input = Input::typed(&[("type", "text")]);
19
20#[derive(Debug)]
21pub(crate) struct Field {
22    pub name: String,
23    pub label: Expression,
24    described: Option<&'static FormField>,
25    pub value: Option<Attribute>,
26    pub options: Option<Attribute>,
27    pub currency: Option<Attribute>,
28    is_required: bool,
29    pub position: Position,
30}
31
32impl Field {
33    pub(crate) fn is_file(&self) -> bool {
34        self.input().control == Control::File
35    }
36
37    fn input(&self) -> Input {
38        self.described.map_or(TEXT, |described| described.input)
39    }
40
41    fn is_required(&self) -> bool {
42        self.described.map_or(self.is_required, |described| {
43            described.required || described.constraints.contains(&Constraint::Required)
44        })
45    }
46
47    /// Only the template's own `required`: the input type's is given at runtime.
48    #[cfg(feature = "client-compile")]
49    pub(crate) fn is_written_required(&self) -> bool {
50        self.is_required
51    }
52
53    /// What a client area renders the field from but its label, which is in the request's.
54    #[cfg(feature = "clients")]
55    pub(crate) fn client_description(&self) -> serde_json::Value {
56        let input = self.input();
57        let pairs = |attributes: &[(&str, &str)]| -> Vec<[String; 2]> {
58            attributes
59                .iter()
60                .map(|(name, value)| [(*name).to_owned(), (*value).to_owned()])
61                .collect()
62        };
63        let parts: Vec<serde_json::Value> = match input.control {
64            Control::Parts(parts) => parts
65                .iter()
66                .map(|part| {
67                    serde_json::json!({
68                        "name": part.name,
69                        "label": humanize(part.name),
70                        "attributes": pairs(part.attributes),
71                        "options": part.options,
72                    })
73                })
74                .collect(),
75            _ => Vec::new(),
76        };
77        let constraints: Vec<(&str, i64)> = self
78            .constraints()
79            .iter()
80            .filter_map(|constraint| constraint_attribute(*constraint))
81            .collect();
82        serde_json::json!({
83            "name": self.name,
84            "control": control_name(input.control),
85            "attributes": pairs(input.attributes),
86            "options": input.options,
87            "parts": parts,
88            "required": self.is_required(),
89            "constraints": constraints,
90        })
91    }
92
93    fn constraints(&self) -> &'static [Constraint] {
94        self.described.map_or(&[], |field| field.constraints)
95    }
96
97    fn value(&self) -> Option<&Expression> {
98        self.value.as_ref().map(|value| &value.value)
99    }
100
101    pub(super) fn describe(&mut self, field: &'static FormField) -> Result<(), LocatedError> {
102        check_props(self, field.input)?;
103        self.described = Some(field);
104        Ok(())
105    }
106
107    pub(super) fn of(field: &'static FormField, position: Position) -> Self {
108        Self {
109            name: field.name.to_owned(),
110            label: label(field.name, position),
111            described: Some(field),
112            value: None,
113            options: None,
114            currency: None,
115            is_required: false,
116            position,
117        }
118    }
119}
120
121#[cfg(feature = "clients")]
122fn control_name(control: Control) -> &'static str {
123    match control {
124        Control::Input => "input",
125        Control::Password => "password",
126        Control::Textarea => "textarea",
127        Control::Checkbox => "checkbox",
128        Control::Select => "select",
129        Control::Money => "money",
130        Control::Percentage => "percentage",
131        Control::Parts(_) => "parts",
132        Control::File => "file",
133    }
134}
135
136/// A label bound to data is the template's to write; the client compiles it.
137#[cfg(feature = "clients")]
138pub(crate) fn constant_label(
139    label: &Expression,
140    language: crate::language::Language<'_>,
141) -> Option<String> {
142    match label {
143        Expression::Authored(text) => Some(text.clone()),
144        #[cfg(feature = "i18n")]
145        Expression::Call(call) => match call.as_ref() {
146            crate::translations::Call::Label { key, humanized, .. } => {
147                Some(language.text(key, humanized).to_owned())
148            }
149            _ => None,
150        },
151        _ => {
152            let _ = language;
153            None
154        }
155    }
156}
157
158pub(super) fn build_field(props: &mut Props, position: Position) -> Result<Field, LocatedError> {
159    let (name, name_position) = props.required_literal("name")?;
160    if !is_field_name(&name) {
161        bail!(
162            name_position,
163            "`Field` name `{name}` is not a field name (letters, digits and `_`)"
164        );
165    }
166    Ok(Field {
167        label: props
168            .optional("label")
169            .unwrap_or_else(|| label(&name, position)),
170        described: None,
171        value: props.take("value"),
172        options: props.take("options"),
173        currency: props.take("currency"),
174        is_required: props.flag("required")?,
175        position,
176        name,
177    })
178}
179
180fn check_props(field: &Field, input: Input) -> Result<(), LocatedError> {
181    let reads_value = matches!(
182        input.control,
183        Control::Money | Control::Select | Control::Checkbox | Control::Parts(_)
184    );
185    for attribute in [&field.value, &field.options, &field.currency]
186        .into_iter()
187        .flatten()
188    {
189        if reads_value || attribute.name != "value" {
190            check_single("Field", attribute)?;
191        }
192    }
193    let unused = match input.control {
194        Control::Password if field.value.is_some() => {
195            bail!(
196                field.position,
197                "a `password` field takes no `value`: a password is never sent back to the page"
198            );
199        }
200        Control::File if field.value.is_some() => {
201            bail!(field.position, "a file field takes no `value`");
202        }
203        Control::Select if field.options.is_none() && input.options.is_empty() => {
204            bail!(field.position, "`Field` needs the `options` prop");
205        }
206        Control::Money => {
207            if let Some(value) = &field.value {
208                check_path("Field", value)?;
209            }
210            check_currency(field)?;
211            &field.options
212        }
213        Control::Parts(_) => {
214            if let Some(value) = &field.value {
215                check_path("Field", value)?;
216            }
217            if field.options.is_some() {
218                &field.options
219            } else {
220                &field.currency
221            }
222        }
223        Control::Select => &field.currency,
224        _ if field.currency.is_some() => &field.currency,
225        _ => &field.options,
226    };
227    match unused {
228        Some(attribute) => Err(LocatedError::new(
229            attribute.position,
230            format!("`Field` has no prop `{}`", attribute.name),
231        )),
232        None => Ok(()),
233    }
234}
235
236/// A bound currency, or a money `value`'s, is checked when rendering; a null one is typed.
237fn check_currency(field: &Field) -> Result<(), LocatedError> {
238    match &field.currency {
239        Some(Attribute {
240            value: Expression::Authored(code),
241            position,
242            ..
243        }) if Currency::parse(code).is_err() => Err(LocatedError::new(
244            *position,
245            format!("currency `{code}` is not an ISO 4217 code such as EUR"),
246        )),
247        _ => Ok(()),
248    }
249}
250
251#[derive(Clone, Copy)]
252enum Source<'a> {
253    Posted(Resolved<'a>),
254    Bound(Option<&'a Expression>),
255}
256
257impl<'a> Source<'a> {
258    fn is_present(self) -> bool {
259        matches!(self, Self::Posted(Some(_)) | Self::Bound(Some(_)))
260    }
261
262    fn write(self, renderer: &mut Renderer<'a>) {
263        if let Self::Bound(Some(value)) = self {
264            return renderer.write_text(value);
265        }
266        let resolved = self.resolve(renderer);
267        write_escaped(renderer.output(), resolved);
268    }
269
270    fn resolve(self, renderer: &Renderer<'a>) -> Resolved<'a> {
271        match self {
272            Self::Posted(value) => value,
273            Self::Bound(value) => value.and_then(|value| renderer.resolve(value)),
274        }
275    }
276}
277
278pub(super) fn render_field<'a>(
279    field: &'a Field,
280    form_id: &str,
281    submitted: Option<&Submitted<'a>>,
282    renderer: &mut Renderer<'a>,
283) -> Result<(), LocatedError> {
284    let error = submitted.and_then(|submitted| submitted.error(&field.name));
285    let input = field.input();
286    let source = match submitted {
287        Some(_) if matches!(input.control, Control::Password | Control::File) => {
288            Source::Posted(None)
289        }
290        Some(submitted) => Source::Posted(submitted.input.get(&field.name).map(Show::show)),
291        None => Source::Bound(field.value()),
292    };
293    renderer.push_markup("<div class=\"sofie-field\">");
294    if !input.attributes.contains(&("type", "hidden")) {
295        let out = renderer.output();
296        out.push_str("<label for=\"");
297        push_field_id(out, form_id, &field.name);
298        out.push_str("\">");
299        renderer.write_text(&field.label);
300        renderer.push_markup("</label>");
301    }
302    let attributes = ControlAttributes {
303        field,
304        form_id,
305        is_invalid: error.is_some(),
306    };
307    match input.control {
308        Control::Input => render_input(&attributes, input.attributes, source, renderer),
309        Control::Password => render_input(&attributes, PASSWORD, source, renderer),
310        Control::Textarea => render_textarea(&attributes, source, renderer),
311        Control::Checkbox => render_checkbox(&attributes, source, renderer),
312        Control::Select => render_select(&attributes, input.options, source, renderer),
313        Control::Money => render_money_input(&attributes, source, renderer)?,
314        Control::Percentage => render_percentage_input(&attributes, source, renderer),
315        Control::Parts(parts) => render_parts(&attributes, parts, source, renderer),
316        Control::File => render_input(&attributes, FILE, source, renderer),
317    }
318    let out = renderer.output();
319    out.push_str("<p class=\"sofie-field-error\" id=\"");
320    push_slot_id(out, form_id, &field.name);
321    out.push_str("\" aria-live=\"polite\">");
322    push_escaped(out, error.unwrap_or_default());
323    out.push_str("</p></div>");
324    Ok(())
325}
326
327const PASSWORD: &[(&str, &str)] = &[("type", "password")];
328const FILE: &[(&str, &str)] = &[("type", "file")];
329
330struct ControlAttributes<'c, 'a> {
331    field: &'a Field,
332    form_id: &'c str,
333    is_invalid: bool,
334}
335
336impl ControlAttributes<'_, '_> {
337    fn push(&self, out: &mut String, name_suffix: &str) {
338        self.push_id(out);
339        self.push_name(out, name_suffix);
340        if self.field.is_required() {
341            out.push_str(" required");
342        }
343        for (name, bound) in self
344            .field
345            .constraints()
346            .iter()
347            .filter_map(|constraint| constraint_attribute(*constraint))
348        {
349            out.push(' ');
350            out.push_str(name);
351            out.push_str("=\"");
352            push_display(out, bound);
353            out.push('"');
354        }
355        if self.is_invalid {
356            out.push_str(" aria-invalid=\"true\"");
357        }
358    }
359
360    /// Only the first part carries the id the label points to; constraints describe the whole
361    /// value, so no part takes them.
362    fn push_part(&self, out: &mut String, part: &str, is_first: bool) {
363        if is_first {
364            self.push_id(out);
365        } else {
366            out.push_str(" aria-label=\"");
367            push_escaped(out, &humanize(part));
368            out.push('"');
369        }
370        self.push_name(out, &format!(".{part}"));
371        if self.field.is_required() {
372            out.push_str(" required");
373        }
374        if self.is_invalid {
375            out.push_str(" aria-invalid=\"true\"");
376        }
377    }
378
379    fn push_id(&self, out: &mut String) {
380        out.push_str(" id=\"");
381        push_field_id(out, self.form_id, &self.field.name);
382        out.push('"');
383    }
384
385    fn push_name(&self, out: &mut String, name_suffix: &str) {
386        let name = &self.field.name;
387        out.push_str(" name=\"");
388        out.push_str(name);
389        out.push_str(name_suffix);
390        out.push_str("\" aria-describedby=\"");
391        push_slot_id(out, self.form_id, name);
392        out.push('"');
393    }
394}
395
396/// Only the database can check `Unique`; `Required` comes from the field's own `required`.
397fn constraint_attribute(constraint: Constraint) -> Option<(&'static str, i64)> {
398    match constraint {
399        Constraint::MinLength(bound) => Some(("minlength", bound)),
400        Constraint::MaxLength(bound) => Some(("maxlength", bound)),
401        Constraint::Min(bound) => Some(("min", bound)),
402        Constraint::Max(bound) => Some(("max", bound)),
403        Constraint::Required | Constraint::Unique => None,
404    }
405}
406
407/// The input's own attributes come from code, yet are escaped like data.
408fn render_input<'a>(
409    attributes: &ControlAttributes<'_, '_>,
410    input_attributes: &[(&str, &str)],
411    source: Source<'a>,
412    renderer: &mut Renderer<'a>,
413) {
414    let zone = DisplayZone::current();
415    let instant = text_of(source.resolve(renderer))
416        .filter(|_| input_attributes.contains(&("type", "datetime-local")))
417        .and_then(|value| match source {
418            Source::Bound(_) => instant_control(&value, zone),
419            Source::Posted(_) => Some(posted_instant_control(value.into_owned(), zone)),
420        });
421    let out = renderer.output();
422    out.push_str("<input");
423    match instant {
424        Some(InstantControl::WithOffset(_)) => {
425            let others = input_attributes.iter().filter(|(name, _)| *name != "type");
426            push_input_attributes(out, [("type", "text")].iter().chain(others));
427        }
428        Some(InstantControl::Second(_)) => {
429            push_input_attributes(out, input_attributes);
430            out.push_str(" step=\"any\"");
431        }
432        Some(InstantControl::Minute(_)) | None => push_input_attributes(out, input_attributes),
433    }
434    attributes.push(out, "");
435    if !source.is_present() {
436        renderer.push_markup(">");
437        return;
438    }
439    renderer.push_markup(" value=\"");
440    match instant {
441        Some(
442            InstantControl::Minute(value)
443            | InstantControl::Second(value)
444            | InstantControl::WithOffset(value),
445        ) => {
446            push_escaped(renderer.output(), &value);
447        }
448        None => source.write(renderer),
449    }
450    renderer.push_markup("\">");
451}
452
453fn push_input_attributes<'s>(
454    out: &mut String,
455    input_attributes: impl IntoIterator<Item = &'s (&'s str, &'s str)>,
456) {
457    for (name, value) in input_attributes {
458        out.push(' ');
459        out.push_str(name);
460        out.push_str("=\"");
461        push_escaped(out, value);
462        out.push('"');
463    }
464}
465
466/// An instant in a `datetime-local` control, on the wall clock `Instant::parse` reads it back on.
467#[derive(Debug, PartialEq)]
468enum InstantControl {
469    Minute(String),
470    /// Needs `step="any"`, or the browser refuses the seconds.
471    Second(String),
472    /// A text box: a wall clock the zone repeats, which a `datetime-local` control can't tell
473    /// apart, or a fraction finer than the milliseconds it keeps.
474    WithOffset(String),
475}
476
477fn instant_control(value: &str, zone: &DisplayZone) -> Option<InstantControl> {
478    let is_offset_time = value.ends_with('Z') && value.as_bytes().get(10) == Some(&b'T');
479    if !is_offset_time {
480        return None;
481    }
482    let instant = Instant::parse(value).ok()?;
483    let wall_clock = zone.wall_clock(instant);
484    if zone.repeats(wall_clock) || wall_clock.subsec_nanosecond() % 1_000_000 != 0 {
485        return Some(InstantControl::WithOffset(zone.with_offset(instant)));
486    }
487    if wall_clock.second() == 0 && wall_clock.subsec_nanosecond() == 0 {
488        return Some(InstantControl::Minute(format!(
489            "{}T{:02}:{:02}",
490            wall_clock.date(),
491            wall_clock.hour(),
492            wall_clock.minute()
493        )));
494    }
495    Some(InstantControl::Second(wall_clock.to_string()))
496}
497
498/// Shown again as typed, in the control that holds it. A text box holds any text that is no wall
499/// clock, a wall clock the zone repeats or that has a fraction finer than milliseconds, and one the
500/// server cannot read in the zone; the client, which cannot read it, skips that last test.
501fn posted_instant_control(text: String, zone: &DisplayZone) -> InstantControl {
502    if !Instant::is_wall_clock(&text) {
503        return InstantControl::WithOffset(text);
504    }
505    let fraction_digits = text
506        .split_once('.')
507        .map_or(0, |(_, fraction)| fraction.len());
508    if fraction_digits > 3 || Instant::parse_in(&text, zone).is_err() {
509        return InstantControl::WithOffset(text);
510    }
511    if text.len() == 16 {
512        InstantControl::Minute(text)
513    } else {
514        InstantControl::Second(text)
515    }
516}
517
518fn render_textarea<'a>(
519    attributes: &ControlAttributes<'_, '_>,
520    source: Source<'a>,
521    renderer: &mut Renderer<'a>,
522) {
523    let out = renderer.output();
524    out.push_str("<textarea");
525    attributes.push(out, "");
526    // The parser drops one newline after the start tag, so a value's own leading newline stays.
527    out.push_str(">\n");
528    source.write(renderer);
529    renderer.push_markup("</textarea>");
530}
531
532/// The hidden `false` comes first, so an unchecked box still posts and a checked one overrides it.
533/// Only `true` checks a bound box: the text `"false"` would be truthy.
534fn render_checkbox<'a>(
535    attributes: &ControlAttributes<'_, '_>,
536    source: Source<'a>,
537    renderer: &mut Renderer<'a>,
538) {
539    let is_checked = match source {
540        Source::Posted(value) => str_of(value) == Some("true"),
541        Source::Bound(_) => matches!(source.resolve(renderer), Some(Shown::Bool(true))),
542    };
543    let out = renderer.output();
544    out.push_str("<input type=\"hidden\" name=\"");
545    out.push_str(&attributes.field.name);
546    out.push_str("\" value=\"false\"><input type=\"checkbox\"");
547    attributes.push(out, "");
548    out.push_str(" value=\"true\"");
549    if is_checked {
550        out.push_str(" checked");
551    }
552    out.push('>');
553}
554
555/// An optional select starts with an empty choice, so nothing need be chosen.
556fn render_select<'a>(
557    attributes: &ControlAttributes<'_, 'a>,
558    own_options: &[&str],
559    source: Source<'a>,
560    renderer: &mut Renderer<'a>,
561) {
562    let options = attributes
563        .field
564        .options
565        .as_ref()
566        .map(|options| &options.value);
567    let selected = choice_of(source.resolve(renderer));
568    let selected = selected.as_deref();
569    let choices: Vec<Cow<'_, str>> = match options {
570        Some(options) => items_of(renderer.resolve(options))
571            .filter_map(|item| choice_of(Some(item)))
572            .collect(),
573        None => own_options.iter().copied().map(Cow::Borrowed).collect(),
574    };
575    let out = renderer.output();
576    out.push_str("<select");
577    attributes.push(out, "");
578    out.push('>');
579    if !attributes.field.is_required() {
580        out.push_str("<option value=\"\"></option>");
581    }
582    #[cfg(feature = "i18n")]
583    super::badge::write_enum_options(renderer, options, selected);
584    for choice in &choices {
585        write_option(renderer.output(), choice, choice, selected);
586    }
587    renderer.push_markup("</select>");
588}
589
590/// Numbers are offered too, written as a form posts them back.
591fn choice_of(resolved: Resolved<'_>) -> Option<Cow<'_, str>> {
592    match resolved {
593        Some(Shown::Integer(_) | Shown::Float(_) | Shown::Number(_)) => {
594            let mut text = String::new();
595            write_plain(&mut text, resolved);
596            Some(Cow::Owned(text))
597        }
598        _ => text_of(resolved),
599    }
600}
601
602pub(super) fn write_option(out: &mut String, value: &str, label: &str, selected: Option<&str>) {
603    out.push_str("<option value=\"");
604    push_escaped(out, value);
605    out.push('"');
606    if selected == Some(value) {
607        out.push_str(" selected");
608    }
609    out.push('>');
610    push_escaped(out, label);
611    out.push_str("</option>");
612}
613
614/// A currency is three letters and a formatted amount digits, `-` and `.`; only a posted amount
615/// needs escaping.
616fn render_money_input<'a>(
617    attributes: &ControlAttributes<'_, 'a>,
618    source: Source<'a>,
619    renderer: &mut Renderer<'a>,
620) -> Result<(), LocatedError> {
621    let field = attributes.field;
622    let amount = money_of(Source::Bound(field.value()).resolve(renderer));
623    let currency = match amount {
624        Some(amount) => Some(amount.currency()),
625        None => bound_currency(field, renderer)?,
626    };
627    let posted = |part: &str| match source {
628        Source::Posted(posted) => Some(str_of(field_of(posted, part)).unwrap_or_default()),
629        Source::Bound(_) => None,
630    };
631    let currency_label = renderer.language.text("field.currency", "Currency");
632    let out = renderer.output();
633    out.push_str("<div class=\"sofie-money-input\"><input type=\"text\" inputmode=\"decimal\"");
634    attributes.push(out, ".amount");
635    match (posted("amount"), amount) {
636        (Some(typed), _) => push_value(out, typed),
637        (None, Some(amount)) => {
638            out.push_str(" value=\"");
639            amount.write_major(out);
640            out.push('"');
641        }
642        (None, None) => {}
643    }
644    out.push('>');
645    push_currency(
646        out,
647        &field.name,
648        currency,
649        posted("currency"),
650        currency_label,
651    );
652    out.push_str("</div>");
653    Ok(())
654}
655
656/// A bound value is basis points; a posted one is shown as it was typed.
657fn render_percentage_input<'a>(
658    attributes: &ControlAttributes<'_, 'a>,
659    source: Source<'a>,
660    renderer: &mut Renderer<'a>,
661) {
662    let resolved = source.resolve(renderer);
663    let basis_points = match (source, resolved) {
664        (Source::Bound(_), Some(Shown::Integer(basis_points))) => Some(basis_points),
665        (Source::Bound(_), Some(Shown::Number(number))) => number.as_i64(),
666        _ => None,
667    };
668    let out = renderer.output();
669    out.push_str(
670        "<div class=\"sofie-percentage-input\"><input type=\"text\" inputmode=\"decimal\"",
671    );
672    attributes.push(out, "");
673    if let Some(basis_points) = basis_points {
674        let sign = if basis_points < 0 { "-" } else { "" };
675        let magnitude = basis_points.unsigned_abs();
676        out.push_str(" value=\"");
677        push_display(
678            out,
679            format_args!("{sign}{}.{:02}", magnitude / 100, magnitude % 100),
680        );
681        out.push('"');
682    } else {
683        push_resolved_value(out, resolved);
684    }
685    out.push_str("><span class=\"sofie-unit\">%</span></div>");
686}
687
688fn render_parts<'a>(
689    attributes: &ControlAttributes<'_, 'a>,
690    parts: &[Part],
691    source: Source<'a>,
692    renderer: &mut Renderer<'a>,
693) {
694    let resolved = source.resolve(renderer);
695    let out = renderer.output();
696    out.push_str("<div class=\"sofie-parts\">");
697    for (index, part) in parts.iter().enumerate() {
698        let value = field_of(resolved, part.name);
699        if part.options.is_empty() {
700            out.push_str("<input");
701            push_input_attributes(out, part.attributes);
702            attributes.push_part(out, part.name, index == 0);
703            push_resolved_value(out, value);
704            out.push('>');
705            continue;
706        }
707        out.push_str("<select");
708        attributes.push_part(out, part.name, index == 0);
709        out.push('>');
710        if !attributes.field.is_required() {
711            out.push_str("<option value=\"\"></option>");
712        }
713        let selected = text_of(value);
714        for option in part.options {
715            write_option(out, option, option, selected.as_deref());
716        }
717        out.push_str("</select>");
718    }
719    out.push_str("</div>");
720}
721
722fn push_currency(
723    out: &mut String,
724    name: &str,
725    currency: Option<Currency>,
726    typed: Option<&str>,
727    label: &str,
728) {
729    let Some(currency) = currency else {
730        out.push_str("<input type=\"text\" class=\"sofie-currency\" maxlength=\"3\" aria-label=\"");
731        push_escaped(out, label);
732        out.push_str("\" name=\"");
733        out.push_str(name);
734        out.push_str(".currency\"");
735        push_value(out, typed.unwrap_or_default());
736        out.push('>');
737        return;
738    };
739    out.push_str("<span class=\"sofie-currency\">");
740    out.push_str(currency.as_str());
741    out.push_str("</span><input type=\"hidden\" name=\"");
742    out.push_str(name);
743    out.push_str(".currency\" value=\"");
744    out.push_str(currency.as_str());
745    out.push_str("\">");
746}
747
748fn push_value(out: &mut String, value: &str) {
749    if value.is_empty() {
750        return;
751    }
752    out.push_str(" value=\"");
753    push_escaped(out, value);
754    out.push('"');
755}
756
757fn push_resolved_value(out: &mut String, resolved: Resolved<'_>) {
758    if matches!(resolved, None | Some(Shown::Null)) {
759        return;
760    }
761    out.push_str(" value=\"");
762    write_escaped(out, resolved);
763    out.push('"');
764}
765
766fn bound_currency<'a>(
767    field: &'a Field,
768    renderer: &Renderer<'a>,
769) -> Result<Option<Currency>, LocatedError> {
770    let Some(currency) = &field.currency else {
771        return Ok(None);
772    };
773    let resolved = renderer.resolve(&currency.value);
774    if matches!(resolved, Some(Shown::Null)) {
775        return Ok(None);
776    }
777    let code = text_of(resolved);
778    Currency::parse(code.as_deref().unwrap_or_default()).map(Some).map_err(|_| {
779        LocatedError::new(
780            field.position,
781            format!(
782                "money field `{}` has no currency: its `value` is not money and its `currency` is not an ISO 4217 code",
783                field.name
784            ),
785        )
786    })
787}
788
789/// Escaped though callers pass checked ids, so a missed check cannot open the attribute.
790fn push_field_id(out: &mut String, form_id: &str, name: &str) {
791    push_escaped(out, form_id);
792    out.push('-');
793    out.push_str(name);
794}
795
796fn push_slot_id(out: &mut String, form_id: &str, name: &str) {
797    push_field_id(out, form_id, name);
798    out.push_str("-error");
799}
800
801/// A form id or field that is not a plain name is skipped, so request data cannot widen selectors.
802#[must_use]
803pub fn field_errors(form_id: &str, errors: &[(&str, String)]) -> String {
804    let mut html = String::new();
805    if !is_element_id(form_id) {
806        return html;
807    }
808    html.push_str("<div hx-swap-oob=\"innerHTML:#");
809    push_escaped(&mut html, form_id);
810    html.push_str(" .sofie-field-error\"></div>");
811    for (field, message) in errors.iter().filter(|(field, _)| is_field_name(field)) {
812        html.push_str("<div hx-swap-oob=\"innerHTML:#");
813        push_slot_id(&mut html, form_id, field);
814        html.push_str("\">");
815        push_escaped(&mut html, message);
816        html.push_str("</div>");
817    }
818    html
819}
820
821#[cfg(test)]
822mod tests {
823    use sofie_core::DisplayZone;
824
825    use super::{InstantControl, instant_control, push_slot_id};
826
827    #[test]
828    fn a_date_time_control_shows_an_instant_on_the_display_zone_s_wall_clock() {
829        let zone = DisplayZone::named("Europe/Istanbul").unwrap();
830
831        let minutes = instant_control("2026-10-11T22:30:00Z", &zone);
832        let seconds = instant_control("2026-10-11T22:30:15.5Z", &zone);
833
834        assert_eq!(
835            minutes,
836            Some(InstantControl::Minute("2026-10-12T01:30".to_owned()))
837        );
838        assert_eq!(
839            seconds,
840            Some(InstantControl::Second("2026-10-12T01:30:15.5".to_owned()))
841        );
842    }
843
844    #[test]
845    fn an_id_is_escaped_even_if_a_check_is_missed() {
846        let mut out = String::new();
847
848        push_slot_id(&mut out, "f\" onclick=\"x", "name");
849
850        assert_eq!(out, "f&quot; onclick=&quot;x-name-error");
851    }
852}