fission_onboarding/
flow.rs1use 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}