1use std::collections::BTreeMap;
4
5use crate::component_graph::AuthoredSubmissionDeclarationFact;
6use crate::{
7 ComponentNode, EffectTriggerPlan, FieldId, FormEntity, FormFieldEntity, FormId, SemanticId,
8 SourceProvenance, SubmissionDeclarationCandidateId, SubmissionPlanId, ValidationRule,
9 ValidationRuleId,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub enum SubmissionDeclarationViolation {
14 InvalidOwner,
15 InvalidDecoratorInvocation,
16 InvalidDecoratorArity,
17 InvalidFormDesignator,
18 UnresolvedForm,
19 InvalidAction,
20 StaticMethod,
21 AsyncMethod,
22 ParameterizedMethod,
23 InvalidReturnType,
24 InheritedDeclaration,
25 DuplicateFormSubmission,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SubmissionDeclarationCandidate {
30 pub id: SubmissionDeclarationCandidateId,
31 pub owner_component: Option<SemanticId>,
32 pub method: Option<SemanticId>,
33 pub form_designator: Option<String>,
34 pub resolved_form: Option<FormId>,
35 pub action_batch: Option<SemanticId>,
36 pub provenance: SourceProvenance,
37 pub decorator_provenance: SourceProvenance,
38 pub form_designator_provenance: Option<SourceProvenance>,
39 pub violations: Vec<SubmissionDeclarationViolation>,
40}
41
42impl SubmissionDeclarationCandidate {
43 #[must_use]
44 pub fn is_valid(&self) -> bool {
45 self.violations.is_empty()
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SubmitResetPolicy {
51 Never,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct FormSubmissionPlan {
56 pub id: SubmissionPlanId,
57 pub form: FormId,
58 pub component: SemanticId,
59 pub candidate: SubmissionDeclarationCandidateId,
60 pub action_method: SemanticId,
61 pub action_batch: SemanticId,
62 pub validation_rules: Vec<ValidationRuleId>,
63 pub blocks_action_on_invalid: bool,
64 pub reset_policy: SubmitResetPolicy,
65 pub provenance: SourceProvenance,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct SubmissionProducts {
70 pub candidates: Vec<SubmissionDeclarationCandidate>,
71 pub plans: BTreeMap<SubmissionPlanId, FormSubmissionPlan>,
72}
73
74impl SubmissionProducts {
75 #[must_use]
76 pub fn plan(&self, form: &FormId) -> Option<&FormSubmissionPlan> {
77 self.plans.get(&SubmissionPlanId::for_form(form))
78 }
79}
80
81#[must_use]
82#[allow(clippy::too_many_lines)]
83pub fn collect_submission_products(
88 components: &[ComponentNode],
89 forms: &BTreeMap<FormId, FormEntity>,
90 fields: &BTreeMap<FieldId, FormFieldEntity>,
91 rules: &BTreeMap<ValidationRuleId, ValidationRule>,
92 action_batches: &EffectTriggerPlan,
93) -> SubmissionProducts {
94 let mut candidates = components
95 .iter()
96 .flat_map(|component| component.submission_declaration_facts.iter())
97 .map(|fact| lower_candidate(fact, forms, action_batches))
98 .collect::<Vec<_>>();
99 candidates.sort_by(|left, right| {
100 (
101 left.provenance.path.as_path(),
102 left.decorator_provenance.span.start,
103 left.id.as_str(),
104 )
105 .cmp(&(
106 right.provenance.path.as_path(),
107 right.decorator_provenance.span.start,
108 right.id.as_str(),
109 ))
110 });
111
112 let mut groups = BTreeMap::<FormId, Vec<usize>>::new();
113 for (index, candidate) in candidates
114 .iter()
115 .enumerate()
116 .filter(|(_, candidate)| candidate.is_valid())
117 {
118 groups
119 .entry(
120 candidate
121 .resolved_form
122 .clone()
123 .expect("valid submission resolves Form"),
124 )
125 .or_default()
126 .push(index);
127 }
128 for indexes in groups.values().filter(|indexes| indexes.len() > 1) {
129 for index in indexes {
130 add_violation(
131 &mut candidates[*index],
132 SubmissionDeclarationViolation::DuplicateFormSubmission,
133 );
134 }
135 }
136 let mut methods = BTreeMap::<SemanticId, Vec<usize>>::new();
137 for (index, candidate) in candidates
138 .iter()
139 .enumerate()
140 .filter(|(_, candidate)| candidate.is_valid())
141 {
142 methods
143 .entry(
144 candidate
145 .method
146 .clone()
147 .expect("valid submission has method"),
148 )
149 .or_default()
150 .push(index);
151 }
152 for indexes in methods.values().filter(|indexes| indexes.len() > 1) {
153 for index in indexes {
154 add_violation(
155 &mut candidates[*index],
156 SubmissionDeclarationViolation::DuplicateFormSubmission,
157 );
158 }
159 }
160
161 let mut plans = BTreeMap::new();
162 for candidate in candidates.iter().filter(|candidate| candidate.is_valid()) {
163 let form = candidate
164 .resolved_form
165 .clone()
166 .expect("valid submission resolves Form");
167 let form_entity = forms.get(&form).expect("valid submission Form exists");
168 let action_method = candidate
169 .method
170 .clone()
171 .expect("valid submission has action method");
172 let action_batch = candidate
173 .action_batch
174 .clone()
175 .expect("valid submission has action batch");
176 let mut validation_rules = rules
177 .values()
178 .filter(|rule| rule.owner_form == form)
179 .collect::<Vec<_>>();
180 validation_rules.sort_by(|left, right| {
181 (
182 left.field_authored_order,
183 left.rule_authored_order,
184 &left.id,
185 )
186 .cmp(&(
187 right.field_authored_order,
188 right.rule_authored_order,
189 &right.id,
190 ))
191 });
192 plans.insert(
193 SubmissionPlanId::for_form(&form),
194 FormSubmissionPlan {
195 id: SubmissionPlanId::for_form(&form),
196 form,
197 component: form_entity
198 .owner
199 .entity_id()
200 .expect("valid Form has Component owner")
201 .clone(),
202 candidate: candidate.id.clone(),
203 action_method,
204 action_batch,
205 validation_rules: validation_rules
206 .into_iter()
207 .map(|rule| rule.id.clone())
208 .collect(),
209 blocks_action_on_invalid: true,
210 reset_policy: SubmitResetPolicy::Never,
211 provenance: candidate.provenance.clone(),
212 },
213 );
214 }
215 let _ = fields; SubmissionProducts { candidates, plans }
217}
218
219fn lower_candidate(
220 fact: &AuthoredSubmissionDeclarationFact,
221 forms: &BTreeMap<FormId, FormEntity>,
222 action_batches: &EffectTriggerPlan,
223) -> SubmissionDeclarationCandidate {
224 let mut violations = Vec::new();
225 if fact.owner_component.is_none() {
226 violations.push(SubmissionDeclarationViolation::InvalidOwner);
227 }
228 if !fact.submit_invoked {
229 violations.push(SubmissionDeclarationViolation::InvalidDecoratorInvocation);
230 }
231 if fact.submit_argument_count != 1 {
232 violations.push(SubmissionDeclarationViolation::InvalidDecoratorArity);
233 }
234 if fact.form_designator.is_none() {
235 violations.push(SubmissionDeclarationViolation::InvalidFormDesignator);
236 }
237 if !fact.has_action || !fact.action_invoked || fact.action_argument_count != 0 {
238 violations.push(SubmissionDeclarationViolation::InvalidAction);
239 }
240 if fact.is_static {
241 violations.push(SubmissionDeclarationViolation::StaticMethod);
242 }
243 if fact.is_async {
244 violations.push(SubmissionDeclarationViolation::AsyncMethod);
245 }
246 if fact.parameter_count != 0 {
247 violations.push(SubmissionDeclarationViolation::ParameterizedMethod);
248 }
249 if fact.return_type.as_deref() != Some("void") {
250 violations.push(SubmissionDeclarationViolation::InvalidReturnType);
251 }
252 if fact.inherited {
253 violations.push(SubmissionDeclarationViolation::InheritedDeclaration);
254 }
255 let resolved_form = fact
256 .owner_component
257 .as_ref()
258 .zip(fact.form_designator.as_ref())
259 .and_then(|(component, name)| {
260 forms
261 .values()
262 .find(|form| form.owner.entity_id() == Some(component) && form.name == *name)
263 .map(|form| form.id.clone())
264 });
265 if fact.submit_invoked
266 && fact.submit_argument_count == 1
267 && fact.form_designator.is_some()
268 && resolved_form.is_none()
269 {
270 violations.push(SubmissionDeclarationViolation::UnresolvedForm);
271 }
272 let action_batch = fact
273 .owner_component
274 .as_ref()
275 .zip(fact.method_name.as_ref())
276 .map(|(component, method)| component.action_batch(method))
277 .filter(|id| action_batches.action_batches.contains_key(id));
278 if fact.has_action && action_batch.is_none() {
279 violations.push(SubmissionDeclarationViolation::InvalidAction);
280 }
281 violations.sort();
282 violations.dedup();
283 SubmissionDeclarationCandidate {
284 id: fact.id.clone(),
285 owner_component: fact.owner_component.clone(),
286 method: fact.method.clone(),
287 form_designator: fact.form_designator.clone(),
288 resolved_form,
289 action_batch,
290 provenance: fact.method_provenance.clone(),
291 decorator_provenance: fact.decorator_provenance.clone(),
292 form_designator_provenance: fact.form_designator_provenance.clone(),
293 violations,
294 }
295}
296
297fn add_violation(
298 candidate: &mut SubmissionDeclarationCandidate,
299 violation: SubmissionDeclarationViolation,
300) {
301 if !candidate.violations.contains(&violation) {
302 candidate.violations.push(violation);
303 candidate.violations.sort();
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use crate::{
310 build_application_semantic_model, FormId, SubmissionDeclarationViolation, SubmissionPlanId,
311 };
312
313 #[test]
314 fn lowers_one_submit_plan_with_the_complete_form_rule_order() {
315 let parsed = presolve_parser::parse_file(
316 "src/Profile.tsx",
317 r#"
318@component("profile-editor")
319class ProfileEditor {
320 @form() profile!: Form;
321 @validate(required()) @field("profile") name = "";
322 @validate(min(0)) @field("profile") age = 0;
323 @action() @submit("profile") save(): void {}
324 render() { return <input field={this.name} />; }
325}
326"#,
327 );
328 let model = build_application_semantic_model(&parsed);
329 let form = FormId::for_owner(&model.components[0].id, "profile");
330 let plan = model.submissions.plan(&form).expect("submission plan");
331 assert_eq!(plan.id, SubmissionPlanId::for_form(&form));
332 assert_eq!(plan.validation_rules.len(), 2);
333 assert_eq!(
334 plan.action_batch,
335 model.components[0].id.action_batch("save")
336 );
337 assert!(matches!(plan.reset_policy, super::SubmitResetPolicy::Never));
338 }
339
340 #[test]
341 fn retains_invalid_submit_candidates_without_plan_or_winner() {
342 let parsed = presolve_parser::parse_file(
343 "src/Profile.tsx",
344 r#"
345@component("profile-editor")
346class ProfileEditor {
347 @form() profile!: Form;
348 @field(this.profile) name = "";
349 @submit(this.profile) missingAction(): void {}
350 @action() @submit(this.profile) duplicate(value: string): void {}
351 render() { return <input field={this.name} />; }
352}
353"#,
354 );
355 let model = build_application_semantic_model(&parsed);
356 assert_eq!(model.submissions.candidates.len(), 2);
357 assert!(model.submissions.plans.is_empty());
358 assert!(model
359 .submissions
360 .candidates
361 .iter()
362 .any(|candidate| candidate
363 .violations
364 .contains(&SubmissionDeclarationViolation::InvalidAction)));
365 assert!(model
366 .submissions
367 .candidates
368 .iter()
369 .any(|candidate| candidate
370 .violations
371 .contains(&SubmissionDeclarationViolation::ParameterizedMethod)));
372 }
373}