mirror of
https://github.com/Nikhil-Doye/workflow-builder.git
synced 2026-07-22 02:01:56 +02:00
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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user