use std::collections::BTreeMap;
use crate::component_graph::AuthoredSubmissionDeclarationFact;
use crate::{
ComponentNode, EffectTriggerPlan, FieldId, FormEntity, FormFieldEntity, FormId, SemanticId,
SourceProvenance, SubmissionDeclarationCandidateId, SubmissionPlanId, ValidationRule,
ValidationRuleId,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SubmissionDeclarationViolation {
InvalidOwner,
InvalidDecoratorInvocation,
InvalidDecoratorArity,
InvalidFormDesignator,
UnresolvedForm,
InvalidAction,
StaticMethod,
AsyncMethod,
ParameterizedMethod,
InvalidReturnType,
InheritedDeclaration,
DuplicateFormSubmission,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubmissionDeclarationCandidate {
pub id: SubmissionDeclarationCandidateId,
pub owner_component: Option<SemanticId>,
pub method: Option<SemanticId>,
pub form_designator: Option<String>,
pub resolved_form: Option<FormId>,
pub action_batch: Option<SemanticId>,
pub provenance: SourceProvenance,
pub decorator_provenance: SourceProvenance,
pub form_designator_provenance: Option<SourceProvenance>,
pub violations: Vec<SubmissionDeclarationViolation>,
}
impl SubmissionDeclarationCandidate {
#[must_use]
pub fn is_valid(&self) -> bool {
self.violations.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubmitResetPolicy {
Never,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormSubmissionPlan {
pub id: SubmissionPlanId,
pub form: FormId,
pub component: SemanticId,
pub candidate: SubmissionDeclarationCandidateId,
pub action_method: SemanticId,
pub action_batch: SemanticId,
pub validation_rules: Vec<ValidationRuleId>,
pub blocks_action_on_invalid: bool,
pub reset_policy: SubmitResetPolicy,
pub provenance: SourceProvenance,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SubmissionProducts {
pub candidates: Vec<SubmissionDeclarationCandidate>,
pub plans: BTreeMap<SubmissionPlanId, FormSubmissionPlan>,
}
impl SubmissionProducts {
#[must_use]
pub fn plan(&self, form: &FormId) -> Option<&FormSubmissionPlan> {
self.plans.get(&SubmissionPlanId::for_form(form))
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn collect_submission_products(
components: &[ComponentNode],
forms: &BTreeMap<FormId, FormEntity>,
fields: &BTreeMap<FieldId, FormFieldEntity>,
rules: &BTreeMap<ValidationRuleId, ValidationRule>,
action_batches: &EffectTriggerPlan,
) -> SubmissionProducts {
let mut candidates = components
.iter()
.flat_map(|component| component.submission_declaration_facts.iter())
.map(|fact| lower_candidate(fact, forms, action_batches))
.collect::<Vec<_>>();
candidates.sort_by(|left, right| {
(
left.provenance.path.as_path(),
left.decorator_provenance.span.start,
left.id.as_str(),
)
.cmp(&(
right.provenance.path.as_path(),
right.decorator_provenance.span.start,
right.id.as_str(),
))
});
let mut groups = BTreeMap::<FormId, Vec<usize>>::new();
for (index, candidate) in candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.is_valid())
{
groups
.entry(
candidate
.resolved_form
.clone()
.expect("valid submission resolves Form"),
)
.or_default()
.push(index);
}
for indexes in groups.values().filter(|indexes| indexes.len() > 1) {
for index in indexes {
add_violation(
&mut candidates[*index],
SubmissionDeclarationViolation::DuplicateFormSubmission,
);
}
}
let mut methods = BTreeMap::<SemanticId, Vec<usize>>::new();
for (index, candidate) in candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.is_valid())
{
methods
.entry(
candidate
.method
.clone()
.expect("valid submission has method"),
)
.or_default()
.push(index);
}
for indexes in methods.values().filter(|indexes| indexes.len() > 1) {
for index in indexes {
add_violation(
&mut candidates[*index],
SubmissionDeclarationViolation::DuplicateFormSubmission,
);
}
}
let mut plans = BTreeMap::new();
for candidate in candidates.iter().filter(|candidate| candidate.is_valid()) {
let form = candidate
.resolved_form
.clone()
.expect("valid submission resolves Form");
let form_entity = forms.get(&form).expect("valid submission Form exists");
let action_method = candidate
.method
.clone()
.expect("valid submission has action method");
let action_batch = candidate
.action_batch
.clone()
.expect("valid submission has action batch");
let mut validation_rules = rules
.values()
.filter(|rule| rule.owner_form == form)
.collect::<Vec<_>>();
validation_rules.sort_by(|left, right| {
(
left.field_authored_order,
left.rule_authored_order,
&left.id,
)
.cmp(&(
right.field_authored_order,
right.rule_authored_order,
&right.id,
))
});
plans.insert(
SubmissionPlanId::for_form(&form),
FormSubmissionPlan {
id: SubmissionPlanId::for_form(&form),
form,
component: form_entity
.owner
.entity_id()
.expect("valid Form has Component owner")
.clone(),
candidate: candidate.id.clone(),
action_method,
action_batch,
validation_rules: validation_rules
.into_iter()
.map(|rule| rule.id.clone())
.collect(),
blocks_action_on_invalid: true,
reset_policy: SubmitResetPolicy::Never,
provenance: candidate.provenance.clone(),
},
);
}
let _ = fields; SubmissionProducts { candidates, plans }
}
fn lower_candidate(
fact: &AuthoredSubmissionDeclarationFact,
forms: &BTreeMap<FormId, FormEntity>,
action_batches: &EffectTriggerPlan,
) -> SubmissionDeclarationCandidate {
let mut violations = Vec::new();
if fact.owner_component.is_none() {
violations.push(SubmissionDeclarationViolation::InvalidOwner);
}
if !fact.submit_invoked {
violations.push(SubmissionDeclarationViolation::InvalidDecoratorInvocation);
}
if fact.submit_argument_count != 1 {
violations.push(SubmissionDeclarationViolation::InvalidDecoratorArity);
}
if fact.form_designator.is_none() {
violations.push(SubmissionDeclarationViolation::InvalidFormDesignator);
}
if !fact.has_action || !fact.action_invoked || fact.action_argument_count != 0 {
violations.push(SubmissionDeclarationViolation::InvalidAction);
}
if fact.is_static {
violations.push(SubmissionDeclarationViolation::StaticMethod);
}
if fact.is_async {
violations.push(SubmissionDeclarationViolation::AsyncMethod);
}
if fact.parameter_count != 0 {
violations.push(SubmissionDeclarationViolation::ParameterizedMethod);
}
if fact.return_type.as_deref() != Some("void") {
violations.push(SubmissionDeclarationViolation::InvalidReturnType);
}
if fact.inherited {
violations.push(SubmissionDeclarationViolation::InheritedDeclaration);
}
let resolved_form = fact
.owner_component
.as_ref()
.zip(fact.form_designator.as_ref())
.and_then(|(component, name)| {
forms
.values()
.find(|form| form.owner.entity_id() == Some(component) && form.name == *name)
.map(|form| form.id.clone())
});
if fact.submit_invoked
&& fact.submit_argument_count == 1
&& fact.form_designator.is_some()
&& resolved_form.is_none()
{
violations.push(SubmissionDeclarationViolation::UnresolvedForm);
}
let action_batch = fact
.owner_component
.as_ref()
.zip(fact.method_name.as_ref())
.map(|(component, method)| component.action_batch(method))
.filter(|id| action_batches.action_batches.contains_key(id));
if fact.has_action && action_batch.is_none() {
violations.push(SubmissionDeclarationViolation::InvalidAction);
}
violations.sort();
violations.dedup();
SubmissionDeclarationCandidate {
id: fact.id.clone(),
owner_component: fact.owner_component.clone(),
method: fact.method.clone(),
form_designator: fact.form_designator.clone(),
resolved_form,
action_batch,
provenance: fact.method_provenance.clone(),
decorator_provenance: fact.decorator_provenance.clone(),
form_designator_provenance: fact.form_designator_provenance.clone(),
violations,
}
}
fn add_violation(
candidate: &mut SubmissionDeclarationCandidate,
violation: SubmissionDeclarationViolation,
) {
if !candidate.violations.contains(&violation) {
candidate.violations.push(violation);
candidate.violations.sort();
}
}
#[cfg(test)]
mod tests {
use crate::{
build_application_semantic_model, FormId, SubmissionDeclarationViolation, SubmissionPlanId,
};
#[test]
fn lowers_one_submit_plan_with_the_complete_form_rule_order() {
let parsed = presolve_parser::parse_file(
"src/Profile.tsx",
r#"
@component("profile-editor")
class ProfileEditor {
@form() profile!: Form;
@validate(required()) @field("profile") name = "";
@validate(min(0)) @field("profile") age = 0;
@action() @submit("profile") save(): void {}
render() { return <input field={this.name} />; }
}
"#,
);
let model = build_application_semantic_model(&parsed);
let form = FormId::for_owner(&model.components[0].id, "profile");
let plan = model.submissions.plan(&form).expect("submission plan");
assert_eq!(plan.id, SubmissionPlanId::for_form(&form));
assert_eq!(plan.validation_rules.len(), 2);
assert_eq!(
plan.action_batch,
model.components[0].id.action_batch("save")
);
assert!(matches!(plan.reset_policy, super::SubmitResetPolicy::Never));
}
#[test]
fn retains_invalid_submit_candidates_without_plan_or_winner() {
let parsed = presolve_parser::parse_file(
"src/Profile.tsx",
r#"
@component("profile-editor")
class ProfileEditor {
@form() profile!: Form;
@field(this.profile) name = "";
@submit(this.profile) missingAction(): void {}
@action() @submit(this.profile) duplicate(value: string): void {}
render() { return <input field={this.name} />; }
}
"#,
);
let model = build_application_semantic_model(&parsed);
assert_eq!(model.submissions.candidates.len(), 2);
assert!(model.submissions.plans.is_empty());
assert!(model
.submissions
.candidates
.iter()
.any(|candidate| candidate
.violations
.contains(&SubmissionDeclarationViolation::InvalidAction)));
assert!(model
.submissions
.candidates
.iter()
.any(|candidate| candidate
.violations
.contains(&SubmissionDeclarationViolation::ParameterizedMethod)));
}
}