Skip to main content

fission_onboarding/
flow.rs

1use std::collections::HashSet;
2
3use fission::prelude::{TextContent, WidgetId};
4use thiserror::Error;
5
6#[derive(Clone, Debug, PartialEq)]
7pub struct OnboardingStep {
8    pub id: String,
9    pub anchor: WidgetId,
10    pub route: Option<String>,
11    pub title: TextContent,
12    pub body: TextContent,
13}
14
15#[derive(Clone, Debug, PartialEq)]
16pub struct OnboardingFlow {
17    id: String,
18    version: u32,
19    steps: Vec<OnboardingStep>,
20}
21
22#[derive(Clone, Debug, Error, PartialEq, Eq)]
23pub enum FlowValidationError {
24    #[error("onboarding flow id cannot be empty")]
25    EmptyFlowId,
26    #[error("onboarding flow version must be at least one")]
27    InvalidVersion,
28    #[error("onboarding flow must contain at least one step")]
29    EmptySteps,
30    #[error("onboarding step id cannot be empty")]
31    EmptyStepId,
32    #[error("onboarding step id is duplicated: {0}")]
33    DuplicateStepId(String),
34}
35
36impl OnboardingFlow {
37    pub fn new(
38        id: impl Into<String>,
39        version: u32,
40        steps: Vec<OnboardingStep>,
41    ) -> Result<Self, FlowValidationError> {
42        let id = id.into();
43        if id.trim().is_empty() {
44            return Err(FlowValidationError::EmptyFlowId);
45        }
46        if version == 0 {
47            return Err(FlowValidationError::InvalidVersion);
48        }
49        if steps.is_empty() {
50            return Err(FlowValidationError::EmptySteps);
51        }
52
53        let mut ids = HashSet::with_capacity(steps.len());
54        for step in &steps {
55            if step.id.trim().is_empty() {
56                return Err(FlowValidationError::EmptyStepId);
57            }
58            if !ids.insert(step.id.clone()) {
59                return Err(FlowValidationError::DuplicateStepId(step.id.clone()));
60            }
61        }
62
63        Ok(Self { id, version, steps })
64    }
65
66    pub fn id(&self) -> &str {
67        &self.id
68    }
69
70    pub fn version(&self) -> u32 {
71        self.version
72    }
73
74    pub fn steps(&self) -> &[OnboardingStep] {
75        &self.steps
76    }
77
78    pub fn step(&self, index: usize) -> Option<&OnboardingStep> {
79        self.steps.get(index)
80    }
81
82    pub fn step_index(&self, step_id: &str) -> Option<usize> {
83        self.steps.iter().position(|step| step.id == step_id)
84    }
85
86    pub fn is_last_step(&self, index: usize) -> bool {
87        index + 1 == self.steps.len()
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn step(id: &str) -> OnboardingStep {
96        OnboardingStep {
97            id: id.into(),
98            anchor: WidgetId::explicit(id),
99            route: None,
100            title: TextContent::Key(format!("onboarding.{id}.title")),
101            body: TextContent::Key(format!("onboarding.{id}.body")),
102        }
103    }
104
105    #[test]
106    fn validates_and_indexes_versioned_flow() {
107        let flow = OnboardingFlow::new("first-task", 2, vec![step("ready"), step("create")])
108            .expect("valid flow");
109
110        assert_eq!(flow.id(), "first-task");
111        assert_eq!(flow.version(), 2);
112        assert_eq!(flow.step_index("create"), Some(1));
113        assert!(!flow.is_last_step(0));
114        assert!(flow.is_last_step(1));
115    }
116
117    #[test]
118    fn rejects_duplicate_step_ids() {
119        assert_eq!(
120            OnboardingFlow::new("first-task", 1, vec![step("create"), step("create")]),
121            Err(FlowValidationError::DuplicateStepId("create".into()))
122        );
123    }
124}