fission-onboarding 0.1.0

Guided in-app tours and spotlight onboarding for Fission applications
Documentation
use std::collections::HashSet;

use fission::prelude::{TextContent, WidgetId};
use thiserror::Error;

#[derive(Clone, Debug, PartialEq)]
pub struct OnboardingStep {
    pub id: String,
    pub anchor: WidgetId,
    pub route: Option<String>,
    pub title: TextContent,
    pub body: TextContent,
}

#[derive(Clone, Debug, PartialEq)]
pub struct OnboardingFlow {
    id: String,
    version: u32,
    steps: Vec<OnboardingStep>,
}

#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum FlowValidationError {
    #[error("onboarding flow id cannot be empty")]
    EmptyFlowId,
    #[error("onboarding flow version must be at least one")]
    InvalidVersion,
    #[error("onboarding flow must contain at least one step")]
    EmptySteps,
    #[error("onboarding step id cannot be empty")]
    EmptyStepId,
    #[error("onboarding step id is duplicated: {0}")]
    DuplicateStepId(String),
}

impl OnboardingFlow {
    pub fn new(
        id: impl Into<String>,
        version: u32,
        steps: Vec<OnboardingStep>,
    ) -> Result<Self, FlowValidationError> {
        let id = id.into();
        if id.trim().is_empty() {
            return Err(FlowValidationError::EmptyFlowId);
        }
        if version == 0 {
            return Err(FlowValidationError::InvalidVersion);
        }
        if steps.is_empty() {
            return Err(FlowValidationError::EmptySteps);
        }

        let mut ids = HashSet::with_capacity(steps.len());
        for step in &steps {
            if step.id.trim().is_empty() {
                return Err(FlowValidationError::EmptyStepId);
            }
            if !ids.insert(step.id.clone()) {
                return Err(FlowValidationError::DuplicateStepId(step.id.clone()));
            }
        }

        Ok(Self { id, version, steps })
    }

    pub fn id(&self) -> &str {
        &self.id
    }

    pub fn version(&self) -> u32 {
        self.version
    }

    pub fn steps(&self) -> &[OnboardingStep] {
        &self.steps
    }

    pub fn step(&self, index: usize) -> Option<&OnboardingStep> {
        self.steps.get(index)
    }

    pub fn step_index(&self, step_id: &str) -> Option<usize> {
        self.steps.iter().position(|step| step.id == step_id)
    }

    pub fn is_last_step(&self, index: usize) -> bool {
        index + 1 == self.steps.len()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn step(id: &str) -> OnboardingStep {
        OnboardingStep {
            id: id.into(),
            anchor: WidgetId::explicit(id),
            route: None,
            title: TextContent::Key(format!("onboarding.{id}.title")),
            body: TextContent::Key(format!("onboarding.{id}.body")),
        }
    }

    #[test]
    fn validates_and_indexes_versioned_flow() {
        let flow = OnboardingFlow::new("first-task", 2, vec![step("ready"), step("create")])
            .expect("valid flow");

        assert_eq!(flow.id(), "first-task");
        assert_eq!(flow.version(), 2);
        assert_eq!(flow.step_index("create"), Some(1));
        assert!(!flow.is_last_step(0));
        assert!(flow.is_last_step(1));
    }

    #[test]
    fn rejects_duplicate_step_ids() {
        assert_eq!(
            OnboardingFlow::new("first-task", 1, vec![step("create"), step("create")]),
            Err(FlowValidationError::DuplicateStepId("create".into()))
        );
    }
}