sofie_engine/catalog/
form.rs1use 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#[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#[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
155pub(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}