/** * DSS Workflow State Machines * * State machine implementation for orchestrating multi-step workflows * with transition guards, side effects, and progress tracking. * * @module workflows */ import { notifySuccess, notifyError, notifyInfo, ErrorCode } from './messaging.js'; import router from './router.js'; /** * Base State Machine class */ class StateMachine { constructor(config) { this.config = config; this.currentState = config.initial; this.previousState = null; this.context = {}; this.history = []; this.listeners = new Map(); } /** * Get current state definition */ getCurrentStateDefinition() { return this.config.states[this.currentState]; } /** * Check if transition is allowed * @param {string} event - Event to transition on * @returns {string|null} Next state or null if not allowed */ canTransition(event) { const stateDefinition = this.getCurrentStateDefinition(); if (!stateDefinition || !stateDefinition.on) { return null; } return stateDefinition.on[event] || null; } /** * Send an event to trigger state transition * @param {string} event - Event name * @param {Object} [data] - Event data * @returns {Promise} Whether transition succeeded */ async send(event, data = {}) { const nextState = this.canTransition(event); if (!nextState) { console.warn(`No transition for event "${event}" in state "${this.currentState}"`); return false; } // Call exit actions for current state await this.callActions(this.getCurrentStateDefinition().exit, { event, data }); // Store previous state this.previousState = this.currentState; // Transition to next state this.currentState = nextState; // Record history this.history.push({ from: this.previousState, to: this.currentState, event, timestamp: new Date().toISOString(), data, }); // Call entry actions for new state await this.callActions(this.getCurrentStateDefinition().entry, { event, data }); // Emit state change this.emit('stateChange', { previous: this.previousState, current: this.currentState, event, data, }); return true; } /** * Call state actions * @param {string[]|undefined} actions - Action names to execute * @param {Object} context - Action context */ async callActions(actions, context) { if (!actions || !Array.isArray(actions)) { return; } for (const actionName of actions) { const action = this.config.actions?.[actionName]; if (action) { try { await action.call(this, { ...context, machine: this }); } catch (error) { console.error(`Action "${actionName}" failed:`, error); } } } } /** * Check if machine is in a specific state * @param {string} state - State name * @returns {boolean} Whether machine is in that state */ isIn(state) { return this.currentState === state; } /** * Check if machine is in final state * @returns {boolean} Whether machine is in final state */ isFinal() { const stateDefinition = this.getCurrentStateDefinition(); return stateDefinition?.type === 'final' || false; } /** * Subscribe to machine events * @param {string} event - Event name * @param {Function} handler - Event handler * @returns {Function} Unsubscribe function */ on(event, handler) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(handler); // Return unsubscribe function return () => { const handlers = this.listeners.get(event); const index = handlers.indexOf(handler); if (index > -1) { handlers.splice(index, 1); } }; } /** * Emit event to listeners * @param {string} event - Event name * @param {*} data - Event data */ emit(event, data) { const handlers = this.listeners.get(event) || []; handlers.forEach(handler => { try { handler(data); } catch (error) { console.error(`Event handler error for "${event}":`, error); } }); } /** * Get workflow progress * @returns {Object} Progress information */ getProgress() { const states = Object.keys(this.config.states); const currentIndex = states.indexOf(this.currentState); const total = states.length; return { current: currentIndex + 1, total, percentage: Math.round(((currentIndex + 1) / total) * 100), state: this.currentState, isComplete: this.isFinal(), }; } /** * Reset machine to initial state */ reset() { this.currentState = this.config.initial; this.previousState = null; this.context = {}; this.history = []; this.emit('reset', {}); } /** * Get state history * @returns {Array} State transition history */ getHistory() { return [...this.history]; } } /** * Create Project Workflow * * Guides user through: Create Project → Configure Settings → Extract Tokens → Success */ export class CreateProjectWorkflow extends StateMachine { constructor(options = {}) { const config = { initial: 'init', states: { init: { on: { CREATE_PROJECT: 'creating', }, entry: ['showCreateForm'], }, creating: { on: { PROJECT_CREATED: 'created', CREATE_FAILED: 'init', }, entry: ['createProject'], }, created: { on: { CONFIGURE_SETTINGS: 'configuring', SKIP_CONFIG: 'ready', }, entry: ['showSuccessMessage', 'promptConfiguration'], }, configuring: { on: { SETTINGS_SAVED: 'ready', CONFIG_CANCELLED: 'created', }, entry: ['navigateToSettings'], }, ready: { on: { EXTRACT_TOKENS: 'extracting', RECONFIGURE: 'configuring', }, entry: ['showReadyMessage'], }, extracting: { on: { EXTRACTION_SUCCESS: 'complete', EXTRACTION_FAILED: 'ready', }, entry: ['extractTokens'], }, complete: { type: 'final', entry: ['showCompletionMessage', 'navigateToTokens'], }, }, actions: { showCreateForm: async ({ machine }) => { notifyInfo('Create a new project to get started'); if (options.onShowCreateForm) { await options.onShowCreateForm(machine); } }, createProject: async ({ data, machine }) => { machine.context.projectName = data.projectName; machine.context.projectId = data.projectId; if (options.onCreateProject) { await options.onCreateProject(machine.context); } }, showSuccessMessage: async ({ machine }) => { notifySuccess( `Project "${machine.context.projectName}" created successfully!`, ErrorCode.SUCCESS_CREATED, { projectId: machine.context.projectId } ); }, promptConfiguration: async ({ machine }) => { notifyInfo( 'Next: Configure your project settings (Figma key, description)', { duration: 7000 } ); if (options.onPromptConfiguration) { await options.onPromptConfiguration(machine); } }, navigateToSettings: async ({ machine }) => { router.navigate('figma'); if (options.onNavigateToSettings) { await options.onNavigateToSettings(machine); } }, showReadyMessage: async ({ machine }) => { notifyInfo('Project configured! Ready to extract tokens from Figma'); if (options.onReady) { await options.onReady(machine); } }, extractTokens: async ({ machine }) => { if (options.onExtractTokens) { await options.onExtractTokens(machine); } }, showCompletionMessage: async ({ machine }) => { notifySuccess( 'Tokens extracted successfully! Your design system is ready.', ErrorCode.SUCCESS_OPERATION ); if (options.onComplete) { await options.onComplete(machine); } }, navigateToTokens: async ({ machine }) => { router.navigate('tokens'); }, }, }; super(config); // Store options this.options = options; // Subscribe to progress updates this.on('stateChange', ({ current, previous }) => { const progress = this.getProgress(); if (options.onProgress) { options.onProgress(current, progress); } // Emit to global event bus window.dispatchEvent(new CustomEvent('workflow-progress', { detail: { workflow: 'create-project', current, previous, progress, } })); }); } /** * Start the workflow * @param {Object} data - Initial data */ async start(data = {}) { this.reset(); this.context = { ...data }; await this.send('CREATE_PROJECT', data); } } /** * Token Extraction Workflow * * Guides through: Connect Figma → Select File → Extract → Sync */ export class TokenExtractionWorkflow extends StateMachine { constructor(options = {}) { const config = { initial: 'disconnected', states: { disconnected: { on: { CONNECT_FIGMA: 'connecting', }, entry: ['promptFigmaConnection'], }, connecting: { on: { CONNECTION_SUCCESS: 'connected', CONNECTION_FAILED: 'disconnected', }, entry: ['testFigmaConnection'], }, connected: { on: { SELECT_FILE: 'fileSelected', DISCONNECT: 'disconnected', }, entry: ['showFileSelector'], }, fileSelected: { on: { EXTRACT: 'extracting', CHANGE_FILE: 'connected', }, entry: ['showExtractButton'], }, extracting: { on: { EXTRACT_SUCCESS: 'extracted', EXTRACT_FAILED: 'fileSelected', }, entry: ['performExtraction'], }, extracted: { on: { SYNC: 'syncing', EXTRACT_AGAIN: 'extracting', }, entry: ['showSyncOption'], }, syncing: { on: { SYNC_SUCCESS: 'complete', SYNC_FAILED: 'extracted', }, entry: ['performSync'], }, complete: { type: 'final', entry: ['showSuccess'], }, }, actions: { promptFigmaConnection: async () => { notifyInfo('Connect to Figma to extract design tokens'); }, testFigmaConnection: async ({ data, machine }) => { if (options.onTestConnection) { await options.onTestConnection(data); } }, showFileSelector: async () => { notifySuccess('Connected to Figma! Select a file to extract tokens.'); }, showExtractButton: async ({ data }) => { notifyInfo(`File selected: ${data.fileName || 'Unknown'}. Ready to extract.`); }, performExtraction: async ({ data, machine }) => { if (options.onExtract) { await options.onExtract(data); } }, showSyncOption: async () => { notifySuccess('Tokens extracted! Sync to your codebase?'); }, performSync: async ({ data }) => { if (options.onSync) { await options.onSync(data); } }, showSuccess: async () => { notifySuccess('Workflow complete! Tokens are synced and ready to use.'); }, }, }; super(config); this.options = options; } async start(data = {}) { this.reset(); await this.send('CONNECT_FIGMA', data); } } /** * Create workflow instances */ export function createProjectWorkflow(options) { return new CreateProjectWorkflow(options); } export function tokenExtractionWorkflow(options) { return new TokenExtractionWorkflow(options); } export { StateMachine }; export default { StateMachine, CreateProjectWorkflow, TokenExtractionWorkflow, createProjectWorkflow, tokenExtractionWorkflow, };