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 #[cfg(feature = "client-compile")]
49 pub(crate) fn is_written_required(&self) -> bool {
50 self.is_required
51 }
52
53 #[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#[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
236fn 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 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
396fn 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
407fn 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#[derive(Debug, PartialEq)]
468enum InstantControl {
469 Minute(String),
470 Second(String),
472 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
498fn 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 out.push_str(">\n");
528 source.write(renderer);
529 renderer.push_markup("</textarea>");
530}
531
532fn 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
555fn 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
590fn 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
614fn 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
656fn 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(¤cy.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
789fn 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#[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" onclick="x-name-error");
851 }
852}