fix: New tutorials (#7427)

* Put back banner a new tutorial is available for a user that completed all his tutorials and never skipped all

* Create onboarding tutorial for operators in tutorial config file

* Add router and steps for onboarding tutorial for operators

* Improve onboarding tutorial for operators

* Improve the tutorial UX

* Refactor

* Remove cursor from last step of operator onboarding tutorial

* Improve filtering per role

* Add Runs page tutorial

* Improve Runs page tutorial

* Add failed run

* Simplify Runs tutorial with job clicks into one unique step

* Finish overall structure of Runs tutorial

* Improve wordings

* Prevent breaking animations by clicking on Next or Previous

* Add success and failure logo to step title

* Improve wording

* Create util function for moving cursor

* Nits

* Improve wordings

* Differentiate successfull and failed jobs steps

* Remove delete flows if operator to prevent permission errors

* Add comment
This commit is contained in:
Tristan TR
2025-12-23 19:19:30 +01:00
committed by GitHub
parent 815aadc679
commit e96da54001
15 changed files with 1479 additions and 92 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import WorkspaceOnboardingTutorial from './tutorials/workspace/WorkspaceOnboardingTutorial.svelte'
import WorkspaceOnboardingOperatorTutorial from './tutorials/workspace/WorkspaceOnboardingOperatorTutorial.svelte'
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
@@ -15,6 +16,10 @@
{
id: 'workspace-onboarding',
component: WorkspaceOnboardingTutorial
},
{
id: 'workspace-onboarding-operator',
component: WorkspaceOnboardingOperatorTutorial
}
]}
/>

View File

@@ -10,12 +10,13 @@
syncTutorialsTodos,
TUTORIAL_BANNER_DISMISSED_KEY
} from '$lib/tutorialUtils'
import { tutorialsToDo, userStore } from '$lib/stores'
import { tutorialsToDo, userStore, skippedAll } from '$lib/stores'
import { TUTORIALS_CONFIG } from '$lib/tutorials/config'
import { hasRoleAccess } from '$lib/tutorials/roleUtils'
import { onMount } from 'svelte'
let isDismissed = $state(true)
let isDismissed = $state(false)
let hasCompletedAny = $state(false)
/**
* Get all tutorial indexes that are accessible to the current user based on their role.
@@ -46,29 +47,37 @@
// Sync tutorial progress from backend first
await syncTutorialsTodos()
// Check if banner has been manually dismissed
// Check if banner has been manually dismissed via X button (soft dismiss, per-device)
const manuallyDismissed = getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true'
if (manuallyDismissed) {
isDismissed = true
return
}
// Check if user deliberately skipped all tutorials (permanent dismiss, from backend)
if ($skippedAll) {
isDismissed = true
return
}
// Safe to check tutorialsToDo here since we awaited syncTutorialsTodos() above
// Filter tutorialsToDo to only include tutorials accessible to the user
// Filter tutorialsToDo to only include tutorials accessible to the user's role
const remainingAccessibleTutorials = $tutorialsToDo.filter((index) =>
accessibleTutorialIndexes.has(index)
)
// Check if all accessible tutorials are completed
const allTutorialsCompleted = remainingAccessibleTutorials.length === 0
// Calculate if user has completed at least one tutorial (for banner wording)
// This determines whether to show "New tutorial available!" or "Learn with interactive tutorials"
hasCompletedAny = remainingAccessibleTutorials.length < accessibleTutorialIndexes.size
// Dismiss banner if manually dismissed OR all accessible tutorials completed
if (manuallyDismissed || allTutorialsCompleted) {
// Hide banner if all accessible tutorials are completed (but can reappear with new tutorials)
if (remainingAccessibleTutorials.length === 0) {
isDismissed = true
// Set localStorage when all tutorials are completed to persist dismissal
// Note: This will re-set the key on every page load if localStorage is cleared
// but tutorials remain completed in backend. This is intentional - the banner
// should stay hidden if tutorials are completed, regardless of localStorage state.
if (allTutorialsCompleted) {
storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
}
return
}
// Show banner - user has accessible tutorials to complete
isDismissed = false
} catch (error) {
console.error('Failed to sync tutorial progress:', error)
// Fallback to manual dismissal check only if API call fails
@@ -77,9 +86,10 @@
})
async function handleSkipAllTutorials() {
// Skip all tutorials and set skipped_all flag in backend (permanent)
await skipAllTodos()
await syncTutorialsTodos()
storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
// No need to set localStorage - backend skipped_all flag is the source of truth
isDismissed = true
}
@@ -117,10 +127,18 @@
<GraduationCap size={20} class="text-accent-primary flex-shrink-0" />
<div class="flex-1 min-w-0">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
Learn with interactive tutorials
{#if hasCompletedAny}
New tutorial available!
{:else}
Learn with interactive tutorials
{/if}
</div>
<div class="text-hint text-3xs truncate text-left font-normal">
Get started quickly with step-by-step guides on building flows, scripts, and more.
{#if hasCompletedAny}
Continue your learning journey and master new Windmill skills.
{:else}
Get started quickly with step-by-step guides on building flows, scripts, and more.
{/if}
</div>
</div>
</div>

View File

@@ -1,7 +1,21 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import { isFlowTainted, triggerPointerDown, clickButtonBySelector } from './utils'
import {
isFlowTainted,
triggerPointerDown,
clickButtonBySelector,
DELAY_SHORT,
DELAY_MEDIUM,
DELAY_LONG,
DELAY_ANIMATION,
DELAY_ANIMATION_LONG,
DELAY_TYPING,
DELAY_CODE_CHAR,
DELAY_CODE_NEWLINE,
moveCursorToElement,
createFakeCursor
} from './utils'
import Tutorial from './Tutorial.svelte'
import type { DriveStep } from 'driver.js'
import { initFlow } from '../flows/flowStore.svelte'
@@ -28,16 +42,6 @@
let step5Complete = $state(false)
let step6Complete = $state(false)
// Constants for delays
const DELAY_SHORT = 100
const DELAY_MEDIUM = 300
const DELAY_LONG = 500
const DELAY_ANIMATION = 1500
const DELAY_ANIMATION_LONG = 2500
const DELAY_TYPING = 50
const DELAY_CODE_CHAR = 2
const DELAY_CODE_NEWLINE = 5
// Helper function to get driver overlay
function getDriverOverlay(): HTMLElement | null {
return document.querySelector('.driver-overlay') as HTMLElement | null
@@ -89,38 +93,13 @@
}
}
// Helper function to move cursor to element (for continuous cursor movement)
async function moveCursorToElement(
cursor: HTMLElement,
element: HTMLElement,
duration: number = DELAY_ANIMATION
): Promise<void> {
const rect = element.getBoundingClientRect()
cursor.style.transition = `all ${duration / 1000}s ease-in-out`
cursor.style.left = `${rect.left + rect.width / 2}px`
cursor.style.top = `${rect.top + rect.height / 2}px`
await wait(duration)
}
// Helper function to create and animate a fake cursor
async function createFakeCursor(
// Helper function to create and animate a fake cursor (extended version with start element support)
async function createFakeCursorWithStart(
startElement: HTMLElement | null,
endElement: HTMLElement,
transitionDuration: number = 1.5
): Promise<HTMLElement> {
const fakeCursor = document.createElement('div')
fakeCursor.style.cssText = `
position: fixed;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(59, 130, 246, 0.8);
border: 2px solid white;
pointer-events: none;
z-index: 10000;
transition: all ${transitionDuration}s ease-in-out;
`
document.body.appendChild(fakeCursor)
const fakeCursor = createFakeCursor()
const endRect = endElement.getBoundingClientRect()
let startX: number, startY: number
@@ -319,7 +298,7 @@
// Animate cursor to the add step button
const button = document.querySelector('#flow-editor-add-step-0') as HTMLElement
if (button) {
const fakeCursor1 = await createFakeCursor(null, button, 1.5)
const fakeCursor1 = await createFakeCursorWithStart(null, button, 1.5)
await wait(DELAY_SHORT)
button.click()
fakeCursor1.remove()
@@ -337,7 +316,7 @@
if (bunSpan) {
// Animate cursor from add step button to TypeScript (Bun) span
const fakeCursor2 = await createFakeCursor(button, bunSpan, 1.5)
const fakeCursor2 = await createFakeCursorWithStart(button, bunSpan, 1.5)
await wait(DELAY_MEDIUM)
fakeCursor2.remove()

View File

@@ -0,0 +1,474 @@
<script lang="ts">
import Tutorial from './Tutorial.svelte'
import type { DriveStep } from 'driver.js'
import { updateProgress } from '$lib/tutorialUtils'
import { JobService, FlowService, type Flow } from '$lib/gen'
import { workspaceStore, userStore } from '$lib/stores'
import { wait } from '$lib/utils'
import { waitJob } from '$lib/components/waitJob'
import { DELAY_SHORT, DELAY_MEDIUM, DELAY_LONG, createFakeCursor, animateCursorToElementAndClick, animateFakeCursorClick } from './utils'
import { goto } from '$app/navigation'
import { base } from '$lib/base'
import { sendUserToast } from '$lib/toast'
interface Props {
index: number
}
let { index }: Props = $props()
let tutorial: Tutorial | undefined = $state(undefined)
let tutorialFlowPaths: string[] = $state([])
// Flags to track if steps are complete
let step2Complete = $state(false)
let step3Complete = $state(false)
let step5Complete = $state(false)
let step6Complete = $state(false)
// Create a simple flow
async function createTutorialFlow(): Promise<string> {
const flowPath = `f/tutorial/runs-tutorial-flow-${Date.now()}`
const flow: Flow = {
summary: 'Tutorial: Simple Hello World Flow',
description: 'A simple flow created for the runs tutorial',
value: {
modules: [
{
id: 'hello',
value: {
type: 'rawscript',
content: 'export async function main() {\n return {\n message: "Hello from the Runs tutorial!",\n timestamp: new Date().toISOString()\n };\n}',
language: 'bun',
input_transforms: {}
},
summary: 'Say hello'
}
]
},
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {},
required: [],
order: []
},
path: flowPath,
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {}
}
await FlowService.createFlow({
workspace: $workspaceStore!,
requestBody: flow
})
return flowPath
}
// Create a broken flow that will fail
async function createBrokenFlow(): Promise<string> {
const flowPath = `f/tutorial/runs-tutorial-broken-${Date.now()}`
const flow: Flow = {
summary: 'Tutorial: Broken Flow Example',
description: 'A flow that intentionally fails to demonstrate error handling',
value: {
modules: [
{
id: 'error',
value: {
type: 'rawscript',
content: 'export async function main() {\n throw new Error("Intentional error for tutorial - this demonstrates how failed jobs appear in the runs list");\n}',
language: 'bun',
input_transforms: {}
},
summary: 'Throw error'
}
]
},
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {},
required: [],
order: []
},
path: flowPath,
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {}
}
await FlowService.createFlow({
workspace: $workspaceStore!,
requestBody: flow
})
return flowPath
}
// Run the flow and wait for completion
async function runFlowAndWait(flowPath: string): Promise<string> {
const jobId = await JobService.runFlowByPath({
workspace: $workspaceStore!,
path: flowPath,
requestBody: {},
skipPreprocessor: true
})
// Wait for job to complete
await waitJob(jobId)
return jobId
}
function getTutorialSteps(driver: any): DriveStep[] {
return [
{
popover: {
title: 'Welcome to your Monitoring Dashboard!',
description: "<p>Before we dive in, let's define a key term: a Job. A &quot;Job&quot; is simply a single run of a script or flow. Every time you run code, Windmill creates a Job to track if it succeeded or failed, how long it took, and what the results were.</p><p style='margin-top: 12px;'>In this tutorial, we will explore:</p><ul style='margin-top: 8px; padding-left: 20px;'><li style='margin-bottom: 8px;'><svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='color: #22c55e; display: inline-block; vertical-align: middle; margin-right: 6px;'><circle cx='12' cy='12' r='10'/><path d='m9 12 2 2 4-4'/></svg>A successful job execution.</li><li style='margin-bottom: 8px;'><svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='color: #ef4444; display: inline-block; vertical-align: middle; margin-right: 6px;'><circle cx='12' cy='12' r='10'/><path d='m12 8v4'/><path d='m12 16h.01'/></svg>A failed job execution.</li><li><svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='color: #3b82f6; display: inline-block; vertical-align: middle; margin-right: 6px;'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>How to filter your monitoring view.</li></ul>",
onNextClick: () => {
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#runs-table-wrapper',
onHighlighted: async () => {
step2Complete = false
await wait(DELAY_MEDIUM)
// Find all jobs
const allJobRows = Array.from(
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
) as HTMLElement[]
// Find successful job (green badge/check icon) - first one that's not failed
const successfulJobRow = allJobRows.find((el) => {
const hasRedBadge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
const hasGreenBadge = el.querySelector('[class*="bg-green"], [class*="text-green"]')
return !hasRedBadge && hasGreenBadge !== null
}) || allJobRows[0]
if (successfulJobRow) {
// Create cursor
const cursor = createFakeCursor()
// Click on successful job
await animateCursorToElementAndClick(cursor, successfulJobRow)
// Wait for navigation to job details page
await wait(DELAY_LONG)
// Navigate back to runs page using SvelteKit navigation
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
await wait(DELAY_LONG)
// Remove the cursor
cursor.remove()
step2Complete = true
}
},
popover: {
title: 'Exploring successful job runs <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #22c55e; display: inline-block; vertical-align: middle;"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
description:
'Let\'s click on a successful job to see how to inspect a completed execution.',
side: 'bottom',
onNextClick: async () => {
if (!step2Complete) {
sendUserToast('Please wait for the job click to complete...', false, [], undefined, 3000)
return
}
// Click on the successful job again (without showing cursor)
const successfulJobRow = Array.from(
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
).find((el) => {
const hasRedBadge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
const hasGreenBadge = el.querySelector('[class*="bg-green"], [class*="text-green"]')
return !hasRedBadge && hasGreenBadge !== null
}) as HTMLElement || Array.from(
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
)[0] as HTMLElement
if (successfulJobRow) {
successfulJobRow.click()
await wait(DELAY_SHORT)
// Wait for navigation to job details page
await wait(DELAY_LONG)
// Navigate back to runs page using SvelteKit navigation
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
await wait(DELAY_LONG)
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#runs-table-wrapper',
onHighlighted: async () => {
step3Complete = false
await wait(DELAY_MEDIUM)
// Find all jobs
const allJobRows = Array.from(
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
) as HTMLElement[]
// Find failed job (red badge/X icon)
const failedJobRow = allJobRows.find((el) => {
const badge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
return badge !== null
}) as HTMLElement
if (failedJobRow) {
// Create cursor
const cursor = createFakeCursor()
// Click on failed job
await animateCursorToElementAndClick(cursor, failedJobRow)
// Wait for navigation to job details page
await wait(DELAY_LONG)
// Navigate back to runs page using SvelteKit navigation
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
await wait(DELAY_LONG)
// Remove the cursor
cursor.remove()
step3Complete = true
}
},
popover: {
title: 'Exploring failed job runs <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #ef4444; display: inline-block; vertical-align: middle;"><circle cx="12" cy="12" r="10"/><path d="m12 8v4"/><path d="m12 16h.01"/></svg>',
description:
'Now let\'s click on a failed job to see how to inspect a failed execution.',
side: 'bottom',
onNextClick: async () => {
if (!step3Complete) {
sendUserToast('Please wait for the job click to complete...', false, [], undefined, 3000)
return
}
// Click on the failed job again (without showing cursor)
const failedJobRow = Array.from(
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
).find((el) => {
const badge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
return badge !== null
}) as HTMLElement
if (failedJobRow) {
failedJobRow.click()
await wait(DELAY_SHORT)
// Wait for navigation to job details page
await wait(DELAY_LONG)
// Navigate back to runs page using SvelteKit navigation
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
await wait(DELAY_LONG)
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: 'div.p-2.px-4.pt-8.w-full.border-b',
popover: {
title: 'Visual run history',
description:
'This chart gives you a visual overview of your run history at a glance. The duration chart shows how long each job takes to complete over time.',
side: 'bottom',
onNextClick: () => {
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: 'div.p-2.px-4.pt-8.w-full.border-b',
onHighlighted: async () => {
step5Complete = false
await wait(DELAY_MEDIUM)
// Find the button with data-value="ConcurrencyChart"
const concurrencyButton = document.querySelector(
'button[data-value="ConcurrencyChart"]'
) as HTMLElement
if (concurrencyButton) {
await animateFakeCursorClick(concurrencyButton)
await wait(DELAY_MEDIUM)
step5Complete = true
}
},
popover: {
title: 'Switching chart views',
description:
'You can switch between different chart views to analyze your runs. The concurrency chart allows you to see how many jobs are running concurrently over time.',
side: 'bottom',
onNextClick: () => {
if (!step5Complete) {
sendUserToast('Please wait for the chart switch to complete...', false, [], undefined, 3000)
return
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: 'div.flex.flex-row.items-start.w-full.border-b.px-4.gap-8',
onHighlighted: async () => {
step6Complete = false
await wait(DELAY_MEDIUM)
// Find the success and failure filter buttons
const successButton = document.querySelector(
'button[data-value="success"]'
) as HTMLElement
const failureButton = document.querySelector(
'button[data-value="failure"]'
) as HTMLElement
if (successButton && failureButton) {
// Create cursor once for both clicks
const cursor = createFakeCursor()
// Click on failure button first
await animateCursorToElementAndClick(cursor, failureButton)
await wait(DELAY_MEDIUM)
// Click on success button
await animateCursorToElementAndClick(cursor, successButton)
// Remove the cursor
cursor.remove()
await wait(DELAY_MEDIUM)
step6Complete = true
}
},
popover: {
title: 'Filtering jobs date, kind, status',
description:
'You can filter jobs, for example by status (failed, running, success). This helps you focus on specific types of executions.',
side: 'bottom',
onNextClick: () => {
if (!step6Complete) {
sendUserToast('Please wait for the filter clicks to complete...', false, [], undefined, 3000)
return
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: 'div.flex.flex-row.gap-4.items-center.px-2.py-1.grow-0.justify-between',
popover: {
title: 'More filtering options',
description:
'Even more filters are available to help you find exactly what you\'re looking for. Explore the additional filtering options to refine your search.',
side: 'bottom',
onNextClick: () => {
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
popover: {
title: 'Tutorial complete! 🎉',
description:
'You now know how to use the Runs page to monitor your executions, view successful results, and debug failed jobs.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu.</p>',
onNextClick: async () => {
updateProgress(index)
driver.destroy()
// Cleanup tutorial flows silently
await cleanupTutorialFlows()
}
}
}
]
}
// Cleanup function to delete tutorial flows
async function cleanupTutorialFlows() {
// Don't delete flows if user is an operator (they don't have permission)
if ($userStore?.operator) {
return
}
for (const flowPath of tutorialFlowPaths) {
try {
await FlowService.deleteFlowByPath({
workspace: $workspaceStore!,
path: flowPath
})
} catch (error) {
console.error(`Error deleting tutorial flow ${flowPath}:`, error)
}
}
}
// Start tutorial - create and run both jobs first
export async function runTutorial() {
// Create and run both flows at the beginning
try {
const successfulFlowPath = await createTutorialFlow()
const brokenFlowPath = await createBrokenFlow()
// Store flow paths for cleanup
tutorialFlowPaths = [successfulFlowPath, brokenFlowPath]
// Run both flows in parallel
await Promise.all([
runFlowAndWait(successfulFlowPath),
runFlowAndWait(brokenFlowPath)
])
// Wait a bit for jobs to appear
await wait(DELAY_LONG)
} catch (error) {
console.error('Error creating/running tutorial flows:', error)
}
tutorial?.runTutorial()
}
</script>
<Tutorial
bind:this={tutorial}
{index}
name="runs-tutorial"
tainted={false}
on:error
on:skipAll
getSteps={(driver) => {
return getTutorialSteps(driver)
}}
/>

View File

@@ -8,6 +8,7 @@
import { wait, type StateStore } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { updateProgress } from '$lib/tutorialUtils'
import { DELAY_SHORT, DELAY_MEDIUM, DELAY_LONG, createFakeCursor } from './utils'
interface Props {
index: number
@@ -30,11 +31,6 @@
7: false
})
// Constants for delays
const DELAY_SHORT = 100
const DELAY_MEDIUM = 300
const DELAY_LONG = 500
// Constants for cursor animation
const CURSOR_START_OFFSET = -100
const CURSOR_CLICK_SCALE = 0.8
@@ -61,25 +57,13 @@
return true
}
// Helper function to create and animate a fake cursor
async function createFakeCursor(
// Helper function to create and animate a fake cursor with start position
async function createFakeCursorWithStart(
startElement: HTMLElement | null,
endElement: HTMLElement,
transitionDuration: number = 1.5
): Promise<HTMLElement> {
const fakeCursor = document.createElement('div')
fakeCursor.style.cssText = `
position: fixed;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(59, 130, 246, 0.8);
border: 2px solid white;
pointer-events: none;
z-index: 10000;
transition: all ${transitionDuration}s ease-in-out;
`
document.body.appendChild(fakeCursor)
const fakeCursor = createFakeCursor()
const endRect = endElement.getBoundingClientRect()
let startX: number, startY: number
@@ -122,7 +106,7 @@
transitionDuration: number = 1.5,
options?: { usePointerEvents?: boolean }
): Promise<void> {
const fakeCursor = await createFakeCursor(null, element, transitionDuration)
const fakeCursor = await createFakeCursorWithStart(null, element, transitionDuration)
await wait(DELAY_MEDIUM)
// Animate click (shrink cursor briefly)

View File

@@ -4,6 +4,17 @@ import { emptyApp } from '../apps/editor/appUtils'
import type { App } from '../apps/types'
import { findGridItem } from '../apps/editor/appUtilsCore'
import { isRunnableByName } from '../apps/inputType'
import { wait } from '$lib/utils'
// Tutorial animation delay constants
export const DELAY_SHORT = 100
export const DELAY_MEDIUM = 300
export const DELAY_LONG = 500
export const DELAY_ANIMATION = 1500
export const DELAY_ANIMATION_LONG = 2500
export const DELAY_TYPING = 50
export const DELAY_CODE_CHAR = 2
export const DELAY_CODE_NEWLINE = 5
export function setInputBySelector(selector: string, value: string) {
const input = document.querySelector(selector) as HTMLInputElement
@@ -190,3 +201,128 @@ export function waitForElementLoading(
attempts++
}, interval)
}
// Helper function to move cursor to element (for continuous cursor movement in tutorials)
export async function moveCursorToElement(
cursor: HTMLElement,
element: HTMLElement,
duration: number = DELAY_ANIMATION
): Promise<void> {
const rect = element.getBoundingClientRect()
cursor.style.transition = `all ${duration / 1000}s ease-in-out`
cursor.style.left = `${rect.left + rect.width / 2}px`
cursor.style.top = `${rect.top + rect.height / 2}px`
await wait(duration)
}
// Helper function to create a fake cursor element for tutorial animations
export function createFakeCursor(): HTMLElement {
const fakeCursor = document.createElement('div')
fakeCursor.style.cssText = `
position: fixed;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(59, 130, 246, 0.8);
border: 2px solid white;
pointer-events: none;
z-index: 10000;
transition: all 1.5s ease-in-out;
`
document.body.appendChild(fakeCursor)
return fakeCursor
}
// Constants for cursor animation
const CURSOR_START_OFFSET = -100
const CURSOR_CLICK_SCALE = 0.8
// Helper function to create and animate a fake cursor with start position
export async function createFakeCursorWithStart(
startElement: HTMLElement | null,
endElement: HTMLElement,
transitionDuration: number = 1.5
): Promise<HTMLElement> {
const fakeCursor = createFakeCursor()
const endRect = endElement.getBoundingClientRect()
let startX: number, startY: number
if (startElement) {
const startRect = startElement.getBoundingClientRect()
startX = startRect.left + startRect.width / 2
startY = startRect.top + startRect.height / 2
} else {
startX = endRect.left + CURSOR_START_OFFSET
startY = endRect.top + endRect.height / 2
}
fakeCursor.style.left = `${startX}px`
fakeCursor.style.top = `${startY}px`
await wait(DELAY_SHORT)
fakeCursor.style.left = `${endRect.left + endRect.width / 2}px`
fakeCursor.style.top = `${endRect.top + endRect.height / 2}px`
await wait(transitionDuration * 1000)
return fakeCursor
}
// Helper function to animate a fake cursor click
export async function animateFakeCursorClick(
element: HTMLElement,
transitionDuration: number = 1.5,
options?: { usePointerEvents?: boolean; startElement?: HTMLElement | null }
): Promise<void> {
const fakeCursor = await createFakeCursorWithStart(
options?.startElement ?? null,
element,
transitionDuration
)
await wait(DELAY_MEDIUM)
// Animate click (shrink cursor briefly)
fakeCursor.style.transform = `scale(${CURSOR_CLICK_SCALE})`
await wait(DELAY_SHORT)
fakeCursor.style.transform = 'scale(1)'
await wait(DELAY_SHORT)
// Trigger pointer events if needed (flow graph uses pointer events instead of click)
if (options?.usePointerEvents) {
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
}
// Click the element
element.click()
await wait(DELAY_SHORT)
// Remove fake cursor
fakeCursor.remove()
}
// Helper function to animate cursor to element and click (for reusing a cursor across multiple clicks)
export async function animateCursorToElementAndClick(
cursor: HTMLElement,
element: HTMLElement,
startOffset: number = CURSOR_START_OFFSET
): Promise<void> {
const rect = element.getBoundingClientRect()
// Set initial position (off-screen to the left)
cursor.style.left = `${rect.left + startOffset}px`
cursor.style.top = `${rect.top + rect.height / 2}px`
await wait(DELAY_SHORT)
// Animate to target position
cursor.style.left = `${rect.left + rect.width / 2}px`
cursor.style.top = `${rect.top + rect.height / 2}px`
await wait(DELAY_ANIMATION)
await wait(DELAY_MEDIUM)
// Click on the element
element.click()
await wait(DELAY_SHORT)
}

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import { updateProgress } from '$lib/tutorialUtils'
import Tutorial from '../Tutorial.svelte'
import type { DriveStep } from 'driver.js'
import { goto } from '$app/navigation'
import { base } from '$lib/base'
import { page } from '$app/stores'
import { wait } from '$lib/utils'
import { DELAY_MEDIUM } from '../utils'
interface Props {
index: number
}
let { index }: Props = $props()
let tutorial: Tutorial | undefined = $state(undefined)
export function runTutorial() {
// Check if we're on the homepage
if ($page.url.pathname !== `${base}/` && $page.url.pathname !== `${base}`) {
// Redirect to homepage with a tutorial parameter
goto(`${base}/?tutorial=workspace-onboarding-operator`)
} else {
tutorial?.runTutorial()
}
}
</script>
<Tutorial
bind:this={tutorial}
{index}
name="workspace-onboarding-operator"
tainted={false}
on:skipAll
getSteps={(driver) => {
const steps: DriveStep[] = [
{
popover: {
title: 'Welcome to Windmill! 🎉',
description:
"Let's take a quick tour! We'll show you the three main tools you can use: Scripts, Flows, and Apps.",
onNextClick: () => {
// Wait a bit to ensure the page is fully rendered before moving to next step
setTimeout(() => {
// Try to find the script tab button
const scriptsButton = document.querySelector('[data-value="script"]') as HTMLElement | null
if (scriptsButton) {
driver.moveNext()
} else {
// If we can't find the button, just move to next step anyway
driver.moveNext()
}
}, 100)
}
}
},
{
popover: {
title: 'Scripts - Run automated tasks',
description:
'<img src="/script-tutorial-operator.png" alt="Script Example" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Scripts</strong> are ready-to-use tasks that do things automatically for you.</p><p style="margin-top: 8px;">You can <strong>run scripts</strong> whenever you need them - like generating a report, sending notifications, or processing data.</p>',
onNextClick: async () => {
// Move to the next step (Flows)
setTimeout(() => {
const flowsButton = document.querySelector('[data-value="flow"]') as HTMLElement | null
if (flowsButton) {
driver.moveNext()
} else {
driver.moveNext()
}
}, 100)
}
},
element: '[data-value="script"]'
},
{
popover: {
title: 'Flows - Run step-by-step processes',
description:
'<img src="/flow.png" alt="Flow" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Flows</strong> are processes that run multiple tasks in order, one after another.</p><p style="margin-top: 8px;">You can <strong>start a flow</strong> and watch it complete each step automatically - perfect for tasks that have multiple stages.</p>',
onNextClick: async () => {
// Move to the next step (Apps)
setTimeout(() => {
const appsButton = document.querySelector('[data-value="app"]') as HTMLElement | null
if (appsButton) {
driver.moveNext()
} else {
driver.moveNext()
}
}, 100)
}
},
element: '[data-value="flow"]'
},
{
popover: {
title: 'Apps - Use custom tools',
description:
'<img src="/app.png" alt="App" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Apps</strong> are easy-to-use tools with buttons, forms, and displays built just for your team.</p><p style="margin-top: 8px;">You can <strong>open an app</strong> to work with your data, fill out forms, or trigger tasks - no technical knowledge needed!</p>',
onNextClick: async () => {
// Move to the next step (cursor animation)
driver.moveNext()
}
},
element: '[data-value="app"]'
},
{
popover: {
title: 'Finally, the Menu section',
description: 'Explore available tabs where you can access your history of runs, your scheduled scripts, your tutorials progress etc.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu.</p>',
onNextClick: async () => {
// Find the target button and click it
const targetButton = document.querySelector('[role="menuitem"]') as HTMLElement | null
if (targetButton) {
targetButton.click()
}
// Wait for menu to open
await wait(DELAY_MEDIUM)
// Mark tutorial as complete
updateProgress(index)
driver.destroy()
// Clean up URL parameter if present
if ($page.url.searchParams.has('tutorial')) {
goto(`${base}/`, { replaceState: true })
}
}
},
element: '[role="menuitem"]'
}
]
return steps
}}
/>

View File

@@ -71,11 +71,6 @@ export async function skipAllTodos() {
tutorialsToDo.set([])
skippedAll.set(true)
// Dismiss the tutorial banner when all tutorials are skipped
if (typeof window !== 'undefined') {
localStorage.setItem(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
}
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } })
}

View File

@@ -1,5 +1,5 @@
import type { ComponentType } from 'svelte'
import { Workflow, GraduationCap, Wrench, PlayCircle, Link2 } from 'lucide-svelte'
import { Workflow, GraduationCap, Wrench, PlayCircle, Link2, History } from 'lucide-svelte'
import { base } from '$lib/base'
import type { Role } from './roleUtils'
@@ -43,7 +43,7 @@ export function getTutorialIndex(id: string): number {
export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
quickstart: {
label: 'Quickstart',
roles: ['admin', 'developer'],
roles: ['admin', 'developer', 'operator'],
progressBar: true,
active: true,
tutorials: [
@@ -88,7 +88,35 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
comingSoon: false,
roles: ['admin','developer'],
order: 3
}
},
{
id: 'runs-tutorial',
icon: History,
title: 'Discover your monitoring dashboard',
description: 'Learn how to monitor, filter, and manage your script and flow executions.',
onClick: () => {
window.location.href = `${base}/runs?tutorial=runs-tutorial`
},
index: 7,
active: true,
comingSoon: false,
roles: ['admin', 'developer','operator'],
order: 4
},
{
id: 'workspace-onboarding-operator',
icon: GraduationCap,
title: 'Workspace onboarding',
description: 'Discover the basics of Windmill with a quick tour of the workspace.',
onClick: () => {
window.location.href = `${base}/?tutorial=workspace-onboarding-operator`
},
index: 6,
active: true,
comingSoon: false,
roles: ['operator'],
order: 1
},
]
},
app_editor: {

View File

@@ -53,12 +53,16 @@ export function hasRoleAccess(
/**
* Check if a preview role has access based on a roles array.
* Used by admins to preview what other roles can see.
* This is a convenience wrapper around hasRoleAccess for preview mode.
* Uses exact role matching - only shows tutorials explicitly marked for the preview role.
*/
export function hasRoleAccessForPreview(
previewRole: Role,
roles?: Role[]
): boolean {
return hasRoleAccess(null, roles, previewRole)
// No roles specified = available to everyone
if (!roles || roles.length === 0) return true
// Exact role match - tutorial must explicitly include the preview role
return roles.includes(previewRole)
}

View File

@@ -110,6 +110,11 @@
setTimeout(() => {
workspaceTutorials?.runTutorialById('workspace-onboarding')
}, 500)
} else if (tutorialParam === 'workspace-onboarding-operator') {
// Small delay to ensure page is fully loaded
setTimeout(() => {
workspaceTutorials?.runTutorialById('workspace-onboarding-operator')
}, 500)
} else if (!$ignoredTutorials.includes(8) && $tutorialsToDo.includes(8)) {
// Check if user hasn't completed or ignored the workspace onboarding tutorial
// Small delay to ensure page is fully loaded

View File

@@ -1,13 +1,31 @@
<!-- TODO : Refactor the runs page to separate state from UI so I don't need to do the {#key trick} -->
<script>
<script lang="ts">
import { page } from '$app/state'
import { onMount } from 'svelte'
import RunsPage, { DEFAULT_RUNS_PER_PAGE } from '../../../../../lib/components/RunsPage.svelte'
import RunsTutorial from '$lib/components/tutorials/RunsTutorial.svelte'
let perPage = $state(
parseInt(page.url.searchParams.get('per_page') ?? DEFAULT_RUNS_PER_PAGE.toString())
)
let runsTutorial: RunsTutorial
onMount(() => {
// Check if there's a tutorial parameter in the URL
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam === 'runs-tutorial') {
// Small delay to ensure page is fully loaded
setTimeout(() => {
runsTutorial?.runTutorial()
}, 500)
}
})
</script>
{#key perPage}
<RunsPage bind:perPage />
{/key}
<RunsTutorial bind:this={runsTutorial} index={7} />

View File

@@ -54,7 +54,9 @@
// This derived value only recalculates when userStore or selectedPreviewRole changes
const accessCheckContext = $derived.by(() => {
const user = $userStore
const usePreview = user?.is_admin && selectedPreviewRole !== userEffectiveRole
// Always use preview mode for admins to show role-specific tutorials
// This ensures admins only see tutorials for the selected role
const usePreview = user?.is_admin
return { user, usePreview, previewRole: selectedPreviewRole }
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -0,0 +1,598 @@
# Windmill Tutorial System Guide
This guide documents the complete tutorial infrastructure in Windmill's frontend, enabling developers to create new interactive tutorials without re-exploring the codebase.
## Table of Contents
1. [Overview](#overview)
2. [Architecture](#architecture)
3. [File Structure](#file-structure)
4. [Creating a New Tutorial](#creating-a-new-tutorial)
5. [Key Components & APIs](#key-components--apis)
6. [Progress Tracking System](#progress-tracking-system)
7. [Role-Based Access](#role-based-access)
8. [Testing & Debugging](#testing--debugging)
---
## Overview
The Windmill tutorial system provides interactive, step-by-step guides for users using the `driver.js` library. Tutorials can:
- Highlight specific UI elements with overlay popovers
- Guide users through workflows with navigation controls
- Track completion progress in the database
- Filter tutorials by user role (admin, developer, operator)
- Support multiple tutorial contexts (workspace, flow editor, app editor)
**Core Technology:** [Driver.js](https://driverjs.com/) - A lightweight JavaScript library for creating product tours
---
## Architecture
### High-Level Flow
```
Tutorial Config (config.ts)
Tutorial Registration (component creation)
Tutorial Router (WorkspaceTutorials.svelte, etc.)
URL Parameter Detection (+page.svelte)
Tutorial Component (driver.js overlay)
Progress Tracking (tutorialUtils.ts → backend)
```
### Component Hierarchy
```
TutorialRouter (manages multiple tutorials)
└── TutorialWrapper (wraps individual tutorials)
└── Tutorial (core driver.js engine)
├── TutorialControls (prev/next buttons)
├── SkipTutorials (skip options)
└── TutorialInner (loads driver.js CSS)
```
### State Management
- **Global Stores** (`stores.ts`):
- `tutorialsToDo`: Array of incomplete tutorial indexes
- `skippedAll`: Boolean flag for skipped tutorials
- `isCurrentlyInTutorial`: Boolean tracking active tutorial state
- **Progress Tracking** (`tutorialUtils.ts`):
- Uses 64-bit bitmask system (each bit = one tutorial)
- Syncs with backend `tutorial_progress` table
- Backend table: `tutorial_progress(email, progress bit(64))`
---
## File Structure
```
frontend/src/lib/
├── tutorials/
│ ├── config.ts # Central tutorial registry
│ └── roleUtils.ts # Role-based access logic
├── tutorialUtils.ts # Progress tracking utilities
├── stores.ts # Global stores (tutorialsToDo, etc.)
└── components/
├── WorkspaceTutorials.svelte # Workspace tutorial container
├── FlowTutorials.svelte # Flow editor tutorials container
├── AppTutorials.svelte # App editor tutorials container
├── RunPageTutorials.svelte # Run page tutorials container
├── tutorials/
│ ├── Tutorial.svelte # Core tutorial engine (driver.js)
│ ├── TutorialRouter.svelte # Multi-tutorial manager
│ ├── TutorialWrapper.svelte # Instance wrapper
│ ├── TutorialInner.svelte # Loads driver.js CSS
│ ├── TutorialControls.svelte # Navigation UI
│ ├── SkipTutorials.svelte # Skip options
│ ├── ignoredTutorials.ts # Local storage for ignored tutorials
│ │
│ ├── workspace/
│ │ ├── WorkspaceOnboardingTutorial.svelte
│ │ └── WorkspaceOnboardingOperatorTutorial.svelte
│ │
│ ├── app/
│ │ ├── BackgroundRunnablesTutorial.svelte
│ │ ├── ConnectionTutorial.svelte
│ │ └── ExpressionEvaluationTutorial.svelte
│ │
│ └── flow/
│ ├── FlowBuilderLiveTutorial.svelte
│ └── TroubleshootFlowTutorial.svelte
└── home/
├── TutorialButton.svelte # Tutorial card UI
└── TutorialBanner.svelte # Homepage banner
```
---
## Creating a New Tutorial
### Step 1: Register Tutorial in Config
**File:** `frontend/src/lib/tutorials/config.ts`
```typescript
export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
quickstart: {
label: 'Quickstart',
roles: ['admin', 'developer', 'operator'],
progressBar: true,
active: true,
tutorials: [
{
id: 'my-new-tutorial', // Unique identifier
icon: GraduationCap, // Lucide icon component
title: 'My New Tutorial',
description: 'Learn something new',
onClick: () => {
window.location.href = `${base}/?tutorial=my-new-tutorial`
},
index: 7, // Next available index (1-64)
active: true,
comingSoon: false,
roles: ['developer', 'admin'], // Who can access
order: 7
}
]
}
}
```
**Important:**
- Choose a unique `index` (1-64) not used by other tutorials
- The `id` must match the tutorial parameter in the URL
- Indexes are used for bitmask progress tracking
### Step 2: Create Tutorial Component
**File:** `frontend/src/lib/components/tutorials/workspace/MyNewTutorial.svelte`
```svelte
<script lang="ts">
import Tutorial from '../Tutorial.svelte'
import { updateProgress } from '$lib/tutorialUtils'
import type { DriveStep } from 'driver.js'
// Props
let { index }: { index: number } = $props()
// Tutorial instance reference
let tutorial: Tutorial
// Define tutorial steps
function getSteps(driver: any): DriveStep[] {
return [
{
// Step 0: Welcome
popover: {
title: 'Welcome!',
description: 'This tutorial will teach you...',
}
},
{
// Step 1: Highlight an element
element: '#some-element-id',
popover: {
title: 'Important Feature',
description: 'Here you can do X, Y, and Z...',
// Optional: Add image
// description: `<img src="/tutorial-image.png" /><p>Description...</p>`
}
},
{
// Step 2: Another element
element: '.some-css-class',
popover: {
title: 'Another Feature',
description: 'Click here to...',
}
},
{
// Final step: Completion
popover: {
title: 'Congratulations!',
description: 'You completed the tutorial!',
onNextClick: async () => {
// Mark tutorial as complete
await updateProgress(index)
driver.destroy()
}
}
}
]
}
// Export function to start tutorial
export function runTutorial(options?: any) {
tutorial?.runTutorial(options)
}
</script>
<Tutorial bind:this={tutorial} {index} {getSteps} />
```
### Step 3: Register in Tutorial Router
**File:** `frontend/src/lib/components/WorkspaceTutorials.svelte` (or appropriate container)
```svelte
<script lang="ts">
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import WorkspaceOnboardingTutorial from './tutorials/workspace/WorkspaceOnboardingTutorial.svelte'
import MyNewTutorial from './tutorials/workspace/MyNewTutorial.svelte'
let tutorialRouter: TutorialRouter
export function runTutorialById(id: string, options?: any) {
tutorialRouter?.runTutorialById(id, options)
}
</script>
<TutorialRouter bind:this={tutorialRouter}>
<WorkspaceOnboardingTutorial index={1} />
<MyNewTutorial index={7} />
</TutorialRouter>
```
### Step 4: Add URL Parameter Handling
**File:** `frontend/src/routes/(root)/(logged)/+page.svelte` (or appropriate page)
```svelte
<script lang="ts">
import { page } from '$app/stores'
import { onMount } from 'svelte'
import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte'
let workspaceTutorials: WorkspaceTutorials
onMount(() => {
const tutorialParam = $page.url.searchParams.get('tutorial')
if (tutorialParam === 'my-new-tutorial') {
workspaceTutorials?.runTutorialById('my-new-tutorial')
}
})
</script>
<WorkspaceTutorials bind:this={workspaceTutorials} />
```
### Step 5: Test Your Tutorial
1. Login as a user with the appropriate role
2. Navigate to the tutorials page
3. Click your tutorial card
4. Verify URL changes to `/?tutorial=my-new-tutorial`
5. Verify tutorial starts automatically
6. Step through all steps
7. Verify completion marks tutorial as done
8. Check database: `SELECT * FROM tutorial_progress WHERE email = 'your@email.com'`
---
## Key Components & APIs
### Tutorial.svelte
**Core tutorial engine that wraps driver.js**
**Props:**
- `index: number` - Tutorial index for progress tracking (1-64)
- `getSteps: (driver) => DriveStep[]` - Function returning tutorial steps
**Exports:**
- `runTutorial(options?: any)` - Start the tutorial
**Features:**
- Auto-completes tutorial when last step is finished
- Renders custom controls and skip options
- Calls `updateProgress(index)` on completion
### TutorialRouter.svelte
**Manages multiple tutorial instances**
**Usage:**
```svelte
<TutorialRouter bind:this={router}>
<TutorialA index={1} />
<TutorialB index={2} />
</TutorialRouter>
```
**Exports:**
- `runTutorialById(id: string, options?: any)` - Start tutorial by ID
**Features:**
- Maintains Map of tutorial instances
- Routes calls to correct tutorial component
- Handles tutorial not found errors
### DriveStep Interface
**TypeScript interface for tutorial steps**
```typescript
interface DriveStep {
element?: string // CSS selector to highlight
popover?: {
title: string
description: string // Supports HTML
onNextClick?: (element, step, context) => void
onPrevClick?: (element, step, context) => void
}
}
```
**Tips:**
- Omit `element` for non-highlighted steps (like welcome/completion)
- Use HTML in `description` for images: `<img src="/path.png" />`
- Use callbacks for custom navigation logic
---
## Progress Tracking System
### Bitmask System
Tutorials use a 64-bit bitmask where each bit represents one tutorial's completion status:
```
Bit 0: Tutorial with index 0 (unused, reserve)
Bit 1: workspace-onboarding
Bit 2: flow-live-tutorial
Bit 3: troubleshoot-flow
Bit 4: backgroundrunnables
Bit 5: connection
Bit 6: workspace-onboarding-operator
...
Bit 63: Maximum possible tutorial
```
### Key Functions (tutorialUtils.ts)
```typescript
// Mark tutorial as complete
await updateProgress(tutorialIndex: number)
// Sync progress from backend
await syncTutorialsTodos()
// Skip all tutorials
await skipAllTodos()
// Reset all progress
await resetAllTodos()
// Skip specific tutorials
await skipTutorialsByIndexes(indexes: number[])
// Complete specific tutorial
await completeTutorialByIndex(index: number)
```
### Backend Integration
**Table:** `tutorial_progress`
```sql
CREATE TABLE tutorial_progress (
email VARCHAR PRIMARY KEY,
progress BIT(64)
);
```
**API Endpoint:** `POST /api/users/tutorial_progress`
```typescript
// Request body
{
"index": 7, // Tutorial index to mark complete
}
```
---
## Role-Based Access
### Available Roles
```typescript
type Role = 'admin' | 'developer' | 'operator'
```
### Role Hierarchy
- **Admin**: Full access, can see all tutorials
- **Developer**: Standard developer tutorials
- **Operator**: Limited to operator-specific tutorials
### Key Functions (roleUtils.ts)
```typescript
// Get current user's role
const role = getUserEffectiveRole(user)
// Check if user can access tutorial
const canAccess = hasRoleAccess(userRole, tutorialRoles)
```
### Setting Role Requirements
In `config.ts`:
```typescript
{
id: 'operator-only-tutorial',
roles: ['operator'], // Only operators see this
// ...
}
{
id: 'admin-dev-tutorial',
roles: ['admin', 'developer'], // Admins and developers see this
// ...
}
{
id: 'everyone-tutorial',
roles: ['admin', 'developer', 'operator'], // Everyone sees this
// ...
}
```
---
## Testing & Debugging
### Testing Checklist
- [ ] Tutorial appears in correct tab/category
- [ ] Tutorial only visible to correct roles
- [ ] Clicking tutorial navigates to correct URL with tutorial parameter
- [ ] Tutorial auto-starts on page load with parameter
- [ ] All steps highlight correct elements
- [ ] Navigation controls work (prev/next)
- [ ] Skip options work correctly
- [ ] Completion marks tutorial as done in database
- [ ] Banner updates to reflect completion
- [ ] Tutorial doesn't auto-start after completion
### Common Issues
**Tutorial doesn't auto-start:**
- Check URL parameter matches tutorial ID in config
- Verify `onMount()` logic in page component
- Ensure tutorial component is registered in router
**Element not highlighting:**
- Verify CSS selector is correct
- Check if element exists when tutorial runs
- Try using more specific selectors or IDs
**Progress not saving:**
- Check tutorial index is unique and correctly passed
- Verify `updateProgress()` is called on final step
- Check network tab for API call to `/api/users/tutorial_progress`
- Inspect database `tutorial_progress` table
**Wrong users see tutorial:**
- Verify `roles` array in config
- Check `getUserEffectiveRole()` returns correct role
- Ensure role filtering logic in tutorial list component
### Debugging Tools
**Browser Console:**
```javascript
// Check current tutorials to do
console.log($tutorialsToDo)
// Check if tutorial is skipped
console.log($skippedAll)
// Get user role
import { getUserEffectiveRole } from '$lib/tutorials/roleUtils'
console.log(getUserEffectiveRole($workspaceStore?.operator, $userStore))
```
**Database Queries:**
```sql
-- Check user's tutorial progress
SELECT email, progress::text FROM tutorial_progress WHERE email = 'user@example.com';
-- Reset user's progress (testing)
UPDATE tutorial_progress SET progress = B'0' WHERE email = 'user@example.com';
-- See all tutorials and their completion
SELECT
email,
(progress & (1::bit(64) << 1))::int AS workspace_onboarding,
(progress & (1::bit(64) << 2))::int AS flow_live_tutorial,
(progress & (1::bit(64) << 3))::int AS troubleshoot_flow
FROM tutorial_progress;
```
---
## Best Practices
### Tutorial Design
1. **Keep It Short**: 4-7 steps is ideal
2. **Clear Objectives**: State what users will learn upfront
3. **Highlight Key Elements**: Focus on essential features
4. **Use Images**: Visual aids help comprehension
5. **End with Encouragement**: Congratulate users on completion
### Technical Best Practices
1. **Unique Indexes**: Always use unique index numbers (1-64)
2. **Stable Selectors**: Use IDs or specific classes for element highlighting
3. **Error Handling**: Wrap `updateProgress()` in try-catch
4. **Role Testing**: Test with all relevant user roles
5. **Mobile Friendly**: Ensure tutorials work on different screen sizes
### Code Organization
1. **Group by Context**: Workspace, flow, app tutorials in separate folders
2. **Consistent Naming**: `[Feature]Tutorial.svelte` convention
3. **Reusable Steps**: Extract common step patterns to utilities
4. **Document Complex Logic**: Add comments for non-obvious step behaviors
---
## Quick Reference
### Creating a New Tutorial (Checklist)
- [ ] Step 1: Add to `config.ts` with unique ID and index
- [ ] Step 2: Create component in appropriate folder
- [ ] Step 3: Register in tutorial router (WorkspaceTutorials, etc.)
- [ ] Step 4: Add URL parameter handling in page component
- [ ] Step 5: Test with appropriate user role
- [ ] Step 6: Verify progress tracking in database
### File Paths (Quick Copy)
```
# Config
frontend/src/lib/tutorials/config.ts
# Tutorial Containers
frontend/src/lib/components/WorkspaceTutorials.svelte
frontend/src/lib/components/FlowTutorials.svelte
frontend/src/lib/components/AppTutorials.svelte
# Tutorial Components
frontend/src/lib/components/tutorials/Tutorial.svelte
frontend/src/lib/components/tutorials/TutorialRouter.svelte
frontend/src/lib/components/tutorials/workspace/[YourTutorial].svelte
# Page Integration
frontend/src/routes/(root)/(logged)/+page.svelte
# Utilities
frontend/src/lib/tutorialUtils.ts
frontend/src/lib/tutorials/roleUtils.ts
```
---
## Additional Resources
- **Driver.js Documentation**: https://driverjs.com/docs/
- **Svelte Tutorial System Examples**: See existing tutorials in `frontend/src/lib/components/tutorials/`
- **Database Schema**: See `backend/summarized_schema.txt` for `tutorial_progress` table details