mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
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:
co-authored by
Claude Opus 5
parent
a72f79322d
commit
a582eb526e
@@ -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.
|
||||
|
||||
Generated
+2
-49
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -1247,7 +1247,6 @@
|
||||
<FlowModuleSchemaMap
|
||||
bind:this={flowModuleSchemaMap}
|
||||
disableAi
|
||||
disableTutorials
|
||||
smallErrorHandler={true}
|
||||
disableStaticInputs
|
||||
localModuleStates={showJobStatus ? localModuleStates : {}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
-11
@@ -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>
|
||||
|
||||
@@ -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 "Job" is simply a single run of a script or flow. Every time you run code, Windmill creates a Job to track if it succeeded or failed, how long it took, and what the results were.</p><p style='margin-top: 12px;'>In this tutorial, we will explore:</p><ul style='margin-top: 8px; padding-left: 20px;'><li style='margin-bottom: 8px;'><svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='color: #22c55e; display: inline-block; vertical-align: middle; margin-right: 6px;'><circle cx='12' cy='12' r='10'/><path d='m9 12 2 2 4-4'/></svg>A successful job execution.</li><li style='margin-bottom: 8px;'><svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='color: #ef4444; display: inline-block; vertical-align: middle; margin-right: 6px;'><circle cx='12' cy='12' r='10'/><path d='m12 8v4'/><path d='m12 16h.01'/></svg>A failed job execution.</li><li><svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='color: #3b82f6; display: inline-block; vertical-align: middle; margin-right: 6px;'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>How to filter your monitoring view.</li></ul>",
|
||||
onNextClick: () => {
|
||||
driver.moveNext()
|
||||
},
|
||||
onPrevClick: () => {
|
||||
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#runs-table-wrapper',
|
||||
onHighlighted: async () => {
|
||||
step2Complete = false
|
||||
await wait(DELAY_MEDIUM)
|
||||
|
||||
// Find all jobs
|
||||
const allJobRows = Array.from(
|
||||
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
|
||||
) as HTMLElement[]
|
||||
|
||||
// Find successful job (green badge/check icon) - first one that's not failed
|
||||
const successfulJobRow =
|
||||
allJobRows.find((el) => {
|
||||
const hasRedBadge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
|
||||
const hasGreenBadge = el.querySelector('[class*="bg-green"], [class*="text-green"]')
|
||||
return !hasRedBadge && hasGreenBadge !== null
|
||||
}) || allJobRows[0]
|
||||
|
||||
if (successfulJobRow) {
|
||||
// Create cursor
|
||||
const cursor = createFakeCursor()
|
||||
|
||||
// Click on successful job
|
||||
await animateCursorToElementAndClick(cursor, successfulJobRow)
|
||||
|
||||
// Wait for navigation to job details page
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Navigate back to runs page using SvelteKit navigation
|
||||
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Remove the cursor
|
||||
cursor.remove()
|
||||
step2Complete = true
|
||||
}
|
||||
},
|
||||
popover: {
|
||||
title:
|
||||
'Exploring successful job runs <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #22c55e; display: inline-block; vertical-align: middle;"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
|
||||
description:
|
||||
"Let's click on a successful job to see how to inspect a completed execution.",
|
||||
side: 'bottom',
|
||||
onNextClick: async () => {
|
||||
if (!step2Complete) {
|
||||
sendUserToast(
|
||||
'Please wait for the job click to complete...',
|
||||
false,
|
||||
[],
|
||||
undefined,
|
||||
3000
|
||||
)
|
||||
return
|
||||
}
|
||||
// Click on the successful job again (without showing cursor)
|
||||
const successfulJobRow =
|
||||
(Array.from(document.querySelectorAll('#runs-table-wrapper .cursor-pointer')).find(
|
||||
(el) => {
|
||||
const hasRedBadge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
|
||||
const hasGreenBadge = el.querySelector(
|
||||
'[class*="bg-green"], [class*="text-green"]'
|
||||
)
|
||||
return !hasRedBadge && hasGreenBadge !== null
|
||||
}
|
||||
) as HTMLElement) ||
|
||||
(Array.from(
|
||||
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
|
||||
)[0] as HTMLElement)
|
||||
|
||||
if (successfulJobRow) {
|
||||
successfulJobRow.click()
|
||||
await wait(DELAY_SHORT)
|
||||
|
||||
// Wait for navigation to job details page
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Navigate back to runs page using SvelteKit navigation
|
||||
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
|
||||
await wait(DELAY_LONG)
|
||||
}
|
||||
|
||||
driver.moveNext()
|
||||
},
|
||||
onPrevClick: () => {
|
||||
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#runs-table-wrapper',
|
||||
onHighlighted: async () => {
|
||||
step3Complete = false
|
||||
await wait(DELAY_MEDIUM)
|
||||
|
||||
// Find all jobs
|
||||
const allJobRows = Array.from(
|
||||
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
|
||||
) as HTMLElement[]
|
||||
|
||||
// Find failed job (red badge/X icon)
|
||||
const failedJobRow = allJobRows.find((el) => {
|
||||
const badge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
|
||||
return badge !== null
|
||||
}) as HTMLElement
|
||||
|
||||
if (failedJobRow) {
|
||||
// Create cursor
|
||||
const cursor = createFakeCursor()
|
||||
|
||||
// Click on failed job
|
||||
await animateCursorToElementAndClick(cursor, failedJobRow)
|
||||
|
||||
// Wait for navigation to job details page
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Navigate back to runs page using SvelteKit navigation
|
||||
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Remove the cursor
|
||||
cursor.remove()
|
||||
step3Complete = true
|
||||
}
|
||||
},
|
||||
popover: {
|
||||
title:
|
||||
'Exploring failed job runs <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #ef4444; display: inline-block; vertical-align: middle;"><circle cx="12" cy="12" r="10"/><path d="m12 8v4"/><path d="m12 16h.01"/></svg>',
|
||||
description: "Now let's click on a failed job to see how to inspect a failed execution.",
|
||||
side: 'bottom',
|
||||
onNextClick: async () => {
|
||||
if (!step3Complete) {
|
||||
sendUserToast(
|
||||
'Please wait for the job click to complete...',
|
||||
false,
|
||||
[],
|
||||
undefined,
|
||||
3000
|
||||
)
|
||||
return
|
||||
}
|
||||
// Click on the failed job again (without showing cursor)
|
||||
const failedJobRow = Array.from(
|
||||
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
|
||||
).find((el) => {
|
||||
const badge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
|
||||
return badge !== null
|
||||
}) as HTMLElement
|
||||
|
||||
if (failedJobRow) {
|
||||
failedJobRow.click()
|
||||
await wait(DELAY_SHORT)
|
||||
|
||||
// Wait for navigation to job details page
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Navigate back to runs page using SvelteKit navigation
|
||||
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
|
||||
await wait(DELAY_LONG)
|
||||
}
|
||||
|
||||
driver.moveNext()
|
||||
},
|
||||
onPrevClick: () => {
|
||||
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#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)
|
||||
}
|
||||
-141
@@ -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
|
||||
}}
|
||||
/>
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user