Add onboarding system with comprehensive management and UI components

- Introduced OnboardingManager utility for managing onboarding preferences and state, including localStorage integration for persistence.
- Implemented OnboardingDemo component to showcase onboarding status and actions, enhancing user interaction with onboarding features.
- Developed OnboardingModal component for guided onboarding experience, allowing users to navigate through steps and manage preferences.
- Enhanced WorkflowList and WorkflowToolbar components to conditionally display onboarding based on user preferences and workflow count.
- Added unit tests for OnboardingManager to ensure reliable functionality and error handling in onboarding processes.
This commit is contained in:
Nikhil-Doye
2025-10-20 17:23:49 -04:00
parent a5dd6cd14e
commit 205add853f
6 changed files with 959 additions and 16 deletions
@@ -0,0 +1,315 @@
import { OnboardingManager } from "../../utils/onboardingManager";
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
describe("OnboardingManager", () => {
beforeEach(() => {
localStorageMock.clear();
});
describe("getPreferences", () => {
it("should return default preferences when no stored data", () => {
const prefs = OnboardingManager.getPreferences();
expect(prefs).toEqual({
hasSeenOnboarding: false,
hasOptedOut: false,
lastShownVersion: "0.0.0",
showOnboardingOnStartup: true,
completedSteps: [],
});
});
it("should return stored preferences when available", () => {
const storedPrefs = {
hasSeenOnboarding: true,
hasOptedOut: false,
lastShownVersion: "1.0.0",
showOnboardingOnStartup: false,
completedSteps: [1, 2, 3],
};
localStorageMock.setItem(
"onboardingPreferences",
JSON.stringify(storedPrefs)
);
const prefs = OnboardingManager.getPreferences();
expect(prefs).toEqual(storedPrefs);
});
it("should handle corrupted localStorage data gracefully", () => {
localStorageMock.setItem("onboardingPreferences", "invalid json");
const prefs = OnboardingManager.getPreferences();
expect(prefs).toEqual({
hasSeenOnboarding: false,
hasOptedOut: false,
lastShownVersion: "0.0.0",
showOnboardingOnStartup: true,
completedSteps: [],
});
});
});
describe("savePreferences", () => {
it("should save preferences to localStorage", () => {
const prefs = {
hasSeenOnboarding: true,
hasOptedOut: false,
lastShownVersion: "1.0.0",
showOnboardingOnStartup: true,
completedSteps: [1, 2],
};
OnboardingManager.savePreferences(prefs);
const stored = JSON.parse(
localStorageMock.getItem("onboardingPreferences") || "{}"
);
expect(stored).toEqual(prefs);
});
it("should merge with existing preferences", () => {
// Set initial preferences
const initialPrefs = {
hasSeenOnboarding: false,
hasOptedOut: false,
lastShownVersion: "0.0.0",
showOnboardingOnStartup: true,
completedSteps: [],
};
localStorageMock.setItem(
"onboardingPreferences",
JSON.stringify(initialPrefs)
);
// Update only some fields
OnboardingManager.savePreferences({
hasSeenOnboarding: true,
completedSteps: [1, 2],
});
const stored = JSON.parse(
localStorageMock.getItem("onboardingPreferences") || "{}"
);
expect(stored).toEqual({
hasSeenOnboarding: true,
hasOptedOut: false,
lastShownVersion: "0.0.0",
showOnboardingOnStartup: true,
completedSteps: [1, 2],
});
});
});
describe("shouldShowOnboarding", () => {
it("should show onboarding for first-time users with no workflows", () => {
const shouldShow = OnboardingManager.shouldShowOnboarding(0);
expect(shouldShow).toBe(true);
});
it("should not show onboarding if user has opted out", () => {
OnboardingManager.optOutOfOnboarding();
const shouldShow = OnboardingManager.shouldShowOnboarding(0);
expect(shouldShow).toBe(false);
});
it("should not show onboarding if user has workflows", () => {
const shouldShow = OnboardingManager.shouldShowOnboarding(1);
expect(shouldShow).toBe(false);
});
it("should show onboarding if user wants to see it on startup", () => {
OnboardingManager.enableOnboardingOnStartup();
const shouldShow = OnboardingManager.shouldShowOnboarding(0);
expect(shouldShow).toBe(true);
});
it("should not show onboarding if user disabled startup option", () => {
OnboardingManager.markOnboardingCompleted();
OnboardingManager.disableOnboardingOnStartup();
const shouldShow = OnboardingManager.shouldShowOnboarding(0);
expect(shouldShow).toBe(false);
});
});
describe("markOnboardingCompleted", () => {
it("should mark onboarding as completed", () => {
OnboardingManager.markOnboardingCompleted([1, 2, 3]);
const prefs = OnboardingManager.getPreferences();
expect(prefs.hasSeenOnboarding).toBe(true);
expect(prefs.hasOptedOut).toBe(false);
expect(prefs.lastShownVersion).toBe("1.0.0");
expect(prefs.completedSteps).toEqual([1, 2, 3]);
});
});
describe("optOutOfOnboarding", () => {
it("should opt out of onboarding", () => {
OnboardingManager.optOutOfOnboarding();
const prefs = OnboardingManager.getPreferences();
expect(prefs.hasOptedOut).toBe(true);
expect(prefs.hasSeenOnboarding).toBe(true);
});
});
describe("resetOnboarding", () => {
it("should reset all onboarding preferences", () => {
// Set some preferences first
OnboardingManager.markOnboardingCompleted([1, 2, 3]);
OnboardingManager.disableOnboardingOnStartup();
// Reset
OnboardingManager.resetOnboarding();
const prefs = OnboardingManager.getPreferences();
expect(prefs).toEqual({
hasSeenOnboarding: false,
hasOptedOut: false,
lastShownVersion: "0.0.0",
showOnboardingOnStartup: true,
completedSteps: [],
});
});
});
describe("enableOnboardingOnStartup", () => {
it("should enable onboarding on startup", () => {
OnboardingManager.disableOnboardingOnStartup();
OnboardingManager.enableOnboardingOnStartup();
const prefs = OnboardingManager.getPreferences();
expect(prefs.showOnboardingOnStartup).toBe(true);
expect(prefs.hasOptedOut).toBe(false);
});
});
describe("disableOnboardingOnStartup", () => {
it("should disable onboarding on startup", () => {
OnboardingManager.disableOnboardingOnStartup();
const prefs = OnboardingManager.getPreferences();
expect(prefs.showOnboardingOnStartup).toBe(false);
});
});
describe("isNewVersionAvailable", () => {
it("should detect new version when last shown version is different", () => {
OnboardingManager.savePreferences({ lastShownVersion: "0.9.0" });
const isNew = OnboardingManager.isNewVersionAvailable();
expect(isNew).toBe(true);
});
it("should not detect new version when versions match", () => {
OnboardingManager.savePreferences({ lastShownVersion: "1.0.0" });
const isNew = OnboardingManager.isNewVersionAvailable();
expect(isNew).toBe(false);
});
});
describe("getProgressPercentage", () => {
it("should calculate progress percentage correctly", () => {
OnboardingManager.savePreferences({ completedSteps: [1, 2, 3] });
const progress = OnboardingManager.getProgressPercentage();
expect(progress).toBe(50); // 3 out of 6 steps = 50%
});
it("should return 0 when no steps completed", () => {
OnboardingManager.savePreferences({ completedSteps: [] });
const progress = OnboardingManager.getProgressPercentage();
expect(progress).toBe(0);
});
it("should return 100 when all steps completed", () => {
OnboardingManager.savePreferences({ completedSteps: [1, 2, 3, 4, 5, 6] });
const progress = OnboardingManager.getProgressPercentage();
expect(progress).toBe(100);
});
});
describe("hasCompletedStep", () => {
it("should return true for completed steps", () => {
OnboardingManager.savePreferences({ completedSteps: [1, 3, 5] });
expect(OnboardingManager.hasCompletedStep(1)).toBe(true);
expect(OnboardingManager.hasCompletedStep(3)).toBe(true);
expect(OnboardingManager.hasCompletedStep(5)).toBe(true);
});
it("should return false for incomplete steps", () => {
OnboardingManager.savePreferences({ completedSteps: [1, 3, 5] });
expect(OnboardingManager.hasCompletedStep(2)).toBe(false);
expect(OnboardingManager.hasCompletedStep(4)).toBe(false);
expect(OnboardingManager.hasCompletedStep(6)).toBe(false);
});
});
describe("markStepCompleted", () => {
it("should mark a step as completed", () => {
OnboardingManager.savePreferences({ completedSteps: [1, 2] });
OnboardingManager.markStepCompleted(3);
const prefs = OnboardingManager.getPreferences();
expect(prefs.completedSteps).toEqual([1, 2, 3]);
});
it("should not duplicate completed steps", () => {
OnboardingManager.savePreferences({ completedSteps: [1, 2, 3] });
OnboardingManager.markStepCompleted(2);
const prefs = OnboardingManager.getPreferences();
expect(prefs.completedSteps).toEqual([1, 2, 3]);
});
});
describe("getStatus", () => {
it("should return comprehensive status information", () => {
OnboardingManager.savePreferences({
hasSeenOnboarding: true,
hasOptedOut: false,
lastShownVersion: "0.9.0",
showOnboardingOnStartup: true,
completedSteps: [1, 2, 3],
});
const status = OnboardingManager.getStatus();
expect(status).toEqual({
hasSeenOnboarding: true,
hasOptedOut: false,
isNewVersionAvailable: true,
progressPercentage: 50,
showOnStartup: true,
});
});
});
});
+239
View File
@@ -0,0 +1,239 @@
import React, { useState, useEffect } from "react";
import { OnboardingManager } from "../utils/onboardingManager";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import {
CheckCircle,
XCircle,
RotateCcw,
Eye,
EyeOff,
Settings,
HelpCircle,
AlertCircle,
} from "lucide-react";
export const OnboardingDemo: React.FC = () => {
const [status, setStatus] = useState(OnboardingManager.getStatus());
const [showOnboarding, setShowOnboarding] = useState(false);
useEffect(() => {
// Update status when component mounts
setStatus(OnboardingManager.getStatus());
}, []);
const handleReset = () => {
OnboardingManager.resetOnboarding();
setStatus(OnboardingManager.getStatus());
};
const handleToggleStartup = () => {
if (status.showOnStartup) {
OnboardingManager.disableOnboardingOnStartup();
} else {
OnboardingManager.enableOnboardingOnStartup();
}
setStatus(OnboardingManager.getStatus());
};
const handleOptOut = () => {
OnboardingManager.optOutOfOnboarding();
setStatus(OnboardingManager.getStatus());
};
const handleMarkCompleted = () => {
OnboardingManager.markOnboardingCompleted([1, 2, 3, 4, 5, 6]);
setStatus(OnboardingManager.getStatus());
};
const handleShowOnboarding = () => {
setShowOnboarding(true);
};
const handleOnboardingClose = () => {
setShowOnboarding(false);
setStatus(OnboardingManager.getStatus());
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HelpCircle className="w-5 h-5" />
Onboarding System Demo
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Status Overview */}
<div className="space-y-3">
<h3 className="font-semibold text-gray-900">Current Status</h3>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
Has seen onboarding:
</span>
<Badge
variant={status.hasSeenOnboarding ? "success" : "secondary"}
>
{status.hasSeenOnboarding ? "Yes" : "No"}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Has opted out:</span>
<Badge
variant={status.hasOptedOut ? "destructive" : "secondary"}
>
{status.hasOptedOut ? "Yes" : "No"}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
Show on startup:
</span>
<Badge
variant={status.showOnStartup ? "success" : "secondary"}
>
{status.showOnStartup ? "Yes" : "No"}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
New version available:
</span>
<Badge
variant={
status.isNewVersionAvailable ? "destructive" : "secondary"
}
>
{status.isNewVersionAvailable ? "Yes" : "No"}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Progress:</span>
<Badge variant="info">{status.progressPercentage}%</Badge>
</div>
</div>
</div>
{/* Actions */}
<div className="space-y-3">
<h3 className="font-semibold text-gray-900">Actions</h3>
<div className="space-y-2">
<Button
onClick={handleShowOnboarding}
className="w-full flex items-center gap-2"
variant="default"
>
<HelpCircle className="w-4 h-4" />
Show Tutorial
</Button>
<Button
onClick={handleToggleStartup}
className="w-full flex items-center gap-2"
variant="outline"
>
{status.showOnStartup ? (
<>
<EyeOff className="w-4 h-4" />
Disable Startup
</>
) : (
<>
<Eye className="w-4 h-4" />
Enable Startup
</>
)}
</Button>
<Button
onClick={handleMarkCompleted}
className="w-full flex items-center gap-2"
variant="outline"
>
<CheckCircle className="w-4 h-4" />
Mark as Completed
</Button>
<Button
onClick={handleOptOut}
className="w-full flex items-center gap-2"
variant="outline"
>
<XCircle className="w-4 h-4" />
Opt Out
</Button>
<Button
onClick={handleReset}
className="w-full flex items-center gap-2"
variant="destructive"
>
<RotateCcw className="w-4 h-4" />
Reset All
</Button>
</div>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
Tutorial Progress
</span>
<span className="text-sm text-gray-500">
{status.progressPercentage}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-gradient-to-r from-blue-500 to-indigo-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${status.progressPercentage}%` }}
/>
</div>
</div>
{/* Feature Highlights */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Enhanced Features
</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> Persistent opt-out status with localStorage</li>
<li> Progress tracking across sessions</li>
<li> Version detection for new tutorial content</li>
<li> Settings panel with startup preferences</li>
<li> Easy re-access from navigation</li>
<li> Step-by-step completion tracking</li>
</ul>
</div>
</CardContent>
</Card>
{/* Onboarding Modal would be rendered here in a real app */}
{showOnboarding && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card className="max-w-md w-full mx-4">
<CardHeader>
<CardTitle>Onboarding Modal</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-4">
This would show the actual OnboardingModal component with all
its enhanced features.
</p>
<Button onClick={handleOnboardingClose} className="w-full">
Close Demo
</Button>
</CardContent>
</Card>
</div>
)}
</div>
);
};
+138 -4
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import {
ArrowRight,
ArrowLeft,
@@ -9,6 +9,10 @@ import {
ArrowDownToLine,
Key,
Settings,
X,
RotateCcw,
Eye,
EyeOff,
} from "lucide-react";
import {
Dialog,
@@ -19,10 +23,12 @@ import {
DialogFooter,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { OnboardingManager } from "../utils/onboardingManager";
interface OnboardingModalProps {
isOpen: boolean;
onClose: () => void;
onComplete?: () => void;
}
const steps = [
@@ -208,17 +214,39 @@ const steps = [
export const OnboardingModal: React.FC<OnboardingModalProps> = ({
isOpen,
onClose,
onComplete,
}) => {
const [currentStep, setCurrentStep] = useState(0);
const [showSettings, setShowSettings] = useState(false);
const [preferences, setPreferences] = useState(
OnboardingManager.getPreferences()
);
useEffect(() => {
if (isOpen) {
setPreferences(OnboardingManager.getPreferences());
// Start from the first incomplete step
const firstIncompleteStep = steps.findIndex(
(step) => !OnboardingManager.hasCompletedStep(step.id)
);
setCurrentStep(Math.max(0, firstIncompleteStep));
}
}, [isOpen]);
if (!isOpen) return null;
const currentStepData = steps[currentStep];
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === steps.length - 1;
const progress = ((currentStep + 1) / steps.length) * 100;
const handleNext = () => {
// Mark current step as completed
OnboardingManager.markStepCompleted(currentStepData.id);
if (isLastStep) {
OnboardingManager.markOnboardingCompleted();
onComplete?.();
onClose();
} else {
setCurrentStep(currentStep + 1);
@@ -232,28 +260,64 @@ export const OnboardingModal: React.FC<OnboardingModalProps> = ({
};
const handleSkip = () => {
OnboardingManager.optOutOfOnboarding();
onClose();
};
const handleReset = () => {
OnboardingManager.resetOnboarding();
setCurrentStep(0);
setPreferences(OnboardingManager.getPreferences());
};
const handleToggleStartup = () => {
const newShowOnStartup = !preferences.showOnboardingOnStartup;
if (newShowOnStartup) {
OnboardingManager.enableOnboardingOnStartup();
} else {
OnboardingManager.disableOnboardingOnStartup();
}
setPreferences(OnboardingManager.getPreferences());
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md max-h-[90vh] overflow-hidden p-0">
{/* Header */}
<DialogHeader className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<currentStepData.icon className="w-5 h-5 text-white" />
</div>
<div>
<DialogTitle className="text-lg font-semibold text-gray-900">
<div className="flex-1">
<DialogTitle className="text-lg font-semibold text-gray-900 flex items-center gap-2">
{currentStepData.title}
{OnboardingManager.hasCompletedStep(currentStepData.id) && (
<Check className="w-4 h-4 text-green-500" />
)}
</DialogTitle>
<DialogDescription className="text-sm text-gray-600">
{currentStepData.description}
</DialogDescription>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title="Settings"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Progress Bar */}
@@ -284,6 +348,76 @@ export const OnboardingModal: React.FC<OnboardingModalProps> = ({
{currentStepData.content}
</div>
{/* Settings Panel */}
{showSettings && (
<div className="p-6 border-t border-gray-200 bg-gray-50">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Onboarding Settings
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700">
Show onboarding on startup
</label>
<p className="text-xs text-gray-500">
Display this tutorial when you have no workflows
</p>
</div>
<button
onClick={handleToggleStartup}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
preferences.showOnboardingOnStartup
? "bg-blue-600"
: "bg-gray-200"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
preferences.showOnboardingOnStartup
? "translate-x-6"
: "translate-x-1"
}`}
/>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700">
Reset tutorial
</label>
<p className="text-xs text-gray-500">
Start the tutorial from the beginning
</p>
</div>
<Button
onClick={handleReset}
variant="outline"
size="sm"
className="flex items-center space-x-2"
>
<RotateCcw className="w-4 h-4" />
<span>Reset</span>
</Button>
</div>
<div className="pt-2 border-t border-gray-200">
<div className="text-xs text-gray-500">
<p>
Progress: {OnboardingManager.getProgressPercentage()}%
complete
</p>
<p>
Completed steps: {preferences.completedSteps.length} of{" "}
{steps.length}
</p>
</div>
</div>
</div>
</div>
)}
{/* Footer */}
<DialogFooter className="p-6 border-t border-gray-200 bg-gray-50">
<div className="flex items-center justify-between w-full">
+46 -12
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { useWorkflowStore } from "../store/workflowStore";
import { OnboardingModal } from "./OnboardingModal";
import { OnboardingManager } from "../utils/onboardingManager";
import {
Plus,
Play,
@@ -36,10 +37,9 @@ export const WorkflowList: React.FC<WorkflowListProps> = ({
const [editingWorkflowName, setEditingWorkflowName] = useState("");
const [showOnboarding, setShowOnboarding] = useState(false);
// Show onboarding for first-time users
// Show onboarding based on preferences and workflow count
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
if (!hasSeenOnboarding && workflows.length === 0) {
if (OnboardingManager.shouldShowOnboarding(workflows.length)) {
setShowOnboarding(true);
}
}, [workflows.length]);
@@ -91,7 +91,11 @@ export const WorkflowList: React.FC<WorkflowListProps> = ({
const handleOnboardingClose = () => {
setShowOnboarding(false);
localStorage.setItem("hasSeenOnboarding", "true");
};
const handleOnboardingComplete = () => {
setShowOnboarding(false);
// OnboardingManager.markOnboardingCompleted() is called in the modal
};
const handleShowOnboarding = () => {
@@ -158,14 +162,43 @@ export const WorkflowList: React.FC<WorkflowListProps> = ({
</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleShowOnboarding}
className="flex items-center space-x-2 px-4 py-3 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded-lg transition-all duration-200"
title="Show tutorial"
>
<HelpCircle className="w-4 h-4" />
<span className="font-medium hidden sm:inline">Help</span>
</button>
<div className="relative group">
<button
onClick={handleShowOnboarding}
className="flex items-center space-x-2 px-4 py-3 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded-lg transition-all duration-200"
title="Show tutorial"
>
<HelpCircle className="w-4 h-4" />
<span className="font-medium hidden sm:inline">Tutorial</span>
{OnboardingManager.getStatus().isNewVersionAvailable && (
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
</button>
{/* Tooltip with onboarding status */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
<div className="text-center">
<div className="font-medium">Tutorial Status</div>
<div className="text-xs text-gray-300 mt-1">
{OnboardingManager.getStatus().hasSeenOnboarding ? (
<>
Progress:{" "}
{OnboardingManager.getStatus().progressPercentage}%
{OnboardingManager.getStatus()
.isNewVersionAvailable && (
<div className="text-blue-300">
New version available!
</div>
)}
</>
) : (
"Not started yet"
)}
</div>
</div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900"></div>
</div>
</div>
<button
onClick={() => setShowCreateForm(true)}
className="group flex items-center space-x-2 px-6 py-3 bg-white text-gray-700 rounded-lg hover:bg-gray-50 transition-all duration-200 shadow-sm hover:shadow-md border border-gray-200"
@@ -416,6 +449,7 @@ export const WorkflowList: React.FC<WorkflowListProps> = ({
<OnboardingModal
isOpen={showOnboarding}
onClose={handleOnboardingClose}
onComplete={handleOnboardingComplete}
/>
</div>
);
+26
View File
@@ -13,6 +13,7 @@ import {
Clock,
Activity,
GitBranch,
HelpCircle,
} from "lucide-react";
import {
downloadWorkflow,
@@ -21,6 +22,8 @@ import {
import { ApiKeysSettings } from "./ApiKeysSettings";
import { DatabaseConnectionManager } from "./DatabaseConnectionManager";
import { WorkflowScheduler } from "./WorkflowScheduler";
import { OnboardingModal } from "./OnboardingModal";
import { OnboardingManager } from "../utils/onboardingManager";
import { Button } from "./ui/button";
export const WorkflowToolbar: React.FC = () => {
@@ -39,6 +42,7 @@ export const WorkflowToolbar: React.FC = () => {
const [showDatabaseManager, setShowDatabaseManager] = useState(false);
const [showScheduler, setShowScheduler] = useState(false);
const [showSaveSuccess, setShowSaveSuccess] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const handleSave = () => {
if (currentWorkflow) {
@@ -158,6 +162,19 @@ export const WorkflowToolbar: React.FC = () => {
Schedule
</Button>
<Button
onClick={() => setShowOnboarding(true)}
variant="outline"
title="Show Tutorial"
className="relative"
>
<HelpCircle className="w-4 h-4 mr-2" />
Tutorial
{OnboardingManager.getStatus().isNewVersionAvailable && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 rounded-full"></div>
)}
</Button>
<div className="w-px h-6 bg-gray-300" />
{/* Execution Mode Selector */}
@@ -262,6 +279,15 @@ export const WorkflowToolbar: React.FC = () => {
workflowId={currentWorkflow?.id}
/>
)}
{/* Onboarding Modal */}
{showOnboarding && (
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={() => setShowOnboarding(false)}
/>
)}
</div>
);
};
+195
View File
@@ -0,0 +1,195 @@
export interface OnboardingPreferences {
hasSeenOnboarding: boolean;
hasOptedOut: boolean;
lastShownVersion: string;
showOnboardingOnStartup: boolean;
completedSteps: number[];
}
const ONBOARDING_VERSION = "1.0.0";
const STORAGE_KEY = "onboardingPreferences";
export class OnboardingManager {
/**
* Get current onboarding preferences from localStorage
*/
static getPreferences(): OnboardingPreferences {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return {
hasSeenOnboarding: parsed.hasSeenOnboarding || false,
hasOptedOut: parsed.hasOptedOut || false,
lastShownVersion: parsed.lastShownVersion || "0.0.0",
showOnboardingOnStartup: parsed.showOnboardingOnStartup !== false, // Default to true
completedSteps: parsed.completedSteps || [],
};
}
} catch (error) {
console.warn("Failed to parse onboarding preferences:", error);
}
// Return default preferences
return {
hasSeenOnboarding: false,
hasOptedOut: false,
lastShownVersion: "0.0.0",
showOnboardingOnStartup: true,
completedSteps: [],
};
}
/**
* Save onboarding preferences to localStorage
*/
static savePreferences(preferences: Partial<OnboardingPreferences>): void {
try {
const current = this.getPreferences();
const updated = { ...current, ...preferences };
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch (error) {
console.warn("Failed to save onboarding preferences:", error);
}
}
/**
* Check if onboarding should be shown
*/
static shouldShowOnboarding(workflowCount: number = 0): boolean {
const prefs = this.getPreferences();
// Don't show if user has opted out
if (prefs.hasOptedOut) {
return false;
}
// Show if user hasn't seen onboarding and has no workflows
if (!prefs.hasSeenOnboarding && workflowCount === 0) {
return true;
}
// Show if user wants to see onboarding on startup
if (prefs.showOnboardingOnStartup && workflowCount === 0) {
return true;
}
// Show if there's a new version of onboarding
if (this.isNewVersionAvailable()) {
return true;
}
return false;
}
/**
* Mark onboarding as completed
*/
static markOnboardingCompleted(completedSteps: number[] = []): void {
this.savePreferences({
hasSeenOnboarding: true,
hasOptedOut: false,
lastShownVersion: ONBOARDING_VERSION,
completedSteps,
});
}
/**
* Opt out of onboarding
*/
static optOutOfOnboarding(): void {
this.savePreferences({
hasOptedOut: true,
hasSeenOnboarding: true,
});
}
/**
* Reset onboarding preferences (for testing or user request)
*/
static resetOnboarding(): void {
this.savePreferences({
hasSeenOnboarding: false,
hasOptedOut: false,
lastShownVersion: "0.0.0",
showOnboardingOnStartup: true,
completedSteps: [],
});
}
/**
* Enable onboarding on startup
*/
static enableOnboardingOnStartup(): void {
this.savePreferences({
showOnboardingOnStartup: true,
hasOptedOut: false,
});
}
/**
* Disable onboarding on startup
*/
static disableOnboardingOnStartup(): void {
this.savePreferences({
showOnboardingOnStartup: false,
});
}
/**
* Check if a new version of onboarding is available
*/
static isNewVersionAvailable(): boolean {
const prefs = this.getPreferences();
return prefs.lastShownVersion !== ONBOARDING_VERSION;
}
/**
* Get onboarding progress percentage
*/
static getProgressPercentage(): number {
const prefs = this.getPreferences();
const totalSteps = 6; // Assuming 6 steps in onboarding
return Math.round((prefs.completedSteps.length / totalSteps) * 100);
}
/**
* Check if user has completed a specific step
*/
static hasCompletedStep(stepId: number): boolean {
const prefs = this.getPreferences();
return prefs.completedSteps.includes(stepId);
}
/**
* Mark a specific step as completed
*/
static markStepCompleted(stepId: number): void {
const prefs = this.getPreferences();
if (!prefs.completedSteps.includes(stepId)) {
this.savePreferences({
completedSteps: [...prefs.completedSteps, stepId],
});
}
}
/**
* Get onboarding status for display
*/
static getStatus(): {
hasSeenOnboarding: boolean;
hasOptedOut: boolean;
isNewVersionAvailable: boolean;
progressPercentage: number;
showOnStartup: boolean;
} {
const prefs = this.getPreferences();
return {
hasSeenOnboarding: prefs.hasSeenOnboarding,
hasOptedOut: prefs.hasOptedOut,
isNewVersionAvailable: this.isNewVersionAvailable(),
progressPercentage: this.getProgressPercentage(),
showOnStartup: prefs.showOnboardingOnStartup,
};
}
}