Skip to main content

sofie_engine/catalog/
form.rs

1use sofie_values::FormField;
2
3use super::field::{Field, render_field};
4use super::props::{Props, is_element_id};
5use super::target::Target;
6use super::{Component, Content};
7use crate::render::Renderer;
8use crate::syntax::{Expression, LocatedError, Op, Position};
9
10/// htmx sends its id as `HX-Trigger`, so the reply can place errors under the fields.
11#[derive(Debug)]
12pub(crate) struct Form {
13    pub id: String,
14    pub post: Expression,
15    pub target: Option<Target>,
16    pub parts: Vec<FormPart>,
17    position: Position,
18}
19
20#[derive(Debug)]
21pub(crate) enum FormPart {
22    Field(Box<Field>),
23    Submit(Submit),
24}
25
26#[derive(Debug)]
27pub(crate) struct Submit {
28    pub label: Expression,
29}
30
31/// Shown again in the form whose `post` is `action`, so nothing typed is lost but a password.
32#[derive(Clone, Copy, Debug)]
33pub struct Submitted<'a> {
34    pub action: &'a str,
35    pub input: &'a serde_json::Value,
36    pub errors: &'a [(&'a str, String)],
37}
38
39impl<'a> Submitted<'a> {
40    pub(super) fn error(&self, field: &str) -> Option<&'a str> {
41        self.errors
42            .iter()
43            .find(|(name, _)| *name == field)
44            .map(|(_, message)| message.as_str())
45    }
46}
47
48pub(super) fn build_form(
49    props: &mut Props,
50    children: Vec<Op>,
51    position: Position,
52) -> Result<Form, LocatedError> {
53    let (id, id_position) = props.required_literal("id")?;
54    if !is_element_id(&id) {
55        bail!(
56            id_position,
57            "`Form` id `{id}` is not an element id (a letter, then letters, digits, `-` and `_`)"
58        );
59    }
60    let parts = form_parts(children, position)?;
61    check_unique_names(&id, &parts)?;
62    Ok(Form {
63        id,
64        post: props.required_post()?,
65        target: Target::take(props)?,
66        parts,
67        position,
68    })
69}
70
71pub(super) fn build_submit(props: &mut Props) -> Result<Submit, LocatedError> {
72    Ok(Submit {
73        label: props.required("label")?,
74    })
75}
76
77fn form_parts(children: Vec<Op>, position: Position) -> Result<Vec<FormPart>, LocatedError> {
78    let mut parts = Vec::new();
79    for child in children {
80        match child {
81            Op::Markup(text) if text.trim().is_empty() => {}
82            Op::Component(Component {
83                content: Content::FormPart(part),
84                ..
85            }) => parts.push(part),
86            _ => {
87                bail!(position, "`Form` takes only `Field` and `Submit` children");
88            }
89        }
90    }
91    Ok(parts)
92}
93
94fn check_unique_names(form_id: &str, parts: &[FormPart]) -> Result<(), LocatedError> {
95    let mut seen: Vec<&str> = Vec::new();
96    for part in parts {
97        let FormPart::Field(field) = part else {
98            continue;
99        };
100        if seen.contains(&field.name.as_str()) {
101            bail!(
102                field.position,
103                "`Form` `{form_id}` has two fields named `{}`",
104                field.name
105            );
106        }
107        seen.push(&field.name);
108    }
109    Ok(())
110}
111
112impl Form {
113    pub(super) fn field_names(&self) -> impl Iterator<Item = &str> {
114        self.parts.iter().filter_map(|part| match part {
115            FormPart::Field(field) => Some(field.name.as_str()),
116            FormPart::Submit(_) => None,
117        })
118    }
119
120    pub(super) fn describe(&mut self, fields: &'static [FormField]) -> Result<(), LocatedError> {
121        let mut written = Vec::new();
122        let mut submits = Vec::new();
123        for part in std::mem::take(&mut self.parts) {
124            match part {
125                FormPart::Field(field) => written.push(field),
126                submit @ FormPart::Submit(_) => submits.push(submit),
127            }
128        }
129        for described in fields {
130            let mut field = match written
131                .iter()
132                .position(|field| field.name == described.name)
133            {
134                Some(index) => written.swap_remove(index),
135                None => Box::new(Field::of(described, self.position)),
136            };
137            field.describe(described)?;
138            self.parts.push(FormPart::Field(field));
139        }
140        if let Some(stray) = written.first() {
141            let names: Vec<&str> = fields.iter().map(|described| described.name).collect();
142            bail!(
143                stray.position,
144                "`Field` `{}` is not a field of the input form `{}` posts; its fields are {}",
145                stray.name,
146                self.id,
147                names.join(", ")
148            );
149        }
150        self.parts.extend(submits);
151        Ok(())
152    }
153}
154
155/// Posts even without JavaScript, so fields never land in a URL.
156pub(super) fn render_form<'a>(
157    form: &'a Form,
158    renderer: &mut Renderer<'a>,
159) -> Result<(), LocatedError> {
160    renderer.push_markup("<form id=\"");
161    renderer.push_markup(&form.id);
162    renderer.push_markup("\" method=\"post\"");
163    if form
164        .parts
165        .iter()
166        .any(|part| matches!(part, FormPart::Field(field) if field.is_file()))
167    {
168        renderer.push_markup(" enctype=\"multipart/form-data\"");
169    }
170    renderer.write_url_attribute("action", &form.post);
171    renderer.write_url_attribute("hx-post", &form.post);
172    if let Some(target) = &form.target {
173        target.write(renderer);
174    }
175    renderer.push_markup(" hx-swap=\"outerHTML\">");
176    renderer.write_idempotency_key(form.position)?;
177    let submitted = renderer.submitted_to(&form.post);
178    for part in &form.parts {
179        match part {
180            FormPart::Field(field) => render_field(field, &form.id, submitted, renderer)?,
181            FormPart::Submit(submit) => render_submit(submit, renderer),
182        }
183    }
184    renderer.push_markup("</form>");
185    Ok(())
186}
187
188fn render_submit<'a>(submit: &'a Submit, renderer: &mut Renderer<'a>) {
189    renderer.push_markup("<button type=\"submit\">");
190    renderer.write_text(&submit.label);
191    renderer.push_markup("</button>");
192}