chore(frontend): remove the tutorial system

Deletes the guided-tour feature: the tutorials directory, the per-editor
wrappers, the home banner and button, the /tutorials route, and the
driver.js dependency they were built on. Also removes what only existed to
serve them — the `tutorialsToDo` / `skippedAll` / `isCurrentlyInTutorial`
stores, the `disableTutorials` prop chain through the flow editor, the
`?tutorial=` deep links, PopupV2's clickOutside exemption for the driver
popover, and the selector-anchor class on the flow editor tabs.

The backend `tutorial_progress` endpoints and table stay: nothing calls
them now, and removing them is a public-API break plus a migration that
would drop existing progress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9
This commit is contained in:
Guilhem Lemouel
2026-09-02 10:15:03 +02:00
co-authored by Claude Opus 5
parent a72f79322d
commit a582eb526e
57 changed files with 15 additions and 5297 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
- **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead.
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy, `gpt-5.6-sol`, `xhigh` reasoning; requires the `codex` CLI >= 0.144.1.
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
- **Domain guides**: `.claude/skills/native-trigger/`
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
+2 -49
View File
@@ -42,7 +42,6 @@
"date-fns": "^2.30.0",
"diff": "^7.0.0",
"dompurify": "^3.3.1",
"driver.js": "^1.3.0",
"esm-env": "^1.0.0",
"fast-equals": "^5.0.1",
"graphql": "^16.7.1",
@@ -1754,7 +1753,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1771,7 +1769,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1788,7 +1785,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1805,7 +1801,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1822,7 +1817,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1839,7 +1833,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1856,7 +1849,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1873,7 +1865,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1890,7 +1881,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1907,7 +1897,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1924,7 +1913,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1941,7 +1929,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1958,7 +1945,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1975,7 +1961,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5577,12 +5562,6 @@
"url": "https://dotenvx.com"
}
},
"node_modules/driver.js": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.3.6.tgz",
"integrity": "sha512-g2nNuu+tWmPpuoyk3ffpT9vKhjPz4NrJzq6mkRDZIwXCrFhrKdDJ9TX5tJOBpvCTBrBYjgRQ17XlcQB15q4gMg==",
"license": "MIT"
},
"node_modules/dts-bundle-generator": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz",
@@ -7582,7 +7561,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -8278,7 +8257,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8299,7 +8277,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8320,7 +8297,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8341,7 +8317,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8362,7 +8337,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8383,7 +8357,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8404,7 +8377,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8425,7 +8397,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8446,7 +8417,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8467,7 +8437,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8488,7 +8457,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -13194,21 +13162,6 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -13988,7 +13941,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
-1
View File
@@ -118,7 +118,6 @@
"date-fns": "^2.30.0",
"diff": "^7.0.0",
"dompurify": "^3.3.1",
"driver.js": "^1.3.0",
"esm-env": "^1.0.0",
"fast-equals": "^5.0.1",
"graphql": "^16.7.1",
-12
View File
@@ -311,18 +311,6 @@
}
}
.driver-popover-title {
@apply leading-6 !text-primary !text-base;
}
.driver-popover-description {
@apply !text-secondary !text-sm;
}
.driver-popover {
@apply p-6 !bg-surface !max-w-2xl;
}
.panel-item {
@apply border dark:border-gray-600 border-gray-200 flex gap-1 truncate font-normal justify-between w-full items-center py-1 px-2 rounded-sm duration-200;
}
@@ -1,29 +0,0 @@
<script lang="ts">
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import BackgroundRunnablesTutorial from './tutorials/app/BackgroundRunnablesTutorial.svelte'
import ConnectionTutorial from './tutorials/app/ConnectionTutorial.svelte'
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
tutorialRouter?.runTutorialById(id, options)
}
</script>
<TutorialRouter
bind:this={tutorialRouter}
tutorials={[
{
id: 'backgroundrunnables',
component: BackgroundRunnablesTutorial,
name: 'backgroundrunnables',
supportsSkipSteps: true
},
{
id: 'connection',
component: ConnectionTutorial,
name: 'connection',
supportsSkipSteps: true
}
]}
/>
-1
View File
@@ -1247,7 +1247,6 @@
<FlowModuleSchemaMap
bind:this={flowModuleSchemaMap}
disableAi
disableTutorials
smallErrorHandler={true}
disableStaticInputs
localModuleStates={showJobStatus ? localModuleStates : {}}
+2 -85
View File
@@ -41,7 +41,7 @@
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { createRawSnippet, getContext, setContext, untrack } from 'svelte'
import { getContext, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -71,11 +71,6 @@
Settings,
Undo,
Redo,
BookOpen,
Circle,
CheckCircle,
RefreshCw,
CheckCheck,
Disc
} from 'lucide-svelte'
import Awareness from './Awareness.svelte'
@@ -83,11 +78,7 @@
import { type FlowCopilotContext } from './copilot/flow'
import { loadFlowModuleState } from './flows/flowStateUtils.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import FlowTutorials from './FlowTutorials.svelte'
import FlowHistory from './flows/FlowHistory.svelte'
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
import { tutorialsToDo } from '$lib/stores'
import { getTutorialIndex } from '$lib/tutorials/config'
import EditorHeader from './EditorHeader.svelte'
import AutosaveIndicator from './AutosaveIndicator.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
@@ -1053,21 +1044,11 @@
setContext('FlowCopilotContext', flowCopilotContext)
let renderCount = $state(0)
let flowTutorials: FlowTutorials | undefined = $state(undefined)
let jsonViewerDrawer: Drawer | undefined = $state(undefined)
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
let flowHistory: FlowHistory | undefined = $state(undefined)
export function triggerTutorial() {
const urlParams = new URLSearchParams(window.location.search)
const tutorial = urlParams.get('tutorial')
if (tutorial) {
flowTutorials?.runTutorialById(tutorial)
}
}
let baseMenuItems: Item[] = $state([])
const mod = isMac() ? '⌘' : 'Ctrl+'
@@ -1091,56 +1072,6 @@
disabled: $history.index === $history.history.length - 1,
shortcut: `${mod}⇧Z`
},
{
displayName: 'Tutorials',
icon: BookOpen,
separatorTop: true,
extra: (() => {
const remaining = [
getTutorialIndex('flow-live-tutorial'),
getTutorialIndex('troubleshoot-flow')
].filter((i) => $tutorialsToDo.includes(i)).length
return remaining > 0
? createRawSnippet(() => ({
render: () =>
`<span class="ml-auto inline-flex items-center justify-center w-4 h-4 text-[10px] font-medium text-white rounded-full bg-surface-accent-primary">${remaining}</span>`
}))
: undefined
})(),
submenuItems: [
{
displayName: 'Build a flow',
action: () => flowTutorials?.runTutorialById('flow-live-tutorial'),
icon: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial'))
? Circle
: CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial'))
? undefined
: 'green'
},
{
displayName: 'Fix a broken flow',
action: () => flowTutorials?.runTutorialById('troubleshoot-flow'),
icon: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
? Circle
: CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
? undefined
: 'green'
},
{
displayName: 'Reset tutorials',
action: () => resetAllTodos(),
icon: RefreshCw,
separatorTop: true
},
{
displayName: 'Skip tutorials',
action: () => skipAllTodos(),
icon: CheckCheck
}
]
},
{
displayName: 'Test flow & record',
icon: Disc,
@@ -1407,14 +1338,7 @@
{#if $enterpriseLicense && !newFlow && !inSessionPane}
<Awareness />
{/if}
<div class="relative">
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
{#if $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) || $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))}
<span
class="absolute top-0.5 right-0.5 block w-2 h-2 rounded-full bg-surface-accent-primary pointer-events-none"
></span>
{/if}
</div>
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
{#if diffEnabled && !diffInMenu}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
it never shows on hover. pointer-events-none on the button lets the
@@ -1550,13 +1474,6 @@
{/if}
{/key}
<FlowTutorials
bind:this={flowTutorials}
on:reload={() => {
renderCount += 1
}}
/>
<FlowAssetsHandler
modules={flowStore.val.value.modules}
enableParser
@@ -1,25 +0,0 @@
<script lang="ts">
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import FlowBuilderLiveTutorial from './tutorials/FlowBuilderLiveTutorial.svelte'
import TroubleshootFlowTutorial from './tutorials/TroubleshootFlowTutorial.svelte'
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
export function runTutorialById(id: string) {
tutorialRouter?.runTutorialById(id)
}
</script>
<TutorialRouter
bind:this={tutorialRouter}
tutorials={[
{
id: 'flow-live-tutorial',
component: FlowBuilderLiveTutorial
},
{
id: 'troubleshoot-flow',
component: TroubleshootFlowTutorial
}
]}
/>
@@ -1,25 +0,0 @@
<script lang="ts">
import { skipAllTodos } from '$lib/tutorialUtils'
import TroubleshootFlowTutorial from './tutorials/TroubleshootFlowTutorial.svelte'
import { getTutorialIndex } from '$lib/tutorials/config'
let troubleshootFlowTutorial: TroubleshootFlowTutorial | undefined = $state(undefined)
export function runTutorialById(id: string) {
if (id === 'troubleshoot-flow') {
troubleshootFlowTutorial?.runTutorial()
}
}
function skipAll() {
skipAllTodos()
}
</script>
<TroubleshootFlowTutorial
bind:this={troubleshootFlowTutorial}
index={getTutorialIndex('troubleshoot-flow')}
on:error
on:skipAll={skipAll}
on:reload
/>
@@ -1,25 +0,0 @@
<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)
export function runTutorialById(id: string) {
tutorialRouter?.runTutorialById(id)
}
</script>
<TutorialRouter
bind:this={tutorialRouter}
tutorials={[
{
id: 'workspace-onboarding',
component: WorkspaceOnboardingTutorial
},
{
id: 'workspace-onboarding-operator',
component: WorkspaceOnboardingOperatorTutorial
}
]}
/>
@@ -473,15 +473,6 @@
let appEditorHeader: AppEditorHeader | undefined = $state(undefined)
export function triggerTutorial() {
const urlParams = new URLSearchParams(window.location.search)
const tutorial = urlParams.get('tutorial')
if (tutorial) {
appEditorHeader?.runTutorialById(tutorial)
}
}
let box: HTMLElement | undefined = $state(undefined)
function parseScroll() {
$yTop = box?.scrollTop ?? 0
@@ -9,23 +9,16 @@
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import {
enterpriseLicense,
tutorialsToDo,
userStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { isMac, type Item, userPathPrefix } from '$lib/utils'
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
import { getTutorialIndex } from '$lib/tutorials/config'
import { random_adj } from '$lib/components/random_positive_adjetive'
import {
AlignHorizontalSpaceAround,
BellOff,
BookOpen,
Bug,
CheckCheck,
CheckCircle,
Circle,
DiffIcon,
Expand,
FileJson,
@@ -33,7 +26,6 @@
FormInput,
History,
Laptop2,
RefreshCw,
Save,
Smartphone,
FileClock,
@@ -61,7 +53,6 @@
import Awareness from '$lib/components/Awareness.svelte'
import { secondaryMenuLeftStore, secondaryMenuRightStore } from './settingsPanel/secondaryMenu'
import Dropdown from '$lib/components/DropdownV2.svelte'
import AppEditorTutorial from './AppEditorTutorial.svelte'
import AppReportsDrawer from './AppReportsDrawer.svelte'
import DebugPanel from './contextPanel/DebugPanel.svelte'
@@ -679,49 +670,9 @@
action: () => {
appExport?.open(toStatic($app, $staticExporter, $summary).app)
}
},
{
displayName: 'Tutorials',
icon: BookOpen,
separatorTop: true,
submenuItems: [
{
displayName: 'Background runnables',
action: () => appEditorTutorial?.runTutorialById('backgroundrunnables'),
icon: $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables'))
? Circle
: CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables'))
? undefined
: 'green'
},
{
displayName: 'Connection',
action: () => appEditorTutorial?.runTutorialById('connection'),
icon: $tutorialsToDo.includes(getTutorialIndex('connection')) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('connection')) ? undefined : 'green'
},
{
displayName: 'Reset tutorials',
action: () => resetAllTodos(),
icon: RefreshCw,
separatorTop: true
},
{
displayName: 'Skip tutorials',
action: () => skipAllTodos(),
icon: CheckCheck
}
]
}
]) as Item[]
let appEditorTutorial: AppEditorTutorial | undefined = $state(undefined)
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
appEditorTutorial?.runTutorialById(id, options)
}
let appReportingDrawerOpen = $state(false)
export function openTroubleshootPanel() {
@@ -1090,15 +1041,7 @@
</div>
{/if}
<div class="flex flex-row gap-2 justify-end items-center overflow-visible shrink-0">
<div class="relative">
<Dropdown items={moreItems} />
{#if $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables')) || $tutorialsToDo.includes(getTutorialIndex('connection'))}
<span
class="absolute top-0.5 right-0.5 block w-2 h-2 rounded-full bg-surface-accent-primary pointer-events-none"
></span>
{/if}
</div>
<AppEditorTutorial bind:this={appEditorTutorial} />
<Dropdown items={moreItems} />
<div class="{compactTopbar ? 'hidden' : 'hidden md:inline'} relative overflow-visible shrink-0">
{#if hasErrors}
@@ -1,35 +0,0 @@
<script lang="ts">
import AppTutorials from '../../AppTutorials.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
let appTutorials: AppTutorials | undefined = $state(undefined)
let targetTutorial: string | undefined = $state(undefined)
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
appTutorials?.runTutorialById(id, options)
}
</script>
<AppTutorials
bind:this={appTutorials}
on:reload
on:error={(event: CustomEvent<{ detail: string }>) => {
targetTutorial = event.detail.detail
}}
/>
<ConfirmationModal
open={targetTutorial !== undefined}
title="Tutorial error"
confirmationText="Open new tab"
on:canceled={() => {
targetTutorial = undefined
}}
on:confirmed={async () => {
window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank')
}}
>
<div class="flex flex-col w-full space-y-4">
<span> This tutorial can only be run on a new app.</span>
</div>
</ConfirmationModal>
@@ -11,7 +11,6 @@
left
} from './componentCallbacks.svelte'
import type { AppEditorContext, AppViewerContext } from '../../types'
import { isCurrentlyInTutorial } from '$lib/stores'
const { history, movingcomponents, jobsDrawerOpen, runnableJobEditorPanel } =
getContext<AppEditorContext>('AppEditorContext') as AppEditorContext
@@ -34,8 +33,7 @@
if (
(typeof classes === 'string' && classes.includes('inputarea')) ||
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!) ||
$runnableJobEditorPanel.focused ||
isCurrentlyInTutorial.val
$runnableJobEditorPanel.focused
) {
return
}
@@ -14,10 +14,9 @@
import { defaultCode } from '../component'
import WorkspaceScriptList from '../settingsPanel/mainInput/WorkspaceScriptList.svelte'
import RunnableSelector from '../settingsPanel/mainInput/RunnableSelector.svelte'
import { defaultScripts, isCurrentlyInTutorial } from '$lib/stores'
import { defaultScripts } from '$lib/stores'
import DefaultScripts from '$lib/components/DefaultScripts.svelte'
import type { Preview } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
import type { InlineScript } from '../../sharedTypes'
interface Props {
@@ -123,10 +122,7 @@
</Drawer>
<div
class={twMerge(
'flex flex-col px-4 gap-2 text-sm',
isCurrentlyInTutorial.val ? 'h-full overflow-y-clip' : ''
)}
class="flex flex-col px-4 gap-2 text-sm"
id="app-editor-empty-runnable"
>
<div class="mt-2 flex justify-between gap-4" id="app-editor-runnable-header">
@@ -7,10 +7,6 @@
import { getAllScriptNames } from '../../utils'
import PanelSection from '../settingsPanel/common/PanelSection.svelte'
import { getAppScripts } from './utils'
import AppTutorials from '$lib/components/AppTutorials.svelte'
import { tutorialsToDo } from '$lib/stores'
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
import { tutorialInProgress } from '$lib/tutorialUtils'
import DocLink from '../settingsPanel/DocLink.svelte'
import HideButton from '../settingsPanel/HideButton.svelte'
import { BG_PREFIX } from '../appUtilsCore'
@@ -37,10 +33,6 @@
}
function createBackgroundScript() {
if ($tutorialsToDo.includes(5) && !$ignoredTutorials?.includes(5) && !tutorialInProgress()) {
appTutorials?.runTutorialById('backgroundrunnables', { skipStepsCount: 2 })
}
for (const [index, script] of $app.hiddenInlineScripts.entries()) {
if (script.hidden) {
delete script.hidden
@@ -75,7 +67,6 @@
selectScript(`${BG_PREFIX}${$app.hiddenInlineScripts.length - 1}`)
}
let appTutorials: AppTutorials | undefined = $state(undefined)
const dispatch = createEventDispatcher()
let runnables = $derived(getAppScripts($app.grid, $app.subgrids))
// When selected component changes, update selectedScriptComponentId
@@ -248,5 +239,3 @@
</div>
</div>
</PanelSection>
<AppTutorials bind:this={appTutorials} on:reload />
@@ -52,11 +52,6 @@
use:clickOutside={{
eventToListenName: 'pointerdown',
stopPropagation: true,
exclude: async () => {
const tutorial = document.querySelector('#driver-popover-content') as HTMLElement | null
if (tutorial) return [tutorial]
return []
},
onClickOutside: () => (open = false)
}}
>
@@ -77,9 +77,7 @@
<svelte:window onhashchange={hashChange} />
{#if !hideTabs}
<ScrollableX class={wrapperClass}>
<!-- `scrollbar-hidden` is inert on this non-scrolling row (ScrollableX owns the
scroll), but TroubleshootFlowTutorial targets it as a selector hook — keep it. -->
<div class={twMerge('border-b flex flex-row whitespace-nowrap scrollbar-hidden', c)} {style}>
<div class={twMerge('border-b flex flex-row whitespace-nowrap', c)} {style}>
{@render children?.({ selected })}
</div>
</ScrollableX>
-1
View File
@@ -9,7 +9,6 @@ export type FlowBuilderWhitelabelCustomUi = {
export?: boolean
history?: boolean
aiBuilder?: boolean
tutorials?: boolean
diff?: boolean
extraDeployOptions?: boolean
editableSummary?: boolean
@@ -45,7 +45,6 @@
interface Props {
loading: boolean
disableStaticInputs?: boolean
disableTutorials?: boolean
disableAi?: boolean
disableSettings?: boolean
disabledFlowInputs?: boolean
@@ -89,7 +88,6 @@
let {
loading,
disableStaticInputs = false,
disableTutorials = false,
disableAi = false,
disableSettings = false,
disabledFlowInputs = false,
@@ -369,7 +367,6 @@
bind:this={flowModuleSchemaMap}
controlsPosition={compactGraphOverlay ? 'bottom' : 'top'}
{disableStaticInputs}
{disableTutorials}
{disableAi}
{disableSettings}
{smallErrorHandler}
@@ -1,62 +0,0 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import FlowTutorials from '../FlowTutorials.svelte'
import { BookOpen, CheckCircle, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
import { tutorialsToDo } from '$lib/stores'
import { getTutorialIndex } from '$lib/tutorials/config'
let flowTutorials: FlowTutorials | undefined = $state(undefined)
async function getTutorialItems() {
return [
{
displayName: 'Build a flow',
action: () => flowTutorials?.runTutorialById('flow-live-tutorial'),
index: getTutorialIndex('flow-live-tutorial'),
icon: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) ? undefined : 'green'
},
{
displayName: 'Fix a broken flow',
action: () => flowTutorials?.runTutorialById('troubleshoot-flow'),
index: getTutorialIndex('troubleshoot-flow'),
icon: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow')) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow')) ? undefined : 'green'
},
{
displayName: 'Reset tutorials',
action: () => resetAllTodos(),
icon: RefreshCw
},
{
displayName: 'Skip tutorials',
action: () => skipAllTodos(),
icon: CheckCheck
}
]
}
</script>
{#key $tutorialsToDo}
<Dropdown items={getTutorialItems}>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize="md"
variant="subtle"
iconOnly
startIcon={{ icon: BookOpen }}
/>
{/snippet}
</Dropdown>
{/key}
<FlowTutorials
bind:this={flowTutorials}
on:reload
on:error
on:skipAll
/>
@@ -128,7 +128,6 @@
{/if}
</div>
{:else}
<!-- Index 0 is used by the tutorial to identify the first "Add step" -->
<InsertModulePopover
{disableAi}
placement={'bottom'}
@@ -28,7 +28,6 @@
import { locateModules, groupByParent } from '../multiSelectUtils'
import { workspaceStore } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
import FlowTutorials from '$lib/components/FlowTutorials.svelte'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import { replaceId } from '../flowStore.svelte'
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
@@ -73,7 +72,6 @@
interface Props {
sidebarSize?: number | undefined
disableStaticInputs?: boolean
disableTutorials?: boolean
disableAi?: boolean
disableSettings?: boolean
newFlow?: boolean
@@ -104,7 +102,6 @@
let {
sidebarSize = $bindable(undefined),
disableStaticInputs = false,
disableTutorials = false,
disableAi = false,
disableSettings = false,
newFlow = false,
@@ -958,7 +955,3 @@
/>
</div>
</div>
{#if !disableTutorials}
<FlowTutorials on:reload />
{/if}
@@ -1,196 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { GraduationCap, X } from 'lucide-svelte'
import { base } from '$lib/base'
import { goto } from '$app/navigation'
import { sendUserToast, type ToastAction } from '$lib/toast'
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
import {
skipAllTodos,
syncTutorialsTodos,
TUTORIAL_BANNER_DISMISSED_KEY
} from '$lib/tutorialUtils'
import { tutorialsToDo, userStore, skippedAll } from '$lib/stores'
import { TUTORIALS_CONFIG } from '$lib/tutorials/config'
import { hasRoleAccess } from '$lib/tutorials/roleUtils'
import { onMount } from 'svelte'
type BannerState = 'hidden' | 'start' | 'new'
// Deciding what to show needs an API round-trip, so the banner paints the state the last visit
// resolved to and reconciles once the sync answers. Guessing wrong once in a while beats
// reflowing the home page on every load; nothing cached means hidden, the direction that does
// not push the page down.
const TUTORIAL_BANNER_STATE_KEY = 'tutorial_banner_state'
const cachedState =
getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true'
? 'hidden'
: getLocalSetting(TUTORIAL_BANNER_STATE_KEY)
let isDismissed = $state(cachedState !== 'start' && cachedState !== 'new')
let hasCompletedAny = $state(cachedState === 'new')
/**
* Get all tutorial indexes that are accessible to the current user based on their role.
* Automatically recomputes when $userStore changes.
*/
const accessibleTutorialIndexes = $derived.by(() => {
const indexes = new Set<number>()
const user = $userStore
for (const tab of Object.values(TUTORIALS_CONFIG)) {
// Check if user has access to this tab category
if (!hasRoleAccess(user, tab.roles)) {
continue
}
for (const tutorial of tab.tutorials) {
// Check if tutorial has an index and user has access to it
if (tutorial.index !== undefined && hasRoleAccess(user, tutorial.roles)) {
indexes.add(tutorial.index)
}
}
}
return indexes
})
function resolveState(state: BannerState) {
isDismissed = state === 'hidden'
hasCompletedAny = state === 'new'
// Last: persisting is best-effort, and a storage failure must not leave the banner stuck on
// whatever the cache said
storeLocalSetting(TUTORIAL_BANNER_STATE_KEY, state)
}
// The banner is interactive while the initial sync is still in flight, so a dismiss or a skip
// can land mid-await. Once that happens the user's choice wins and the sync must not resurrect
// the banner.
let userHidBanner = false
function hideBannerForUser() {
userHidBanner = true
resolveState('hidden')
}
onMount(async () => {
// Manually dismissed via the X button (soft dismiss, per-device). Checked before the network
// call so a dismissed banner can never flash back in.
if (getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true') {
resolveState('hidden')
return
}
try {
// Sync tutorial progress from backend first
await syncTutorialsTodos()
} catch (error) {
console.error('Failed to sync tutorial progress:', error)
// Keep whatever the last successful sync resolved to rather than guessing again
return
}
if (userHidBanner) {
return
}
// Check if user deliberately skipped all tutorials (permanent dismiss, from backend)
if ($skippedAll) {
resolveState('hidden')
return
}
// Safe to check tutorialsToDo here since we awaited syncTutorialsTodos() above
// Filter tutorialsToDo to only include tutorials accessible to the user's role
const remainingAccessibleTutorials = $tutorialsToDo.filter((index) =>
accessibleTutorialIndexes.has(index)
)
// Hide banner if all accessible tutorials are completed (but can reappear with new tutorials)
if (remainingAccessibleTutorials.length === 0) {
resolveState('hidden')
return
}
// Having completed at least one accessible tutorial switches the wording to
// "New tutorial available!" instead of "Learn with interactive tutorials"
resolveState(
remainingAccessibleTutorials.length < accessibleTutorialIndexes.size ? 'new' : 'start'
)
})
async function handleSkipAllTutorials() {
// Skip all tutorials and set skipped_all flag in backend (permanent)
await skipAllTodos()
await syncTutorialsTodos()
// No need to set the dismissed flag - backend skipped_all flag is the source of truth
hideBannerForUser()
}
function dismissBanner() {
storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
hideBannerForUser()
const actions: ToastAction[] = [
{
label: 'Skip tutorials',
callback: handleSkipAllTutorials,
buttonType: 'default'
}
]
sendUserToast(
'You can still access tutorials from the Tutorials page in the main menu or in the Help submenu.',
false,
actions,
undefined,
8000
)
}
function goToTutorials() {
goto(`${base}/tutorials`)
}
</script>
{#if !isDismissed}
<div
class="flex items-center justify-between gap-4 px-4 py-3 rounded-lg border border-light bg-surface-tertiary mb-4"
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<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">
{#if hasCompletedAny}
New tutorial available!
{:else}
Learn with interactive tutorials
{/if}
</div>
<div class="text-hint text-3xs truncate text-left font-normal">
{#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>
<div class="flex items-center gap-2 flex-shrink-0">
<Button
size="xs"
variant="accent"
onclick={goToTutorials}
startIcon={{ icon: GraduationCap }}
>
View tutorials
</Button>
<button
onclick={dismissBanner}
class="p-1.5 rounded hover:bg-surface-hover text-secondary hover:text-primary transition-colors"
aria-label="Dismiss tutorial banner"
>
<X size={16} />
</button>
</div>
</div>
{/if}
@@ -1,124 +0,0 @@
<script lang="ts">
import { CheckCircle2, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
import type { ComponentType } from 'svelte'
interface Props {
icon: ComponentType
title: string
description: string
onclick: () => void
isCompleted?: boolean
disabled?: boolean
comingSoon?: boolean
onReset?: () => void
onComplete?: () => void
}
let {
icon: Icon,
title,
description,
onclick,
isCompleted = false,
disabled = false,
comingSoon = false,
onReset,
onComplete
}: Props = $props()
let isHovered = $state(false)
// Determine which action button to show
const actionButton = $derived(() => {
if (isCompleted && isHovered && onReset) {
return {
icon: RefreshCw,
label: 'Reset',
onClick: onReset
}
}
if (!isCompleted && isHovered && onComplete) {
return {
icon: CheckCheck,
label: 'Mark as completed',
onClick: onComplete
}
}
return null
})
function handleAction(e: MouseEvent | KeyboardEvent) {
const button = actionButton()
if (!button) return
e.stopPropagation()
e.preventDefault()
button.onClick()
}
</script>
<button
onclick={disabled || comingSoon ? undefined : onclick}
disabled={disabled || comingSoon}
class="group relative flex items-center gap-4 w-full px-4 py-3 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b border-b border-light transition-colors text-left last:border-b-0 {disabled || comingSoon
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-surface-hover'}"
>
<!-- Icon -->
<Icon size={20} class="flex-shrink-0 text-accent-primary transition-colors" />
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold {!disabled && !comingSoon
? 'group-hover:text-accent-primary'
: ''} transition-colors">
{title}
{#if comingSoon}
<span class="ml-2 text-3xs text-secondary">(Coming soon)</span>
{/if}
</div>
<div class="text-hint text-3xs truncate text-left font-normal">
{description}
</div>
</div>
<!-- Status -->
<div
role="status"
class="flex items-center gap-1.5 flex-shrink-0"
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
>
{#if actionButton()}
{@const button = actionButton()!}
{@const ActionIcon = button.icon}
<div
role="button"
tabindex="0"
onclick={handleAction}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleAction(e)
}
}}
class="flex items-center gap-1.5 px-2 py-1 text-xs font-normal text-secondary hover:text-primary hover:bg-surface-hover rounded transition-colors cursor-pointer"
>
<ActionIcon size={14} class="flex-shrink-0" />
{button.label}
</div>
{:else}
<span
class="text-xs font-normal {isCompleted
? 'text-green-500'
: 'text-blue-300'}"
>
{isCompleted ? 'Completed' : 'Not started'}
</span>
{#if isCompleted}
<CheckCircle2 size={14} class="text-green-500 flex-shrink-0" />
{:else}
<Circle size={14} class="text-blue-300 flex-shrink-0" />
{/if}
{/if}
</div>
</button>
@@ -12,7 +12,6 @@
Building,
Calendar,
ServerCog,
GraduationCap,
Table2
} from 'lucide-svelte'
import { base } from '$lib/base'
@@ -25,9 +24,7 @@
superadmin,
usedTriggerKinds,
userWorkspaces,
workspaceStore,
tutorialsToDo,
skippedAll
workspaceStore
} from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { USER_SETTINGS_HASH } from './settings'
@@ -57,22 +54,10 @@
[
{ label: 'Home', id: 'home', href: `${base}/`, icon: Home },
{ label: 'Runs', id: 'runs', href: `${base}/runs`, icon: Play },
{ label: 'Schedules', id: 'schedules', href: `${base}/schedules`, icon: Calendar },
// Add Tutorials to main menu only if not all completed and not skipped
...($tutorialsToDo.length > 0 && !$skippedAll
? [
{
label: 'Tutorials',
id: 'tutorials',
href: `${base}/tutorials`,
icon: GraduationCap
}
]
: [])
{ label: 'Schedules', id: 'schedules', href: `${base}/schedules`, icon: Calendar }
].filter(
(link) =>
link.id === 'home' ||
link.id === 'tutorials' ||
($userWorkspaces &&
$workspaceStore &&
$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] ===
@@ -12,7 +12,6 @@
Building,
Moon,
Sun,
GraduationCap,
BookOpen,
Github,
Newspaper,
@@ -120,7 +119,6 @@
}
const helpItems: Item[] = [
{ displayName: 'Tutorials', icon: GraduationCap, href: `${base}/tutorials` },
{
displayName: 'Docs',
icon: BookOpen,
@@ -8,12 +8,9 @@
workspaceStore,
isCriticalAlertsUIOpen,
enterpriseLicense,
devopsRole,
tutorialsToDo,
skippedAll
devopsRole
} from '$lib/stores'
import { isForkOwner } from '$lib/utils/workspaceHierarchy'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
import {
BookOpen,
@@ -26,7 +23,6 @@
FolderCog,
FolderOpen,
Github,
GraduationCap,
HelpCircle,
Home,
LogOut,
@@ -51,7 +47,6 @@
import DiscordIcon from '../icons/brands/Discord.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { twMerge } from 'tailwind-merge'
import { onMount } from 'svelte'
import { base } from '$lib/base'
import { page } from '$app/state'
import SideBarNotification from './SideBarNotification.svelte'
@@ -116,11 +111,6 @@
'boolean'
)
onMount(async () => {
// Sync tutorial progress on mount
await syncTutorialsTodos()
})
function openChangelogs() {
markChangelogsOpened()
hasNewChangelogs = false
@@ -131,14 +121,6 @@
label: 'Help',
icon: HelpCircle,
subItems: [
{
label: 'Tutorials',
href: `${base}/tutorials`,
icon: GraduationCap,
aiId: 'sidebar-menu-link-tutorials',
aiDescription: 'Button to navigate to tutorials',
external: false
},
{
label: 'Docs',
href: 'https://www.windmill.dev/docs/intro/',
@@ -269,19 +251,7 @@
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-groups',
aiDescription: 'Button to navigate to groups'
},
// Add Tutorials to main menu only if not all completed and not skipped
...($tutorialsToDo.length > 0 && !$skippedAll
? [
{
label: 'Tutorials',
href: `${base}/tutorials`,
icon: GraduationCap,
aiId: 'sidebar-menu-link-tutorials-main',
aiDescription: 'Button to navigate to tutorials'
}
]
: [])
}
].filter((l) => !excludeMainLabels.includes(l.label))
)
let defaultExtraTriggerLinks = $derived([
@@ -1,754 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
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'
import type { Flow, FlowModule } from '$lib/gen'
import { loadFlowModuleState } from '../flows/flowStateUtils.svelte'
import { wait, type StateStore } from '$lib/utils'
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/toast'
import { updateProgress } from '$lib/tutorialUtils'
const { flowStore, flowStateStore, selectionManager, currentEditor } =
getContext<FlowEditorContext>('FlowEditorContext')
interface Props {
index: number
}
let { index }: Props = $props()
let tutorial: Tutorial | undefined = undefined
// Flags to track if steps are complete
let step2Complete = $state(false)
let step3Complete = $state(false)
let step4Complete = $state(false)
let step5Complete = $state(false)
let step6Complete = $state(false)
// Helper function to get driver overlay
function getDriverOverlay(): HTMLElement | null {
return document.querySelector('.driver-overlay') as HTMLElement | null
}
// Helper function to type text character by character
async function typeText(
input: HTMLInputElement,
text: string,
delay: number = DELAY_TYPING
): Promise<void> {
input.value = ''
input.focus()
for (let i = 0; i < text.length; i++) {
input.value += text[i]
input.dispatchEvent(new Event('input', { bubbles: true }))
await wait(delay)
}
}
// Helper function to update module summary in flowStore
function updateModuleSummary(moduleId: string, summary: string): void {
const moduleIndex = flowStore.val.value.modules.findIndex((m) => m.id === moduleId)
if (moduleIndex !== -1) {
flowStore.val.value.modules[moduleIndex].summary = summary
flowStore.val = { ...flowStore.val }
}
}
// Helper function to add module to flow
async function addModuleToFlow(module: FlowModule): Promise<void> {
const state = await loadFlowModuleState(module)
flowStateStore.val[module.id] = state
flowStore.val.value.modules.push(module)
flowStore.val = { ...flowStore.val }
}
// Helper function to find button by text and classes
function findButtonByText(text: string, classes: string[] = []): HTMLElement | null {
const buttons = Array.from(document.querySelectorAll('button'))
return buttons.find((btn) => {
const hasText = btn.textContent?.includes(text) ?? false
const hasClasses = classes.every((cls) => btn.classList.contains(cls))
return hasText && (classes.length === 0 || hasClasses)
}) as HTMLElement | null
}
// Helper function to cleanup custom overlay
function cleanupCustomOverlay(): void {
const customOverlay = document.querySelector('.tutorial-custom-overlay')
if (customOverlay) {
customOverlay.remove()
}
}
// 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 = 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 - 100
startY = endRect.top + endRect.height / 2
}
fakeCursor.style.left = `${startX}px`
fakeCursor.style.top = `${startY}px`
await wait(100)
fakeCursor.style.left = `${endRect.left + endRect.width / 2}px`
fakeCursor.style.top = `${endRect.top + endRect.height / 2}px`
await wait(transitionDuration * 1000)
return fakeCursor
}
export function runTutorial() {
tutorial?.runTutorial()
}
const flowJson: Flow = {
summary: '',
description: '',
value: {
modules: [
{
id: 'a',
value: {
type: 'rawscript',
content:
'export async function main(celsius: number) {\n // Validate that the temperature is within a reasonable range\n if (celsius < -273.15) {\n throw new Error("Temperature cannot be below absolute zero (-273.15°C)");\n }\n \n if (celsius > 1000) {\n throw new Error("Temperature seems unreasonably high. Please check your input.");\n }\n \n return {\n celsius: celsius,\n isValid: true,\n message: "Temperature is valid"\n };\n}',
language: 'bun',
input_transforms: {}
},
summary: 'Validate temperature input'
},
{
id: 'b',
value: {
type: 'rawscript',
content:
'export async function main(celsius: number) {\n // Convert Celsius to Fahrenheit using the formula: F = (C × 9/5) + 32\n const fahrenheit = (celsius * 9/5) + 32;\n \n return {\n celsius: celsius,\n fahrenheit: Math.round(fahrenheit * 100) / 100 // Round to 2 decimal places\n };\n}',
language: 'bun',
input_transforms: {
celsius: {
expr: 'results.a.celsius',
type: 'javascript'
}
}
},
summary: 'Convert to Fahrenheit'
},
{
id: 'c',
value: {
type: 'rawscript',
content:
'export async function main(celsius: number, fahrenheit: number) {\n // Categorize the temperature based on Celsius value\n let category: string;\n let emoji: string;\n \n if (celsius < 0) {\n category = "Freezing";\n emoji = "❄️";\n } else if (celsius < 10) {\n category = "Cold";\n emoji = "🥶";\n } else if (celsius < 20) {\n category = "Cool";\n emoji = "😊";\n } else if (celsius < 30) {\n category = "Warm";\n emoji = "☀️";\n } else {\n category = "Hot";\n emoji = "🔥";\n }\n \n return {\n celsius: celsius,\n fahrenheit: fahrenheit,\n category: category,\n emoji: emoji\n };\n}',
language: 'bun',
input_transforms: {
celsius: {
expr: 'results.b.celsius',
type: 'javascript'
},
fahrenheit: {
expr: 'results.b.fahrenheit',
type: 'javascript'
}
}
},
summary: 'Categorize temperature'
}
]
},
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
celsius: {
type: 'number',
description: 'Temperature in Celsius',
default: ''
}
},
required: ['celsius'],
order: ['celsius']
},
path: '',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {}
}
</script>
<Tutorial
bind:this={tutorial}
{index}
name="flow-live-tutorial"
tainted={isFlowTainted(flowStore.val)}
on:error
on:skipAll
getSteps={(driver) => {
const steps: DriveStep[] = [
{
popover: {
title: 'Build your first flow',
description:
"Let's create a temperature converter that validates input and converts Celsius to Fahrenheit.",
onNextClick: async () => {
const emptyFlow: Flow = {
summary: '',
description: '',
value: { modules: [] },
schema: flowJson.schema,
path: '',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {}
}
await initFlow(emptyFlow, flowStore as StateStore<Flow>, flowStateStore)
driver.moveNext()
}
}
},
{
element: '#flow-editor-virtual-Input',
onHighlighted: async () => {
step2Complete = false
await wait(DELAY_MEDIUM)
triggerPointerDown('#flow-editor-virtual-Input')
await wait(DELAY_SHORT)
selectionManager.selectId('Input')
await wait(200)
const overlay = getDriverOverlay()
if (overlay) {
overlay.style.width = '50%'
overlay.style.right = 'auto'
overlay.style.left = '0'
}
const celsiusInput = document.querySelector(
'input[type="number"][placeholder=""]'
) as HTMLInputElement
if (celsiusInput) {
celsiusInput.value = ''
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
await wait(DELAY_MEDIUM)
celsiusInput.value = '2'
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
await wait(400)
celsiusInput.value = '25'
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
step2Complete = true
}
},
popover: {
title: 'Set the input',
description: 'Every flow starts with input. Here we define a temperature in Celsius.',
side: 'bottom',
align: 'start',
onNextClick: () => {
if (!step2Complete) {
sendUserToast('Please wait for the input to be filled...', false, [], undefined, 3000)
return
}
driver.moveNext()
}
}
},
{
element: '#flow-editor-add-step-0',
onHighlighted: async () => {
step3Complete = false
// Animate cursor to the add step button
const button = document.querySelector('#flow-editor-add-step-0') as HTMLElement
if (button) {
const fakeCursor1 = await createFakeCursorWithStart(null, button, 1.5)
await wait(DELAY_SHORT)
button.click()
fakeCursor1.remove()
}
const overlay = getDriverOverlay()
if (overlay) {
overlay.style.display = 'none'
}
await wait(DELAY_LONG)
const spans = Array.from(document.querySelectorAll('span'))
const bunSpan = spans.find((span) =>
span.textContent?.includes('TypeScript (Bun)')
) as HTMLElement
if (bunSpan) {
// Animate cursor from add step button to TypeScript (Bun) span
const fakeCursor2 = await createFakeCursorWithStart(button, bunSpan, 1.5)
await wait(DELAY_MEDIUM)
fakeCursor2.remove()
// Automatically trigger next step after cursor animation
await wait(DELAY_SHORT)
// Add module with empty summary and empty content
const moduleData = flowJson.value.modules[0]
const module: FlowModule = {
id: moduleData.id,
summary: '', // Start with empty summary
value: moduleData.value
}
// Clear content after module creation if it's a rawscript
if ('content' in module.value) {
module.value = { ...module.value, content: '' } as typeof module.value
}
await addModuleToFlow(module)
await wait(700)
// Restore overlay
const overlay = getDriverOverlay()
if (overlay) {
overlay.style.display = ''
}
step3Complete = true
driver.moveNext()
}
},
popover: {
title: 'Choose TypeScript',
description: 'Pick TypeScript (Bun) to write our validation script.',
side: 'top',
onNextClick: () => {
if (!step3Complete) {
sendUserToast(
'Please wait for the script to be created...',
false,
[],
undefined,
3000
)
return
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#a',
onHighlighted: async () => {
// Reset the flag when step starts
step4Complete = false
selectionManager.selectId('a')
await wait(DELAY_LONG)
const overlay = getDriverOverlay()
if (overlay) {
overlay.style.width = '50%'
overlay.style.right = 'auto'
overlay.style.left = '0'
}
// First, type the summary
await wait(DELAY_MEDIUM)
const summaryInput = document.querySelector(
'input[placeholder="Summary"]'
) as HTMLInputElement
if (summaryInput) {
const summaryText = 'Validate temperature input'
await typeText(summaryInput, summaryText)
updateModuleSummary('a', summaryText)
await wait(DELAY_LONG)
}
// Then, type the code
let editorState = get(currentEditor)
let attempts = 0
while (attempts < 20) {
if (editorState && editorState.type === 'script' && editorState.stepId === 'a') {
break
}
await wait(100)
editorState = get(currentEditor)
attempts++
}
if (editorState && editorState.type === 'script') {
const editor = editorState.editor
const moduleA = flowJson.value.modules.find((m) => m.id === 'a')
const codeToType =
moduleA?.value && 'content' in moduleA.value ? moduleA.value.content : ''
if (codeToType) {
editor.setCode('', true)
await wait(200)
let currentText = ''
for (let i = 0; i < codeToType.length; i++) {
const char = codeToType[i]
currentText += char
editor.setCode(currentText, true)
const delay = char === '\n' ? DELAY_CODE_NEWLINE : DELAY_CODE_CHAR
await wait(delay)
}
// Update the flow store with the typed code
const moduleIndex = flowStore.val.value.modules.findIndex((m) => m.id === 'a')
if (
moduleIndex !== -1 &&
'content' in flowStore.val.value.modules[moduleIndex].value
) {
flowStore.val.value.modules[moduleIndex].value = {
...flowStore.val.value.modules[moduleIndex].value,
content: codeToType
}
flowStore.val = { ...flowStore.val }
}
// Press Enter after finishing typing
await wait(DELAY_MEDIUM)
const model = editor.getModel()
if (model && 'setValue' in model) {
model.setValue(currentText + '\n')
}
// Mark step 4 as complete
step4Complete = true
}
}
},
popover: {
title: 'Add validation logic',
description: 'Watch as we write code to validate the temperature input.',
side: 'bottom',
onNextClick: () => {
// Only proceed if code writing is complete
if (!step4Complete) {
sendUserToast(
'Please wait for the code to finish typing...',
false,
[],
undefined,
3000
)
return
}
const driverOverlay = getDriverOverlay()
if (driverOverlay) {
driverOverlay.style.display = 'none'
}
const customOverlay = document.createElement('div')
customOverlay.className = 'tutorial-custom-overlay'
customOverlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
pointer-events: none;
clip-path: polygon(
0 0, 100% 0, 100% 50%, 50% 50%, 50% 100%, 0 100%
);
`
document.body.appendChild(customOverlay)
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
onHighlighted: async () => {
step5Complete = false
// Create a single cursor that will move continuously
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)
// Step 1: Move to and click plug button
document.querySelector('#flow-editor-plug')?.parentElement?.classList.remove('opacity-0')
await wait(DELAY_SHORT)
const plugButton = document.querySelector('#flow-editor-plug') as HTMLElement
if (plugButton) {
const plugRect = plugButton.getBoundingClientRect()
// Start from off-screen left
fakeCursor.style.left = `${plugRect.left - 100}px`
fakeCursor.style.top = `${plugRect.top + plugRect.height / 2}px`
await wait(DELAY_SHORT)
// Move to plug button
fakeCursor.style.left = `${plugRect.left + plugRect.width / 2}px`
fakeCursor.style.top = `${plugRect.top + plugRect.height / 2}px`
await wait(DELAY_ANIMATION)
await wait(DELAY_MEDIUM)
clickButtonBySelector('#flow-editor-plug')
}
await wait(DELAY_MEDIUM)
// Step 2: Move to and click flow_input.celsius
const targetButton = document.querySelector(
'button[title="flow_input.celsius"]'
) as HTMLElement
if (targetButton) {
await moveCursorToElement(fakeCursor, targetButton, DELAY_ANIMATION_LONG)
await wait(DELAY_MEDIUM)
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window
})
targetButton.dispatchEvent(clickEvent)
}
await wait(DELAY_LONG)
// Step 3: Move to and click Test this step tab
const testTabButton = findButtonByText('Test this step', ['border-b-2', 'cursor-pointer'])
if (testTabButton) {
await moveCursorToElement(fakeCursor, testTabButton, DELAY_ANIMATION)
await wait(DELAY_SHORT)
testTabButton.click()
}
await wait(DELAY_LONG)
// Step 4: Move to and click Run button
const testActionButton = findButtonByText('Run', ['bg-surface-accent-primary', 'w-full'])
if (testActionButton) {
await moveCursorToElement(fakeCursor, testActionButton, DELAY_ANIMATION)
await wait(DELAY_MEDIUM)
testActionButton.click()
await wait(DELAY_MEDIUM)
}
// Remove cursor at the end
fakeCursor.remove()
step5Complete = true
},
popover: {
title: 'Wire it up and test',
description: 'Connect the input, then run a quick test to verify the validation works.',
onNextClick: async () => {
if (!step5Complete) {
sendUserToast('Please wait for the test to complete...', false, [], undefined, 3000)
return
}
cleanupCustomOverlay()
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
onHighlighted: async () => {
step6Complete = false
// First, add modules b and c with empty summaries
const modulesToAdd = [flowJson.value.modules[1], flowJson.value.modules[2]]
for (let i = 0; i < modulesToAdd.length; i++) {
await new Promise((resolve) => setTimeout(resolve, i === 0 ? 0 : 700))
const moduleData = modulesToAdd[i]
const module: FlowModule = {
id: moduleData.id,
summary: '', // Start with empty summary
value: moduleData.value
}
await addModuleToFlow(module)
}
await wait(700)
// Create a single cursor for continuous movement
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)
// Step 1: Click on script 'b'
await wait(DELAY_MEDIUM)
const scriptB = document.querySelector('#b') as HTMLElement
if (scriptB) {
const bRect = scriptB.getBoundingClientRect()
// Start from off-screen
fakeCursor.style.left = `${bRect.left - 100}px`
fakeCursor.style.top = `${bRect.top + bRect.height / 2}px`
await wait(DELAY_SHORT)
// Move to script b
fakeCursor.style.left = `${bRect.left + bRect.width / 2}px`
fakeCursor.style.top = `${bRect.top + bRect.height / 2}px`
await wait(DELAY_ANIMATION)
await wait(DELAY_MEDIUM)
selectionManager.selectId('b')
}
await wait(DELAY_LONG)
// Type summary for script 'b'
const summaryInputB = document.querySelector(
'input[placeholder="Summary"]'
) as HTMLInputElement
if (summaryInputB) {
const summaryTextB = 'Convert to Fahrenheit'
await typeText(summaryInputB, summaryTextB)
updateModuleSummary('b', summaryTextB)
await wait(DELAY_LONG)
}
// Step 2: Move to and click on script 'c'
const scriptC = document.querySelector('#c') as HTMLElement
if (scriptC) {
await moveCursorToElement(fakeCursor, scriptC, DELAY_ANIMATION)
await wait(DELAY_SHORT)
selectionManager.selectId('c')
}
await wait(DELAY_LONG)
// Type summary for script 'c'
const summaryInputC = document.querySelector(
'input[placeholder="Summary"]'
) as HTMLInputElement
if (summaryInputC) {
const summaryTextC = 'Categorize temperature'
await typeText(summaryInputC, summaryTextC)
updateModuleSummary('c', summaryTextC)
await wait(DELAY_LONG)
}
// Move cursor to Test Flow button
const testFlowButton = document.querySelector('#flow-editor-test-flow') as HTMLElement
if (testFlowButton) {
await moveCursorToElement(fakeCursor, testFlowButton, DELAY_ANIMATION)
await wait(DELAY_MEDIUM)
}
// Remove cursor at the end
fakeCursor.remove()
step6Complete = true
},
popover: {
title: 'Add the final steps',
description: 'Two more scripts to convert and categorize the temperature.',
onNextClick: () => {
if (!step6Complete) {
sendUserToast(
'Please wait for the summaries to be added...',
false,
[],
undefined,
3000
)
return
}
// Reset the driver.js overlay to full screen
const driverOverlay = getDriverOverlay()
if (driverOverlay) {
driverOverlay.style.display = ''
driverOverlay.style.width = ''
driverOverlay.style.right = ''
driverOverlay.style.left = ''
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#flow-editor-test-flow',
popover: {
title: 'Ready to test!',
description:
'Run the complete flow and see your temperature converter in action.<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 or in the <strong>Help</strong> submenu.</p>',
onNextClick: () => {
updateProgress(index)
driver.destroy()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
}
]
return steps
}}
/>
@@ -1,510 +0,0 @@
<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: '#runs-chart',
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: '#runs-chart',
onHighlighted: async () => {
step5Complete = false
await wait(DELAY_MEDIUM)
// Switch to the Concurrency chart tab
const concurrencyButton = document.querySelector(
'#runs-chart-concurrency-tab'
) 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: '#status',
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: '#runs-filters-bar',
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)
}}
/>
@@ -1,32 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import Button from '../common/button/Button.svelte'
import { CheckCircle } from 'lucide-svelte'
const dispatch = createEventDispatcher()
</script>
<div class="flex flex-row gap-2 justify-end w-full pt-6 pb-2">
<Button
size="xs"
startIcon={{ icon: CheckCircle }}
variant="default"
btnClasses="font-normal"
on:click={() => {
dispatch('skipThis')
}}
>
Mark this tutorial as completed
</Button>
<Button
size="xs"
startIcon={{ icon: CheckCircle }}
btnClasses="font-normal"
variant="default"
on:click={() => {
dispatch('skipAll')
}}
>
Mark all tutorials as completed
</Button>
</div>
@@ -1,441 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import Tutorial from './Tutorial.svelte'
import type { DriveStep } from 'driver.js'
import { initFlow } from '../flows/flowStore.svelte'
import type { Flow } from '$lib/gen'
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
}
let { index }: Props = $props()
const { flowStore, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
let tutorial: Tutorial | undefined = undefined
// Flags to track if steps are complete
let stepComplete = $state<Record<number, boolean>>({
1: false,
2: false,
3: false,
4: false,
5: false,
6: false,
7: false
})
// Constants for cursor animation
const CURSOR_START_OFFSET = -100
const CURSOR_CLICK_SCALE = 0.8
// DOM Selectors
const SELECTORS = {
testFlowButton: '#flow-editor-test-flow',
testFlowDrawer: '#flow-editor-test-flow-drawer',
flowPreviewContent: '#flow-preview-content',
stepB: '#b'
} as const
// Text constants
const TEXT = {
convertToFahrenheit: 'Convert to Fahrenheit'
} as const
// Helper function to check if step is complete
function checkStepComplete(step: number): boolean {
if (!stepComplete[step]) {
sendUserToast('Please wait...', false, [], undefined, 3000)
return false
}
return true
}
// 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 = 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 get element by selector (handles both querySelector and getElementById)
function getElementBySelector(selector: string): HTMLElement | null {
// If selector starts with #, try getElementById first, then fallback to querySelector
if (selector.startsWith('#')) {
const id = selector.slice(1)
return document.getElementById(id) || document.querySelector(selector)
}
return document.querySelector(selector) as HTMLElement | null
}
// Helper function to animate a fake cursor click
async function animateFakeCursorClick(
element: HTMLElement,
transitionDuration: number = 1.5,
options?: { usePointerEvents?: boolean }
): Promise<void> {
const fakeCursor = await createFakeCursorWithStart(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 find close button in drawer
function findCloseButton(drawer: HTMLElement): HTMLElement | null {
return Array.from(drawer.querySelectorAll('button')).find(btn => {
const svg = btn.querySelector('svg.lucide-x')
return svg !== null
}) as HTMLElement | null
}
// Helper function to find button by text
function findButtonByText(container: HTMLElement, text: string): HTMLElement | null {
const buttons = Array.from(container.querySelectorAll('button'))
return buttons.find(btn => btn.textContent?.includes(text)) as HTMLElement | null
}
export async function runTutorial() {
// Load the pre-built flow immediately when tutorial starts
await initFlow(preBuiltFlow, flowStore as StateStore<Flow>, flowStateStore)
await wait(DELAY_MEDIUM)
// Set the celsius input to 25
await wait(DELAY_SHORT)
const celsiusInput = document.querySelector('input[type="number"]') as HTMLInputElement
if (celsiusInput) {
celsiusInput.value = '25'
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
}
tutorial?.runTutorial()
}
// Pre-built flow - same as the flow builder tutorial result
const preBuiltFlow: Flow = {
summary: 'Temperature Converter',
description: 'Convert Celsius to Fahrenheit and categorize the temperature',
value: {
modules: [
{
id: 'a',
value: {
type: 'rawscript',
content:
'export async function main(celsius: number) {\n // Validate that the temperature is within a reasonable range\n if (celsius < -273.15) {\n throw new Error("Temperature cannot be below absolute zero (-273.15°C)");\n }\n \n if (celsius > 1000) {\n throw new Error("Temperature seems unreasonably high. Please check your input.");\n }\n \n return {\n celsius: celsius,\n isValid: true,\n message: "Temperature is valid"\n };\n}',
language: 'bun',
input_transforms: {
celsius: {
expr: 'flow_input.celsius',
type: 'javascript'
}
}
},
summary: 'Validate temperature input'
},
{
id: 'b',
value: {
type: 'rawscript',
content:
'export async function main(celsius: number) {\n // Convert Celsius to Fahrenheit using the formula: F = (C × 9/5) + 32\n const fahrenheit = (celsius * 9/5) + 32;\n \n return {\n celsius: celsiu,\n fahrenheit: Math.round(fahrenheit * 100) / 100 // Round to 2 decimal places\n };\n}',
language: 'bun',
input_transforms: {
celsius: {
expr: 'results.a.celsius',
type: 'javascript'
}
}
},
summary: 'Convert to Fahrenheit'
},
{
id: 'c',
value: {
type: 'rawscript',
content:
'export async function main(celsius: number, fahrenheit: number) {\n // Categorize the temperature based on Celsius value\n let category: string;\n let emoji: string;\n \n if (celsius < 0) {\n category = "Freezing";\n emoji = "❄️";\n } else if (celsius < 10) {\n category = "Cold";\n emoji = "🥶";\n } else if (celsius < 20) {\n category = "Cool";\n emoji = "😊";\n } else if (celsius < 30) {\n category = "Warm";\n emoji = "☀️";\n } else {\n category = "Hot";\n emoji = "🔥";\n }\n \n return {\n celsius: celsius,\n fahrenheit: fahrenheit,\n category: category,\n emoji: emoji\n };\n}',
language: 'bun',
input_transforms: {
celsius: {
expr: 'results.b.celsius',
type: 'javascript'
},
fahrenheit: {
expr: 'results.b.fahrenheit',
type: 'javascript'
}
}
},
summary: 'Categorize temperature'
}
]
},
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
celsius: {
type: 'number',
description: 'Temperature in Celsius',
default: 25
}
},
required: ['celsius'],
order: ['celsius']
},
path: '',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {}
}
</script>
<Tutorial
bind:this={tutorial}
index={index}
name="troubleshoot-flow"
tainted={false}
on:error
on:skipAll
getSteps={(driver) => {
const steps: DriveStep[] = [
{
popover: {
title: '🛠️ Troubleshoot a broken flow',
description:
'We created a flow that is a temperature converter that validates input and converts Celsius to Fahrenheit. For this tutorial, our flow is intentionally broken.',
onNextClick: () => {
driver.moveNext()
}
}
},
{
element: SELECTORS.testFlowButton,
onHighlighted: async () => {
stepComplete[1] = false
await wait(DELAY_SHORT)
stepComplete[1] = true
},
popover: {
title: 'Test our flow',
description:
'Let\'s run it so you can see what needs to be fixed.',
side: 'bottom',
onNextClick: async () => {
if (!checkStepComplete(1)) return
// Click the Test Flow button to open the drawer
const testFlowButton = document.querySelector(SELECTORS.testFlowButton) as HTMLElement
if (testFlowButton) {
testFlowButton.click()
await wait(DELAY_LONG)
}
driver.moveNext()
}
}
},
{
element: SELECTORS.testFlowDrawer,
onHighlighted: async () => {
stepComplete[2] = false
await wait(DELAY_SHORT)
stepComplete[2] = true
},
popover: {
title: 'Run the flow',
description:
'Click "Next" to execute the flow. We\'ll use the results to troubleshoot the error.',
side: 'left',
onNextClick: async () => {
if (!checkStepComplete(2)) return
// Click the Test button to execute the flow
const testButton = document.querySelector(SELECTORS.testFlowDrawer) as HTMLElement
if (testButton) {
testButton.click()
}
await wait(DELAY_LONG)
driver.moveNext()
}
}
},
{
element: '.border.rounded-md.shadow.p-2',
onHighlighted: async () => {
stepComplete[3] = false
await wait(DELAY_SHORT)
stepComplete[3] = true
},
popover: {
title: 'Review the error',
description:
'Our flow failed. Let\'s review the error and understand what happened.',
side: 'left',
onNextClick: () => {
if (!checkStepComplete(3)) return
driver.moveNext()
}
}
},
{
element: '.border-b.flex.flex-row.whitespace-nowrap.scrollbar-hidden.mx-auto',
onHighlighted: async () => {
stepComplete[4] = false
await wait(DELAY_SHORT)
stepComplete[4] = true
},
popover: {
title: 'Explore the tabs',
description:
'Use these tabs to navigate between different views: Result, Logs, and Graph. We\'ll focus on the Graph tab to review the error.',
side: 'bottom',
onNextClick: () => {
if (!checkStepComplete(4)) return
driver.moveNext()
}
}
},
{
element: '.grid.grid-cols-3.border.h-full',
onHighlighted: async () => {
stepComplete[5] = false
await wait(DELAY_SHORT)
// Find the step 'b' button inside the drawer and click it with fake cursor
const flowPreviewContent = getElementBySelector(SELECTORS.flowPreviewContent)
if (flowPreviewContent) {
const stepButton = findButtonByText(flowPreviewContent, TEXT.convertToFahrenheit)
if (stepButton) {
await animateFakeCursorClick(stepButton, 1.5, { usePointerEvents: true })
await wait(DELAY_MEDIUM)
}
}
stepComplete[5] = true
},
popover: {
title: 'Inspect the flow graph',
description:
'B step failed during the run. Let\'s take a closer look at its behavior.',
side: 'top',
onNextClick: () => {
if (!checkStepComplete(5)) return
driver.moveNext()
}
}
},
{
element: '.rounded-md.grow.bg-surface-tertiary.text-xs.flex.flex-col.max-h-screen.gap-2.overflow-hidden.border',
onHighlighted: async () => {
stepComplete[6] = false
await wait(DELAY_SHORT)
stepComplete[6] = true
},
popover: {
title: 'Error spotted!',
description:
'We made a typo in the code. Let\'s fix it and run the flow again.',
side: 'left',
onNextClick: async () => {
if (!checkStepComplete(6)) return
// Click the close button inside the drawer
const drawer = getElementBySelector(SELECTORS.flowPreviewContent)
if (drawer) {
const closeButton = findCloseButton(drawer)
if (closeButton) {
await animateFakeCursorClick(closeButton, 1.5)
}
}
await wait(DELAY_LONG)
driver.moveNext()
}
}
},
{
element: SELECTORS.stepB,
onHighlighted: async () => {
stepComplete[7] = false
await wait(DELAY_SHORT)
// Click on div id="b" to open the editor
const stepBDiv = getElementBySelector(SELECTORS.stepB)
if (stepBDiv) {
await animateFakeCursorClick(stepBDiv, 1.5)
await wait(DELAY_LONG)
}
stepComplete[7] = true
},
popover: {
title: 'Your turn now!',
description:
'Fix the issue in the code, and run the flow again to confirm everything works.<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 or in the <strong>Help</strong> submenu.</p>',
side: 'top',
onNextClick: () => {
if (!checkStepComplete(7)) return
updateProgress(index)
driver.destroy()
}
}
}
]
return steps
}}
/>
@@ -1,157 +0,0 @@
<script lang="ts">
import { driver, type Driver, type DriveStep } from 'driver.js'
import { createEventDispatcher, mount } from 'svelte'
import { updateProgress } from '$lib/tutorialUtils'
import { ignoredTutorials } from './ignoredTutorials'
import SkipTutorials from './SkipTutorials.svelte'
import TutorialControls from './TutorialControls.svelte'
import TutorialInner from './TutorialInner.svelte'
import { isCurrentlyInTutorial } from '$lib/stores'
type Options = {
indexToInsertAt?: number
skipStepsCount?: number
}
interface Props {
index?: number;
name?: string;
tainted?: boolean;
onDestroyed?: (() => void) | undefined;
getSteps?: (driver: Driver, options?: Options | undefined) => DriveStep[];
}
let {
index = 0,
name = 'action',
tainted = false,
onDestroyed = undefined,
getSteps = () => []
}: Props = $props();
let totalSteps = 0
let tutorial: Driver | undefined = $state(undefined)
const dispatch = createEventDispatcher()
// Render controls needs to be exposed so steps that have a custom render can call it
export function renderControls({ config, state }) {
const popoverContent = document.querySelector('#driver-popover-content')
popoverContent?.addEventListener('pointerdown', (event) => {
event.stopPropagation()
})
const popoverDescription = document.querySelector('#driver-popover-description')
if (!tutorial) {
return
}
if (state.activeIndex == 0) {
const div = document.createElement('div')
mount(SkipTutorials, {
target: div,
events: {
skipAll: () => {
dispatch('skipAll')
tutorial?.destroy()
},
skipThis: () => {
updateProgress(index)
tutorial?.destroy()
}
}
})
if (popoverDescription) {
popoverDescription.appendChild(div)
}
}
const controls = document.createElement('div')
mount(TutorialControls, {
target: controls,
props: {
activeIndex: state.activeIndex,
totalSteps
},
events: {
next: () => {
const step = tutorial?.getActiveStep()
if (step) {
if (tutorial?.getActiveStep()?.popover?.onNextClick) {
const activeElement = tutorial?.getActiveElement()
tutorial?.getActiveStep()?.popover?.onNextClick?.(activeElement, step, {
config,
state,
driver: tutorial
})
} else {
tutorial?.moveNext()
}
}
},
previous: () => {
const step = tutorial?.getActiveStep()
if (step) {
if (tutorial?.getActiveStep()?.popover?.onPrevClick) {
const activeElement = tutorial?.getActiveElement()
tutorial?.getActiveStep()?.popover?.onPrevClick?.(activeElement, step, {
config,
state,
driver: tutorial
})
} else {
tutorial?.movePrevious()
}
}
}
}
})
if (popoverDescription) {
popoverDescription.appendChild(controls)
}
}
export const runTutorial = (options?: Options | undefined) => {
if (tainted) {
dispatch('error', { detail: name })
return
}
isCurrentlyInTutorial.val = true
tutorial = driver({
allowClose: true,
disableActiveInteraction: true,
showButtons: ['close'],
showProgress: false,
overlayColor: 'rgba(0, 0, 0, 0.8)',
onPopoverRender: (popover, { config, state }) => {
renderControls({ config, state })
},
onDestroyed: () => {
onDestroyed?.()
if (!tutorial?.hasNextStep()) {
$ignoredTutorials = Array.from(new Set([...$ignoredTutorials, index]))
}
isCurrentlyInTutorial.val = false
}
})
const steps = getSteps(tutorial, options)
totalSteps = steps.length
tutorial.setSteps(steps)
tutorial.drive()
}
</script>
{#if tutorial}
<TutorialInner />
{/if}
@@ -1,53 +0,0 @@
<script lang="ts">
import { ArrowLeft, ArrowRight } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { createEventDispatcher } from 'svelte'
import Alert from '../common/alert/Alert.svelte'
interface Props {
activeIndex?: number | undefined;
totalSteps?: number | undefined;
}
let { activeIndex = undefined, totalSteps = undefined }: Props = $props();
const dispatch = createEventDispatcher()
</script>
<div class="flex flex-col gap-4 w-full pt-4">
{#if activeIndex === 0}
<Alert size="xs" title="Help">
<li> UI is not interactive during tutorial, press next at every step </li>
<li> You can use the arrow keys to navigate </li>
</Alert>
{/if}
<div class="flex flex-row gap-2 justify-between w-full items-center">
{#if activeIndex !== undefined && totalSteps !== undefined}
<div class="text-xs">
Step {activeIndex + 1} of {totalSteps}
</div>
{/if}
<div class="flex flex-row gap-2">
<Button
size="xs2"
color="light"
startIcon={{ icon: ArrowLeft }}
on:click={() => {
dispatch('previous')
}}
>
Previous
</Button>
<Button
size="xs2"
variant="accent"
endIcon={{ icon: ArrowRight }}
on:click={() => {
dispatch('next')
}}
>
Next
</Button>
</div>
</div>
</div>
@@ -1,3 +0,0 @@
<script lang="ts">
import 'driver.js/dist/driver.css'
</script>
@@ -1,29 +0,0 @@
<script lang="ts">
interface Props {
completed: number
total: number
label?: string
}
let { completed, total, label = 'tutorials' }: Props = $props()
const progressPercentage = $derived(
total > 0 ? Math.round((completed / total) * 100) : 0
)
</script>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-2 gap-2">
<div class="text-xs font-semibold text-emphasis whitespace-nowrap">
Progress: {completed} of {total} {label} completed
</div>
<div class="text-xs font-normal text-secondary flex-shrink-0">{progressPercentage}%</div>
</div>
<div class="w-full h-2 bg-surface-secondary rounded-full overflow-hidden">
<div
class="h-full bg-surface-accent-primary transition-all duration-300 ease-out rounded-full"
style="width: {progressPercentage}%"
></div>
</div>
</div>
@@ -1,64 +0,0 @@
<script lang="ts">
import { skipAllTodos } from '$lib/tutorialUtils'
import TutorialWrapper from './TutorialWrapper.svelte'
interface TutorialDefinition {
id: string
component: any // Svelte component type - using any to avoid complex type issues
name?: string // Optional name prop (used by some tutorials like AppTutorials)
supportsSkipSteps?: boolean // Whether runTutorial accepts skipStepsCount parameter
}
interface Props {
tutorials: TutorialDefinition[]
}
let { tutorials }: Props = $props()
// Map tutorial IDs to their component instances
const tutorialInstances = new Map<
string,
{ runTutorial: (options?: number) => void } | { runTutorial: () => void } | undefined
>()
function skipAll() {
skipAllTodos()
}
// Helper function to register a tutorial instance
function registerInstance(id: string, instance: any) {
tutorialInstances.set(id, instance)
}
// Export function to run tutorial by ID
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
const instance = tutorialInstances.get(id)
if (!instance) {
console.warn(`Tutorial instance not found for id: ${id}`)
return
}
// Check if this tutorial supports skipStepsCount
const tutorial = tutorials.find((t) => t.id === id)
if (tutorial?.supportsSkipSteps && options?.skipStepsCount !== undefined) {
// Type assertion needed because TypeScript can't narrow the union type
;(instance as { runTutorial: (options?: number) => void }).runTutorial(options.skipStepsCount)
} else {
// Call runTutorial without parameters
if ('runTutorial' in instance && typeof instance.runTutorial === 'function') {
instance.runTutorial()
}
}
}
</script>
{#each tutorials as tutorial}
<TutorialWrapper
id={tutorial.id}
component={tutorial.component}
name={tutorial.name}
onInstanceReady={registerInstance}
onSkipAll={skipAll}
/>
{/each}
@@ -1,36 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte'
import { getTutorialIndex } from '$lib/tutorials/config'
interface Props {
id: string
component: any // Svelte component type - using any to avoid complex type issues
name?: string
onInstanceReady: (id: string, instance: any) => void
onSkipAll: () => void
}
let { id, component: Component, name, onInstanceReady, onSkipAll }: Props = $props()
let instance: any = $state(undefined)
const index = getTutorialIndex(untrack(() => id))
$effect(() => {
if (instance) {
onInstanceReady(id, instance)
}
})
</script>
{#if Component}
{@const Comp = Component}
<Comp
bind:this={instance}
{index}
{...(name ? { name } : {})}
on:error
on:skipAll={onSkipAll}
on:reload
/>
{/if}
@@ -1,91 +0,0 @@
<script lang="ts">
import { updateProgress } from '$lib/tutorialUtils'
import { type DriveStep } from 'driver.js'
import Tutorial from '../Tutorial.svelte'
import { clickButtonBySelector } from '../utils'
interface Props {
name: string;
index: number;
}
let { name, index }: Props = $props();
let tutorial: Tutorial | undefined = $state(undefined)
export function runTutorial(skipStepsCount: number | undefined = undefined) {
tutorial?.runTutorial({ skipStepsCount })
}
</script>
<Tutorial
bind:this={tutorial}
{index}
{name}
on:error
on:skipAll
getSteps={(driver, options) => {
const steps: DriveStep[] = [
{
element: '#app-editor-runnable-panel',
popover: {
title: 'Runnable panel',
description:
'This is the runnable panel. Here you can add runnables to your app. Runnables are scripts that can be executed in the background. You can add as many runnables as you want.'
}
},
{
element: '#create-background-runnable',
popover: {
title: 'Create a runnable',
description:
'Click here to create a runnable. Runnables are scripts that can be executed in the background. You can add as many runnables as you want.',
onNextClick: () => {
clickButtonBySelector('#create-background-runnable')
setTimeout(() => driver.moveNext())
}
}
},
{
element: '#app-editor-empty-runnable',
popover: {
title: 'Empty runnable panel',
description:
'This is the empty runnable panel. Here you can add runnables to your app. Runnables are scripts that can be executed in the background. You can add as many runnables as you want. You can also select a script or a flow from your workspace or the Hub.'
}
},
{
element: '#app-editor-backend-runnables',
popover: {
title: 'Backend runnables',
description:
'Backend runnables are scripts that are executed on the server. They can be used to perform tasks that are not possible to be performed on the client. For example, you can use backend runnables to send emails, perform database operations, etc.'
}
},
{
element: '#app-editor-frontend-runnables',
popover: {
title: 'Frontend runnables',
description:
'Frontend scripts are executed in the browser and can manipulate the app context directly. You can also interact with components using component controls.',
onNextClick: () => {
setTimeout(() => {
driver.moveNext()
updateProgress(index)
})
}
}
}
]
// Remove steps if we want to skip them (excpet the first one)
if (options?.skipStepsCount) {
steps.splice(1, options.skipStepsCount)
}
return steps
}}
/>
@@ -1,128 +0,0 @@
<script lang="ts">
import { insertNewGridItem, appComponentFromType } from '$lib/components/apps/editor/appUtils'
import type { AppComponent } from '$lib/components/apps/editor/component'
import type { AppViewerContext, AppEditorContext } from '$lib/components/apps/types'
import { push } from '$lib/history.svelte'
import { getContext } from 'svelte'
import Tutorial from '../Tutorial.svelte'
import { clickButtonBySelector } from '../utils'
import { updateProgress } from '$lib/tutorialUtils'
interface Props {
name: string;
index: number;
}
let { name, index }: Props = $props();
let tutorial: Tutorial | undefined = $state(undefined)
const { app, selectedComponent, focusedGrid } = getContext<AppViewerContext>('AppViewerContext')
const { history } = getContext<AppEditorContext>('AppEditorContext')
export function runTutorial() {
tutorial?.runTutorial()
}
function addComponent(): void {
push(history, $app)
const id = insertNewGridItem(
$app,
appComponentFromType('textcomponent') as (id: string) => AppComponent,
$focusedGrid
)
$selectedComponent = [id]
$app = $app
}
</script>
<Tutorial
bind:this={tutorial}
{index}
{name}
on:error
on:skipAll
getSteps={(driver) => [
{
popover: {
title: 'Connection tutorial',
description: 'We will connect the input of a text component to an output.',
onNextClick: () => {
addComponent()
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: `#component-input`,
popover: {
title: 'Data source',
description:
'Here we can set the data source of the text component: it can be static, the result of an evaluation or the result of script or flow. We are going to connect the data source to an output.',
onNextClick: () => {
clickButtonBySelector('#component-input')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '[data-connection-button] button[title="Connect"]',
popover: {
title: 'Connect the text component',
description: 'Click on the plug icon to connect the text component',
onNextClick: () => {
clickButtonBySelector('[data-connection-button] button[title="Connect"]')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#output-ctx',
popover: {
title: 'Select the output',
description:
"You can now select the output in the output menu. Let's select your email in the app context",
onNextClick: () => {
clickButtonBySelector('#output-ctx')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '.val',
popover: {
title: 'Click on the output',
description: 'Simply click on the output to connect it',
onNextClick: () => {
clickButtonBySelector('.val')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
popover: {
title: 'Connection done',
description: 'You can now see the email output connected to the text component input',
onNextClick: () => {
updateProgress(index)
setTimeout(() => {
driver.moveNext()
})
}
}
}
]}
/>
@@ -1,33 +0,0 @@
<script lang="ts">
import Tutorial from '../Tutorial.svelte'
interface Props {
name: string;
index: number;
}
let { name, index }: Props = $props();
let tutorial: Tutorial | undefined = $state(undefined)
export function runTutorial() {
tutorial?.runTutorial()
}
</script>
<Tutorial
bind:this={tutorial}
{index}
{name}
on:error
on:skipAll
getSteps={(driver) => [
{
popover: {
title: 'Expression evaluation tutorial',
description:
'Learn how to build our first branch to be executed on a condition. You can use arrow keys to navigate'
}
}
]}
/>
@@ -1,3 +0,0 @@
import { writable } from 'svelte/store'
export const ignoredTutorials = writable<number[]>([])
@@ -1,328 +0,0 @@
import type { FlowModule, OpenFlow } from '$lib/gen'
import { deepEqual } from 'fast-equals'
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
if (input) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
}
export function clickButtonBySelector(selector: string) {
const button = document.querySelector(selector) as HTMLButtonElement
if (button) {
button.click()
}
}
export function clickFirstButtonBySelector(selector: string) {
const buttons = document.querySelector(selector)
const button = buttons?.childNodes[0] as HTMLButtonElement
if (button) {
button.click()
}
}
export function triggerPointerDown(selector: string) {
const elem = document.querySelector(selector) as HTMLElement
if (elem) {
elem.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
}
}
export function selectOptionsBySelector(selector: string, value: string) {
const select = document.querySelector(selector) as HTMLSelectElement
if (select) {
select.value = value
select.dispatchEvent(new Event('change', { bubbles: true }))
}
}
export function isFlowTainted(flow: OpenFlow) {
return (
flow.value.modules.length > 0 || Object.keys((flow?.schema?.properties as any) ?? {}).length > 0
)
}
export function isAppTainted(app: App) {
if (app.hideLegacyTopBar === true) {
// An empty app should have only have a topbar and no hidden inline scripts
if (Array.isArray(app.hiddenInlineScripts) && app.hiddenInlineScripts?.length > 0) {
return true
}
// New apps have only a single component which is the topbar
if (Array.isArray(app.grid) && app.grid.length > 1) {
return true
}
// Check if the current app is different from an empty app
return !deepEqual(app, emptyApp())
} else {
// For older apps,
return !(app.grid?.length === 0 && app.hiddenInlineScripts?.length === 0)
}
}
export function updateFlowModuleById(
flow: OpenFlow,
id: string,
callback: (module: FlowModule) => void
) {
const dfs = (modules: FlowModule[]) => {
for (const module of modules) {
if (module.id === id) {
callback(module)
return
}
if (module.value.type === 'forloopflow') {
dfs(module.value.modules)
} else if (module.value.type === 'branchone') {
module.value.branches.forEach((branch) => dfs(branch.modules))
} else if (module.value.type === 'branchall') {
module.value.branches.forEach((branch) => dfs(branch.modules))
}
// AI agent tools are leaf nodes - no traversal needed
}
}
dfs(flow.value.modules)
}
export function updateBackgroundRunnableCode(app: App, index: number, newCode: string) {
const script = app.hiddenInlineScripts[index]
if (isRunnableByName(script) && script.inlineScript) {
script.inlineScript.content = newCode
}
}
export function updateInlineRunnableCode(app: App, componentId: string, newCode: string) {
const gridItem = findGridItem(app, componentId)
if (gridItem?.data.componentInput?.type === 'runnable') {
if (
isRunnableByName(gridItem.data.componentInput.runnable) &&
gridItem.data.componentInput.runnable.inlineScript
) {
gridItem.data.componentInput.runnable.inlineScript.content = newCode
}
}
}
export function connectComponentSourceToOutput(app: App, componentId: string, targetId: string) {
const gridItem = findGridItem(app, componentId)
if (gridItem) {
gridItem.data.componentInput = {
type: 'evalv2',
fieldType: 'object',
expr: `${targetId}.result`,
connections: [
{
componentId: targetId,
id: 'result'
}
]
}
}
}
export function connectInlineRunnableInputToComponentOutput(
app: App,
sourceComponentId: string,
sourceField: string,
targetComponentId: string,
targetField: string,
fieldType: string = 'text'
) {
const gridItem = findGridItem(app, sourceComponentId)
if (gridItem?.data.componentInput?.type === 'runnable') {
// @ts-ignore
gridItem.data.componentInput.fields = {
[sourceField]: {
type: 'evalv2',
expr: `${targetComponentId}.${targetField}`,
fieldType: fieldType,
connections: [
{
componentId: targetComponentId,
id: targetField
}
]
}
}
}
}
function elementExists(selector: string): boolean {
return !!document.querySelector(selector)
}
export function waitForElementLoading(
selector: string,
callback: () => void,
interval: number = 50,
maxAttempts: number = 30
): void {
let attempts = 0
const checkExistence = setInterval(() => {
if (elementExists(selector)) {
clearInterval(checkExistence)
callback()
} else if (attempts >= maxAttempts) {
clearInterval(checkExistence)
console.error('Element not found after multiple attempts.')
}
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)
}
@@ -1,141 +0,0 @@
<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/state'
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
}}
/>
@@ -1,95 +0,0 @@
<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/state'
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`)
} else {
tutorial?.runTutorial()
}
}
</script>
<Tutorial
bind:this={tutorial}
{index}
name="workspace-onboarding"
tainted={false}
on:skipAll
getSteps={(driver) => {
const steps: DriveStep[] = [
{
popover: {
title: 'Welcome to your Windmill workspace! 🎉',
description:
"Let's take a quick tour! We will show you the main sections of your workspace.",
onNextClick: async () => {
// The New menu button mounts once an async permission check resolves, so
// wait for it before highlighting it in the next step.
for (let i = 0; i < 20 && !document.querySelector('#create-new-button'); i++) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
driver.moveNext()
}
}
},
{
popover: {
title: 'Create your first script',
description:
'<img src="/languages.png" alt="Programming Languages" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p>Open the <strong>New</strong> menu to create a script. Scripts turn code into tools. Write in Python, TypeScript, Go, Bash, SQL and more. Run them manually, on schedule, or via webhooks.</p>',
onNextClick: () => {
driver.moveNext()
}
},
element: '#create-new-button'
},
{
popover: {
title: 'Create your first flow',
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>The same <strong>New</strong> menu lets you create a flow. Flows orchestrate multiple scripts. Chain them together with branching, loops, and error handling to build complex workflows.</p>',
onNextClick: () => {
driver.moveNext()
}
},
element: '#create-new-button'
},
{
popover: {
title: 'Create your first app',
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>And from the <strong>New</strong> menu you can also create an app. Apps are custom UIs built with drag-and-drop. Combine tables, forms, charts, and buttons that trigger your scripts and flows. That\'s it for the tour!</p><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 or in the <strong>Help</strong> submenu.</p>',
onNextClick: async () => {
// 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: '#create-new-button'
}
]
return steps
}}
/>
-4
View File
@@ -68,8 +68,6 @@ export function clearWorkspaceFromStorage() {
sessionStorage.removeItem('workspace')
}
export const tutorialsToDo = writable<number[]>([])
export const skippedAll = writable<boolean>(false)
export const globalEmailInvite = writable<string>('')
export const awarenessStore = writable<Record<string, string>>(undefined)
export const enterpriseLicense = writable<string | undefined>(undefined)
@@ -333,8 +331,6 @@ export const workspaceColor: Readable<string | null | undefined> = derived(
}
)
export const isCurrentlyInTutorial: StateStore<boolean> = createState({ val: false })
export function getFlatTableNamesFromSchema(dbSchema: DBSchema | undefined): string[] {
const schema = dbSchema?.schema ?? {}
const tableNames: string[] = []
-224
View File
@@ -1,224 +0,0 @@
import { get } from 'svelte/store'
import { tutorialsToDo, skippedAll } from './stores'
import { UserService } from './gen'
import { TUTORIALS_CONFIG } from './tutorials/config'
/**
* LocalStorage key for tracking if the tutorial banner has been dismissed.
* Shared between tutorialUtils and TutorialBanner component.
*/
export const TUTORIAL_BANNER_DISMISSED_KEY = 'tutorial_banner_dismissed'
/**
* Get the maximum tutorial index from the config.
* This ensures we don't hardcode the max ID and it automatically updates when tutorials are added.
*/
function getMaxTutorialId(): number {
let maxId = 0
for (const tab of Object.values(TUTORIALS_CONFIG)) {
for (const tutorial of tab.tutorials) {
if (tutorial.index !== undefined && tutorial.index > maxId) {
maxId = tutorial.index
}
}
}
return maxId
}
const MAX_TUTORIAL_ID = getMaxTutorialId()
/**
* Helper function to calculate tutorial progress for a given set of tutorial indexes.
* Returns total count. For completed count, use in component with reactive store access.
*/
export function getTutorialProgressTotal(tutorialIndexes: Record<string, number>): number {
return Object.values(tutorialIndexes).length
}
/**
* Helper function to calculate completed tutorials count.
* Must be called with current tutorialsToDo array.
*/
export function getTutorialProgressCompleted(
tutorialIndexes: Record<string, number>,
tutorialsToDoArray: number[]
): number {
return Object.values(tutorialIndexes).filter((index) => !tutorialsToDoArray.includes(index))
.length
}
export async function updateProgress(id: number) {
const bef = get(tutorialsToDo)
const aft = bef.filter((x) => x != id)
tutorialsToDo.set(aft)
skippedAll.set(false) // Mark as not skipped when completing a tutorial
let bits = 0
for (let i = 0; i <= MAX_TUTORIAL_ID; i++) {
let mask = 1 << i
if (!aft.includes(i)) {
bits = bits | mask
}
}
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: false } })
}
export async function skipAllTodos() {
let bits = 0
for (let i = 0; i <= MAX_TUTORIAL_ID; i++) {
let mask = 1 << i
bits = bits | mask
}
tutorialsToDo.set([])
skippedAll.set(true)
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } })
}
export async function resetAllTodos() {
let todos: number[] = []
for (let i = 0; i <= MAX_TUTORIAL_ID; i++) {
todos.push(i)
}
tutorialsToDo.set(todos)
skippedAll.set(false)
await UserService.updateTutorialProgress({ requestBody: { progress: 0, skipped_all: false } })
}
/**
* Skip (mark as complete) all tutorials in a specific set of indexes
*/
export async function skipTutorialsByIndexes(tutorialIndexes: number[]) {
const currentTodos = get(tutorialsToDo)
const aft = currentTodos.filter((x) => !tutorialIndexes.includes(x))
tutorialsToDo.set(aft)
// Get current progress bits
const currentResponse = await UserService.getTutorialProgress()
let bits: number = currentResponse.progress ?? 0
// Set bits for the specified indexes
for (const index of tutorialIndexes) {
const mask = 1 << index
bits = bits | mask
}
// Only set skipped_all to true if ALL tutorials are now complete
const allComplete = aft.length === 0
await UserService.updateTutorialProgress({
requestBody: {
progress: bits,
skipped_all: allComplete
}
})
}
/**
* Reset (mark as incomplete) all tutorials in a specific set of indexes
*/
export async function resetTutorialsByIndexes(tutorialIndexes: number[]) {
const currentTodos = get(tutorialsToDo)
const aft = [...new Set([...currentTodos, ...tutorialIndexes])]
tutorialsToDo.set(aft)
skippedAll.set(false)
// Get current progress bits
const currentResponse = await UserService.getTutorialProgress()
let bits: number = currentResponse.progress ?? 0
// Clear bits for the specified indexes
for (const index of tutorialIndexes) {
const mask = 1 << index
bits = bits & ~mask
}
await UserService.updateTutorialProgress({
requestBody: {
progress: bits,
skipped_all: false
}
})
}
/**
* Update a single tutorial's completion status by index
*/
async function updateTutorialStatusByIndex(tutorialIndex: number, completed: boolean) {
const currentTodos = get(tutorialsToDo)
const isInTodos = currentTodos.includes(tutorialIndex)
// Only update if the status needs to change
// isInTodos = true means NOT completed, isInTodos = false means completed
// So if completed === !isInTodos, we're already in the desired state
if (completed === !isInTodos) {
return // Already in the desired state
}
// Update todos list
const aft = completed
? currentTodos.filter((x) => x !== tutorialIndex)
: [...currentTodos, tutorialIndex]
tutorialsToDo.set(aft)
skippedAll.set(false)
// Get current progress bits
const currentResponse = await UserService.getTutorialProgress()
let bits: number = currentResponse.progress ?? 0
// Update bit for this tutorial index
const mask = 1 << tutorialIndex
bits = completed ? bits | mask : bits & ~mask
await UserService.updateTutorialProgress({
requestBody: {
progress: bits,
skipped_all: false
}
})
}
/**
* Reset (mark as incomplete) a single tutorial by index
*/
export async function resetTutorialByIndex(tutorialIndex: number) {
await updateTutorialStatusByIndex(tutorialIndex, false)
}
/**
* Mark a single tutorial as completed by index
*/
export async function completeTutorialByIndex(tutorialIndex: number) {
await updateTutorialStatusByIndex(tutorialIndex, true)
}
export async function syncTutorialsTodos() {
const response = await UserService.getTutorialProgress()
const bits: number = response.progress!
const skipped: boolean = response.skipped_all ?? false
const todos: number[] = []
for (let i = 0; i <= MAX_TUTORIAL_ID; i++) {
let mask = 1 << i
if ((bits & mask) == 0) {
todos.push(i)
}
}
tutorialsToDo.set(todos)
skippedAll.set(skipped)
}
export function tutorialInProgress() {
const svg = document.getElementsByClassName('driver-overlay driver-overlay-animated')
return svg.length > 0
}
/**
* Check if tutorials should be hidden from the main menu.
* Returns true if all tutorials are completed OR user skipped all.
*/
export function shouldHideTutorialsFromMainMenu(): boolean {
const todos = get(tutorialsToDo)
const skipped = get(skippedAll)
// Hide if all tutorials are completed OR user skipped all
return todos.length === 0 || skipped
}
-159
View File
@@ -1,159 +0,0 @@
import type { ComponentType } from 'svelte'
import { Workflow, GraduationCap, Wrench, PlayCircle, Link2, History } from 'lucide-svelte'
import { base } from '$lib/base'
import type { Role } from './roleUtils'
export interface TutorialConfig {
id: string
icon: ComponentType
title: string
description: string
onClick: () => void
index?: number // Bitmask index in the database (for progress tracking)
active?: boolean // Whether this tutorial is active and should be displayed (default: true)
comingSoon?: boolean
roles?: Role[] // Roles that can access this tutorial (if not specified, available to everyone)
order?: number
}
export interface TabConfig {
label: string
tutorials: TutorialConfig[]
roles?: Role[] // Roles that can access this tab category (if not specified, available to everyone)
progressBar?: boolean // Whether to display the progress bar for this tab (default: true)
active?: boolean // Whether this tab category is active and should be displayed (default: true)
}
export type TabId = 'quickstart' | 'app_editor'
/**
* Get tutorial index from config by tutorial ID.
* Throws an error if the tutorial or its index is not found.
*/
export function getTutorialIndex(id: string): number {
for (const tab of Object.values(TUTORIALS_CONFIG)) {
const tutorial = tab.tutorials.find((t) => t.id === id)
if (tutorial?.index !== undefined) return tutorial.index
}
throw new Error(`Tutorial index not found for id: ${id}. Make sure the tutorial has an index defined in config.`)
}
// Available roles : developer, admin, operator
export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
quickstart: {
label: 'Quickstart',
roles: ['admin', 'developer', 'operator'],
progressBar: true,
active: true,
tutorials: [
{
id: 'workspace-onboarding',
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`
},
index: 1,
active: true,
comingSoon: false,
roles: ['developer', 'admin'],
order: 1
},
{
id: 'flow-live-tutorial',
icon: Workflow,
title: 'Build a flow',
description: 'Learn how to build workflows in Windmill with our interactive tutorial.',
onClick: () => {
window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial`
},
index: 2,
active: true,
comingSoon: false,
roles: ['developer', 'admin'],
order: 2
},
{
id: 'troubleshoot-flow',
icon: Wrench,
title: 'Fix a broken flow',
description: 'Learn how to monitor and debug your script and flow executions.',
onClick: () => {
window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow`
},
index: 3,
active: true,
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: {
label: 'App Editor',
roles: ['developer', 'admin'],
progressBar: false,
active: true,
tutorials: [
{
id: 'backgroundrunnables',
icon: PlayCircle,
title: 'Background runnables',
description: 'Learn how to create and use background runnables in your apps.',
onClick: () => {
window.location.href = `${base}/apps/add?tutorial=backgroundrunnables`
},
index: 4,
active: true,
comingSoon: false,
roles: ['developer','admin'],
order: 4
},
{
id: 'connection',
icon: Link2,
title: 'Connection',
description: 'Learn how to connect component inputs to outputs in your apps.',
onClick: () => {
window.location.href = `${base}/apps/add?tutorial=connection`
},
index: 5,
active: true,
comingSoon: false,
roles: ['developer', 'admin'],
order: 5
}
]
}
} as const
-68
View File
@@ -1,68 +0,0 @@
import type { UserExt } from '$lib/stores'
export type Role = 'admin' | 'developer' | 'operator'
/**
* Get the effective role of a user based on their database flags.
* - Admin: user.is_admin === true
* - Operator: user.operator === true (and not admin)
* - Developer: default (neither admin nor operator)
*/
export function getUserEffectiveRole(user: UserExt | null | undefined): Role | null {
if (!user) return null
if (user.is_admin) return 'admin'
if (user.operator) return 'operator'
return 'developer'
}
/**
* Check if a role has access to a required role.
* This is the core role-checking logic used by both normal and preview modes.
*/
function checkRoleMatch(
userRole: Role,
requiredRole: Role
): boolean {
if (requiredRole === 'admin') return userRole === 'admin'
if (requiredRole === 'operator') return userRole === 'operator' || userRole === 'admin'
if (requiredRole === 'developer') return userRole === 'developer' || userRole === 'admin'
return false
}
/**
* Check if a user or preview role has access based on a roles array.
* This is the unified function that handles both normal user access and admin preview mode.
*/
export function hasRoleAccess(
user: UserExt | null | undefined,
roles?: Role[],
previewRole?: Role
): boolean {
// No roles specified = available to everyone
if (!roles || roles.length === 0) return true
// If previewRole is provided, use it (admin preview mode)
// Otherwise, derive role from user
const effectiveRole = previewRole ?? getUserEffectiveRole(user)
if (!effectiveRole) return false
// Check if effective role has any of the required roles
return roles.some((role) => checkRoleMatch(effectiveRole, role))
}
/**
* Check if a preview role has access based on a roles array.
* Used by admins to preview what other roles can see.
* Uses exact role matching - only shows tutorials explicitly marked for the preview role.
*/
export function hasRoleAccessForPreview(
previewRole: Role,
roles?: Role[]
): boolean {
// 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)
}
@@ -60,7 +60,6 @@
} from '$lib/components/sidebar/FavoriteMenu.svelte'
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { isCloudHosted } from '$lib/cloud'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { PanelLeftClose, PanelLeftOpen, Home, Play, Search, WandSparkles } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { confirmPendingLoginMethod } from '$lib/lastLoginMethod'
@@ -462,7 +461,6 @@
function onLoad() {
loadFavorites()
syncTutorialsTodos()
loadHubBaseUrl()
loadWsBaseUrl()
loadDisableHub()
@@ -34,11 +34,6 @@
import { goto, replaceState } from '$app/navigation'
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte'
import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte'
import { onMount, setContext } from 'svelte'
import { tutorialsToDo } from '$lib/stores'
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
import TutorialBanner from '$lib/components/home/TutorialBanner.svelte'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { z } from 'zod'
@@ -96,40 +91,9 @@
appViewer?.openDrawer?.()
}
let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined)
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
// Provide workspaceTutorials to child components via a reactive wrapper
let workspaceTutorialsContext = $derived(workspaceTutorials)
setContext('workspaceTutorials', {
get value() {
return workspaceTutorialsContext
}
})
let showCreateButtons = $state(false)
onMount(() => {
// Check if there's a tutorial parameter in the URL
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam === 'workspace-onboarding') {
// Small delay to ensure page is fully loaded
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
setTimeout(() => {
workspaceTutorials?.runTutorialById('workspace-onboarding')
}, 500)
}
})
</script>
<Drawer bind:this={codeViewer} size="900px">
@@ -316,8 +280,6 @@
</div>
</div>
<TutorialBanner />
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
{#if tab == 'hub'}
@@ -400,5 +362,4 @@
{/if}
</div>
<WorkspaceTutorials bind:this={workspaceTutorials} />
<HomeConnectDrawer bind:this={homeConnectDrawer} />
@@ -15,7 +15,7 @@
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { emptyApp } from '$lib/components/apps/editor/appUtils'
import { importStore } from '$lib/components/apps/store'
import { onDestroy, tick, untrack } from 'svelte'
import { onDestroy, untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft } from '$lib/userDraft.svelte'
import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag'
@@ -23,7 +23,6 @@
import { runResetToDeployed } from '$lib/userDraftToast'
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
let appEditor: AppEditor | undefined = $state(undefined)
/** Seeded from a hub app this load; AppEditor relaxes a few authoring affordances. */
let fromHub = $state(false)
let savedApp:
@@ -193,20 +192,6 @@
path: pathParam ?? '',
policy: seedPolicy
}
// Tutorial links ("/apps/add?tutorial=...") land here via the
// redirect; fire once AppEditor has mounted and the runnable
// panel the tour points at exists.
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam) {
await tick()
let attempts = 0
while (attempts < 20 && !document.querySelector('#app-editor-runnable-panel')) {
await new Promise((resolve) => setTimeout(resolve, 100))
attempts++
}
if (tok !== loadAppToken) return
appEditor?.triggerTutorial()
}
return
}
// Falling through with `?new_draft=true` still set means the draft is
@@ -474,7 +459,6 @@
{#if app}
<div class="h-screen">
<AppEditor
bind:this={appEditor}
{fromHub}
onSavedNewAppPath={(url) => {
goto(`/apps/edit/${url}`)
@@ -310,20 +310,6 @@
loading = false
selectedId = page.url.searchParams.get('selected') ?? seedSelectedId ?? 'settings-metadata'
renderEditor = true
// Tutorial links ("/flows/add?tutorial=...") land here via the
// redirect; fire once the builder has mounted and the flow input
// anchor the tour points at exists.
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam) {
await tick()
let attempts = 0
while (attempts < 20 && !document.querySelector('#flow-editor-virtual-Input')) {
await new Promise((resolve) => setTimeout(resolve, 100))
attempts++
}
if (tok !== loadFlowToken) return
flowBuilder?.triggerTutorial()
}
return
}
// Falling through with `?new_draft=true` still set means the draft is
@@ -2,27 +2,10 @@
<script lang="ts">
import { page } from '$app/state'
import { onMount } from 'svelte'
import RunsPage from '../../../../../lib/components/RunsPage.svelte'
import RunsTutorial from '$lib/components/tutorials/RunsTutorial.svelte'
let runsTutorial: RunsTutorial
// Get the path from route params (e.g., /runs/u/user/script → "u/user/script")
let initialPath = $derived(page.params.path ?? '')
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>
<RunsPage {initialPath} />
<RunsTutorial bind:this={runsTutorial} index={7} />
@@ -1,428 +0,0 @@
<script lang="ts">
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Tab } from '$lib/components/common'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import TutorialButton from '$lib/components/home/TutorialButton.svelte'
import TutorialProgressBar from '$lib/components/tutorials/TutorialProgressBar.svelte'
import { tutorialsToDo } from '$lib/stores'
import { onMount } from 'svelte'
import { afterNavigate } from '$app/navigation'
import {
syncTutorialsTodos,
resetAllTodos,
getTutorialProgressTotal,
getTutorialProgressCompleted,
skipAllTodos,
skipTutorialsByIndexes,
resetTutorialsByIndexes,
resetTutorialByIndex,
completeTutorialByIndex
} from '$lib/tutorialUtils'
import { Button } from '$lib/components/common'
import { RefreshCw, CheckCheck, CheckCircle2, Circle, Shield, Code, UserCog } from 'lucide-svelte'
import { TUTORIALS_CONFIG, type TabId, type TabConfig } from '$lib/tutorials/config'
import { userStore } from '$lib/stores'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import {
hasRoleAccess,
hasRoleAccessForPreview,
getUserEffectiveRole,
type Role
} from '$lib/tutorials/roleUtils'
import PageHeader from '$lib/components/PageHeader.svelte'
// Get user's effective role (derived from userStore)
const userEffectiveRole = $derived.by(() => {
return getUserEffectiveRole($userStore) ?? 'admin'
})
// State for the role selector (only used when user is admin)
// Defaults to user's actual role
let selectedPreviewRole: Role = $state('admin')
// Initialize selectedPreviewRole to user's role when admin, reset when not admin
$effect(() => {
const user = $userStore
if (user?.is_admin) {
// Initialize to user's actual role if not already set to a valid role
// This ensures it's always set to the user's role when they're admin
selectedPreviewRole = userEffectiveRole
} else {
// Reset to 'admin' as default (though this shouldn't matter for non-admins)
selectedPreviewRole = 'admin'
}
})
// Memoize access check dependencies to avoid unnecessary recalculations
// This derived value only recalculates when userStore or selectedPreviewRole changes
const accessCheckContext = $derived.by(() => {
const user = $userStore
// 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 }
})
// Get active tabs only (filtered by active and roles)
// Optimized: $derived.by() automatically memoizes - only recalculates when dependencies change
const activeTabs = $derived.by(() => {
// Access context to establish reactive dependency
const context = accessCheckContext
return (Object.entries(TUTORIALS_CONFIG) as [TabId, TabConfig][]).filter(([, config]) => {
// Filter by active
if (config.active === false) return false
// Filter by roles (context is captured in closure)
if (context.usePreview) {
return hasRoleAccessForPreview(context.previewRole, config.roles)
}
return hasRoleAccess(context.user, config.roles)
})
})
// Initialize tab to first active tab (already filtered by role and active status)
let tab: TabId = $state('quickstart')
// Set initial tab and ensure current tab is active and accessible
$effect(() => {
const firstActiveTab = activeTabs[0]?.[0]
if (firstActiveTab) {
// If current tab is not in active tabs, switch to first active tab
if (!activeTabs.some(([tabId]) => tabId === tab)) {
tab = firstActiveTab
}
}
})
// Get current tab configuration
const currentTabConfig = $derived(TUTORIALS_CONFIG[tab])
// Filter tutorials by role and active status (same logic as displayed tutorials)
// Optimized: $derived.by() automatically memoizes - only recalculates when tab or accessCheckContext changes
const visibleTutorials = $derived.by(() => {
// Access context to establish reactive dependency
const context = accessCheckContext
return currentTabConfig.tutorials.filter((tutorial) => {
if (tutorial.active === false) return false
// Use context directly to avoid function call overhead
if (context.usePreview) {
return hasRoleAccessForPreview(context.previewRole, tutorial.roles)
}
return hasRoleAccess(context.user, tutorial.roles)
})
})
// Create tutorial index mapping for current tab (only visible tutorials with index defined)
// Optimized: only recalculates when visibleTutorials changes
const currentTabTutorialIndexes = $derived.by(() => {
return Object.fromEntries(
visibleTutorials
.filter((tutorial) => tutorial.index !== undefined)
.map((tutorial) => [tutorial.id, tutorial.index!])
)
})
// Calculate progress for current tab (only counting visible tutorials)
const totalTutorials = $derived(getTutorialProgressTotal(currentTabTutorialIndexes))
const completedTutorials = $derived(
getTutorialProgressCompleted(currentTabTutorialIndexes, $tutorialsToDo)
)
// Sort visible tutorials by order
const tutorials = $derived(visibleTutorials.sort((a, b) => (a.order ?? 999) - (b.order ?? 999)))
// Sync tutorial progress on mount and when navigating to this page
onMount(() => {
// Initial sync
syncTutorialsTodos()
// Sync when page becomes visible (user returns from completing a tutorial)
const handleVisibilityChange = () => {
if (!document.hidden) {
syncTutorialsTodos()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// Also sync on window focus
const handleFocus = () => {
syncTutorialsTodos()
}
window.addEventListener('focus', handleFocus)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('focus', handleFocus)
}
})
// Sync when navigating to this page (e.g., after completing a tutorial)
afterNavigate(() => {
syncTutorialsTodos()
})
// Check if a tutorial is completed
function isTutorialCompleted(tutorialId: string): boolean {
const tutorial = currentTabConfig.tutorials.find((t) => t.id === tutorialId)
if (!tutorial || tutorial.index === undefined) return false
return !$tutorialsToDo.includes(tutorial.index)
}
// Get list of tutorial indexes for current tab
const currentTabIndexes = $derived(Object.values(currentTabTutorialIndexes))
// Skip all tutorials in current tab
async function skipCurrentTabTutorials() {
if (currentTabIndexes.length === 0) return
try {
await skipTutorialsByIndexes(currentTabIndexes)
await syncTutorialsTodos()
} catch (error) {
console.error('Error marking tutorials as completed:', error)
}
}
// Reset all tutorials in current tab
async function resetCurrentTabTutorials() {
if (currentTabIndexes.length === 0) return
try {
await resetTutorialsByIndexes(currentTabIndexes)
await syncTutorialsTodos()
} catch (error) {
console.error('Error resetting tutorials:', error)
}
}
// Update a single tutorial's completion status
async function updateSingleTutorial(tutorialId: string, completed: boolean) {
const tutorial = currentTabConfig.tutorials.find((t) => t.id === tutorialId)
if (!tutorial || tutorial.index === undefined) {
console.warn(`Tutorial not found or has no index: ${tutorialId}`)
return
}
try {
if (completed) {
await completeTutorialByIndex(tutorial.index)
} else {
await resetTutorialByIndex(tutorial.index)
}
await syncTutorialsTodos()
} catch (error) {
console.error(`Error ${completed ? 'completing' : 'resetting'} tutorial:`, error)
}
}
// Calculate progress for each tab
function getTabProgress(tabId: TabId) {
const tabConfig = TUTORIALS_CONFIG[tabId]
const context = accessCheckContext
// Get all tutorial indexes for this tab (filtered by role)
const indexes: number[] = []
for (const tutorial of tabConfig.tutorials) {
if (tutorial.active === false || tutorial.index === undefined) continue
// Use context directly to check access
if (context.usePreview) {
if (!hasRoleAccessForPreview(context.previewRole, tutorial.roles)) continue
} else {
if (!hasRoleAccess(context.user, tutorial.roles)) continue
}
indexes.push(tutorial.index)
}
const total = indexes.length
const completed = indexes.filter((index) => !$tutorialsToDo.includes(index)).length
return { total, completed }
}
// Get badge info for a tab
function getTabBadge(tabId: TabId) {
const { total, completed } = getTabProgress(tabId)
if (total === 0) return { type: 'none' as const }
if (completed === 0) {
// Circle icon if not started
return { type: 'dot' as const }
}
if (completed === total) {
// CheckCircle2 icon if completed
return { type: 'check' as const }
}
// (1/3) format if started
return { type: 'progress' as const, text: `(${completed}/${total})` }
}
</script>
<CenteredPage>
<PageHeader
title="Tutorials"
tooltip="Learn how to use Windmill with our interactive tutorials"
documentationLink="https://www.windmill.dev/docs/intro"
>
{#if activeTabs.length > 0}
<div class="flex gap-2">
<Button
size="xs"
variant="default"
startIcon={{ icon: CheckCheck }}
onclick={async () => {
await skipAllTodos()
await syncTutorialsTodos()
}}
>
Mark all as completed
</Button>
<Button
size="xs"
variant="default"
startIcon={{ icon: RefreshCw }}
onclick={async () => {
await resetAllTodos()
await syncTutorialsTodos()
}}
>
Reset all
</Button>
</div>
{/if}
</PageHeader>
<div class="flex flex-col gap-4 pb-2 my-4 mr-2">
{#if $userStore?.is_admin}
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-xs text-secondary">View as an</span>
<ToggleButtonGroup
bind:selected={selectedPreviewRole}
onSelected={(v) => {
selectedPreviewRole = (v || userEffectiveRole) as Role
}}
noWFull
>
{#snippet children({ item })}
<ToggleButton
value={userEffectiveRole}
label="Admin (me)"
icon={Shield}
size="sm"
{item}
tooltip="View tutorials as yourself (admin)"
/>
<ToggleButton
value="developer"
label="Developer"
icon={Code}
size="sm"
{item}
tooltip="Preview tutorials visible to developers"
/>
<ToggleButton
value="operator"
label="Operator"
icon={UserCog}
size="sm"
{item}
tooltip="Preview tutorials visible to operators"
/>
{/snippet}
</ToggleButtonGroup>
</div>
<span class="text-3xs text-secondary">
This allows you to see which tutorials your team members can access
</span>
</div>
{/if}
</div>
{#if activeTabs.length > 0}
<div class="flex justify-between pt-4">
<Tabs class="w-full" bind:selected={tab}>
{#each activeTabs as [tabId, config]}
{@const badge = getTabBadge(tabId as TabId)}
{#if badge.type === 'progress'}
<Tab value={tabId} label={config.label}>
{#snippet extra()}
<span class="text-xs text-secondary ml-1.5 flex-shrink-0">{badge.text}</span>
{/snippet}
</Tab>
{:else if badge.type === 'check'}
<Tab value={tabId} label={config.label}>
{#snippet extra()}
<CheckCircle2 size={14} class="ml-1.5 flex-shrink-0" />
{/snippet}
</Tab>
{:else if badge.type === 'dot'}
<Tab value={tabId} label={config.label}>
{#snippet extra()}
<Circle size={14} class="ml-1.5 flex-shrink-0" />
{/snippet}
</Tab>
{:else}
<Tab value={tabId} label={config.label} />
{/if}
{/each}
</Tabs>
</div>
{#if tutorials.length > 0}
<div class="pt-8">
<div class="flex items-start gap-4 mb-6">
{#if currentTabConfig.progressBar !== false}
<TutorialProgressBar
completed={completedTutorials}
total={totalTutorials}
label="tutorials"
/>
{/if}
<div class="flex gap-2 flex-shrink-0 pt-1">
<Button
size="xs"
variant="default"
startIcon={{ icon: CheckCheck }}
onclick={skipCurrentTabTutorials}
>
Mark as completed
</Button>
<Button
size="xs"
variant="default"
startIcon={{ icon: RefreshCw }}
onclick={resetCurrentTabTutorials}
>
Reset
</Button>
</div>
</div>
<div class="border rounded-md bg-surface-tertiary">
{#each tutorials as tutorial}
<TutorialButton
icon={tutorial.icon}
title={tutorial.title}
description={tutorial.description}
onclick={tutorial.onClick}
isCompleted={isTutorialCompleted(tutorial.id)}
disabled={tutorial.active === false}
comingSoon={tutorial.comingSoon}
onReset={() => updateSingleTutorial(tutorial.id, false)}
onComplete={() => updateSingleTutorial(tutorial.id, true)}
/>
{/each}
</div>
</div>
{:else if currentTabConfig}
<div class="pt-8">
<div class="text-center text-secondary text-sm py-8">
No tutorials available for this section yet.
</div>
</div>
{/if}
{:else}
<div class="pt-8">
<div class="text-center text-secondary text-sm py-8">
No tutorials available for now. Coming soon.
</div>
</div>
{/if}
</CenteredPage>
@@ -308,7 +308,6 @@
{#if flowStore.val?.value?.modules}
<FlowModuleSchemaMap
disableAi
disableTutorials
smallErrorHandler={true}
disableStaticInputs
/>
-598
View File
@@ -1,598 +0,0 @@
# 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