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:
@@ -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
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
474
frontend/src/lib/components/tutorials/RunsTutorial.svelte
Normal file
474
frontend/src/lib/components/tutorials/RunsTutorial.svelte
Normal 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 "Job" 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)
|
||||
}}
|
||||
/>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
BIN
frontend/static/script-tutorial-operator.png
Normal file
BIN
frontend/static/script-tutorial-operator.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
598
frontend/tutorial-system-guide.mdc
Normal file
598
frontend/tutorial-system-guide.mdc
Normal 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
|
||||
Reference in New Issue
Block a user