feat: new live onboarding for flows (#7194)

* Start workspace onboarding

* Add pictures to tutorial steps

* Remove unecessary step

* Continue tutorial by creating a flow together

* Add image into the Create Flow tutorial pop up

* Generate flow from frontend

* Set pause between each node

* Add automatic scripts overview

* Simplify tutorial, and add step to show the code

* Add input step

* Autoremove last step after 5 seconds

* Add flow typing when opening code editor

* Remove lock field from json file

* Add Guides tab on left menu

* Add /guides page

* Add tutorial card in Guides tab

* Add step to show data connector

* Add second text input to show 2 types of inputs and fill them dynamically

* Improve tutorial chronology

* Add flow input connexion with first sctript

* Improve overlay

* Improve wording

* Add new tutorial step to show node b

* Add test step

* Add cursor to pick typescript

* Improve end of tutorial

* Refactor

* Highlight bottom right corner for 5 and 6

* Fix last step overlay

* change home tutorial button

* guidelines nits

* Automate onNext() trigger on step 3

* Improve fakr cursor for Test this step button

* Improve overlay transitions

* Merge data connectors and test step steps

* Improve live code writing in step 3

* Add a step to complete the flow

* Improve the step where we generate remaining scripts

* Refactor

* Add blocking behavior on step 3

* nit about delay

* Prevent clicking on Next while code not generated

* Sharpen wordings

* Remove Svelte 4 and migrate to Svelte 5

* Remove unecesary helper function

* Add toast if the user clicks on Next button before code finished generating

* Add toasts to each step

* Improve tutorial trigger timing

* Improve delays

* Add cursor movement to Test Flow button

* Block previous on certain steps to prevent bug

* Fix for github npm check

* Fix for github npm check

* Unlike workspace onboarding and flow tutorial

* Rename flow tutorial with better name

* Remove the automatic trigger for flow previous and broken tutorial

* Push tutorials to Help sectionof the sidebar

* Fix redirection t /tutorials page

* Add tutorials page and update workspace onboarding flow

- Rename guides to tutorials page (/tutorials)
- Add workspace onboarding tutorial to tutorials page
- Remove Tutorial button from homepage
- Add welcome cards for empty workspace with 3 tutorial options
- Update workspace onboarding to redirect to homepage before starting
- Clean up URL parameter after tutorial completion
- Move Tutorials to Help menu in sidebar
- Remove automatic "action" tutorial trigger for new flows
- Add flow-live-tutorial (renamed from workspace-onboarding-continue)
- Add Previous button blocking with toast notifications in flow tutorial

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add tutorials to workspace homepage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Start tutorials for Run/logs section

* Fix data connector

* Add flow execution graph from Run drawer

* Add tabs highlighting in drawer

* Improve tutorial on run drawer

* Add mouse cursor moving from graph tab

* Add cursor click on script in Drawer Graph tabs

* Add troubleshooting flow in tutorial

* Add step to show logs of failed step

* add step 7 to invite the user to fix by himself and se the new results

* Improve wording

* Nit improvements

* Nits

* Refactor

* Refactor

* Rename the tutorial

* Remove deleted file

* Improve wording

* Improve first step of troubleshooting flow tutorial

* Add tutorials to /tutorials page and create component

* Remove previous Flow tutorials

* Fixes, and improve tutorial button design

* Improve status in Tutorial button

* Align tutorial button to brand guidelines

* Add skip all to onboarding workspace tutorial

* Add skipped_all to tutorial_progress

* Connect backend and frontend for tutorial progress

* Add store and helper to display or not Tutorials from left menu

* Add reminder at the end of each tutorial

* Add tutorial banner

* Remove tutorials from elpty workspace

* Improve Tutorials page

* Align banner to guidelines

* Add reset tutorials buttons

* Refactor

* Refactor to make it easy to add new tutorials and tabs

* Improve tutorial config to make it easy to add new tutorials

* Refactor and remove hardcoded indexes

* Add getTutorialIndex in tutorial config file

* Nit

* Add Mark all as complete button in tutorial page

* Add skip tutorial button in banner toast

* Replace if else in tutorials router by map to make it easier to maintain and scale

* Delete broken simple app tutorial

* Add Guide flow guide buttons inside the Create Flow page

* Add flow editor tutorials into flow builder page

* Update existing app tutorials with new tutorial system

* Create a dedicated tutorial category for app editor

* Add global progress bar

* Add Reset & Skip at tutorial category level

* Add progress to tab title

* Nits on design

* Make progress bar a props and design nits

* Add active props for Tutorial Category

* Display tutorials according to the user role

* Adapt progress bar to the user role

* Add roles array for each tutorial

* Add Tutorials tab in Operator menu

* Edge case if no Category and no Tutorial available for my role

* Allow the user to reset a single tutorial

* Allow a user to mark as completed a single tutorial

* Nit on hoovering tutorial status

* Allow admins to see which tutorials are available per role

* Create utils that allow admins to see which tutorials can access other roles of their organization

* Refactor resetSingleTutorial and completeSingleTutorial into one function

* Improve role system

* Remove hardcoded MAX_TUTORIAL_ID

* Fix type assertion

* Remove console log

* Reduce recalculations when unrelated state changes

* Add console.error

* Remove unused function

* Add tutorial wrapper and better router

* Nits to pass npm checks

* Fix typescripts and lint errors

* Add SQLx query cache for tutorial_progress queries

* Improve wording for workspace tutorial

---------

Co-authored-by: Diego Imbert <diego@windmill.dev>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Tristan TR
2025-12-08 17:42:35 +01:00
committed by GitHub
parent 9bc026b429
commit dbf8d47b29
52 changed files with 2904 additions and 1559 deletions
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO tutorial_progress (email, progress, skipped_all) VALUES ($2, $1::bigint::bit(64), $3) ON CONFLICT (email) DO UPDATE SET progress = EXCLUDED.progress, skipped_all = EXCLUDED.skipped_all",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "04362a55081f7a98bca8fe4db0669939da8944711037957664cc2989b239c9d1"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT progress::bigint as progress, skipped_all FROM tutorial_progress WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "progress",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "skipped_all",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
false
]
},
"hash": "f3b7ea04830022f918f9302b4d40f57c9c9c48192bc2293986c8abb54ae94446"
}
@@ -0,0 +1,3 @@
-- Remove skipped_all column from tutorial_progress table
ALTER TABLE tutorial_progress
DROP COLUMN skipped_all;
@@ -0,0 +1,5 @@
-- Add skipped_all column to tutorial_progress table
ALTER TABLE tutorial_progress
ADD COLUMN skipped_all BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN tutorial_progress.skipped_all IS 'Indicates if the user has skipped all tutorials (vs completing them all)';
+4
View File
@@ -1349,6 +1349,8 @@ paths:
properties:
progress:
type: integer
skipped_all:
type: boolean
post:
summary: update tutorial progress
operationId: updateTutorialProgress
@@ -1364,6 +1366,8 @@ paths:
properties:
progress:
type: integer
skipped_all:
type: boolean
responses:
"200":
description: tutorial progress
+20 -9
View File
@@ -562,20 +562,30 @@ async fn list_users_as_super_admin(
#[derive(Serialize, Deserialize)]
struct Progress {
progress: u64,
skipped_all: bool,
}
async fn get_tutorial_progress(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<Progress> {
let res = sqlx::query_scalar!(
"SELECT progress::bigint FROM tutorial_progress WHERE email = $1",
let row = sqlx::query!(
"SELECT progress::bigint as progress, skipped_all FROM tutorial_progress WHERE email = $1",
authed.email
)
.fetch_optional(&db)
.await?
.flatten()
.unwrap_or_default() as u64;
Ok(Json(Progress { progress: res }))
.await?;
if let Some(row) = row {
Ok(Json(Progress {
progress: row.progress.unwrap_or_default() as u64,
skipped_all: row.skipped_all,
}))
} else {
Ok(Json(Progress {
progress: 0,
skipped_all: false,
}))
}
}
async fn update_tutorial_progress(
@@ -583,10 +593,11 @@ async fn update_tutorial_progress(
Extension(db): Extension<DB>,
Json(progress): Json<Progress>,
) -> Result<String> {
sqlx::query_scalar!(
"INSERT INTO tutorial_progress VALUES ($2, $1::bigint::bit(64)) ON CONFLICT (email) DO UPDATE SET progress = EXCLUDED.progress",
sqlx::query!(
"INSERT INTO tutorial_progress (email, progress, skipped_all) VALUES ($2, $1::bigint::bit(64), $3) ON CONFLICT (email) DO UPDATE SET progress = EXCLUDED.progress, skipped_all = EXCLUDED.skipped_all",
progress.progress as i64,
authed.email
authed.email,
progress.skipped_all
)
.execute(&db)
.await?;
+19 -41
View File
@@ -1,51 +1,29 @@
<script lang="ts">
import { skipAllTodos } from '$lib/tutorialUtils'
import AppTutorial from './tutorials/app/AppTutorial.svelte'
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import BackgroundRunnablesTutorial from './tutorials/app/BackgroundRunnablesTutorial.svelte'
import ConnectionTutorial from './tutorials/app/ConnectionTutorial.svelte'
let backgroundRunnablesTutorial: BackgroundRunnablesTutorial | undefined = undefined
let connectionTutorial: ConnectionTutorial | undefined = undefined
let appTutorial: AppTutorial | undefined = undefined
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
if (id === 'backgroundrunnables') {
backgroundRunnablesTutorial?.runTutorial(options?.skipStepsCount)
} else if (id === 'connection') {
connectionTutorial?.runTutorial()
} else if (id === 'simpleapptutorial') {
appTutorial?.runTutorial()
}
}
function skipAll() {
skipAllTodos()
tutorialRouter?.runTutorialById(id, options)
}
</script>
<AppTutorial
bind:this={appTutorial}
on:error
on:skipAll={skipAll}
on:reload
index={7}
name="simpleapptutorial"
/>
<BackgroundRunnablesTutorial
bind:this={backgroundRunnablesTutorial}
on:error
on:skipAll={skipAll}
on:reload
index={5}
name="backgroundrunnables"
/>
<ConnectionTutorial
bind:this={connectionTutorial}
on:error
on:skipAll={skipAll}
on:reload
index={6}
name="connection"
<TutorialRouter
bind:this={tutorialRouter}
tutorials={[
{
id: 'backgroundrunnables',
component: BackgroundRunnablesTutorial,
name: 'backgroundrunnables',
supportsSkipSteps: true
},
{
id: 'connection',
component: ConnectionTutorial,
name: 'connection',
supportsSkipSteps: true
}
]}
/>
+2 -12
View File
@@ -13,7 +13,6 @@
import { initHistory, redo, undo } from '$lib/history.svelte'
import {
enterpriseLicense,
tutorialsToDo,
userStore,
workspaceStore,
usedTriggerKinds
@@ -61,11 +60,10 @@
import { getAllModules } from './flows/flowExplorer'
import { type FlowCopilotContext } from './copilot/flow'
import { loadFlowModuleState } from './flows/flowStateUtils.svelte'
import FlowBuilderTutorials from './FlowBuilderTutorials.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import FlowTutorials from './FlowTutorials.svelte'
import { ignoredTutorials } from './tutorials/ignoredTutorials'
import FlowHistory from './flows/FlowHistory.svelte'
import FlowEditorTutorial from './flows/FlowEditorTutorial.svelte'
import Summary from './Summary.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
@@ -805,8 +803,6 @@
if (tutorial) {
flowTutorials?.runTutorialById(tutorial)
} else if ($tutorialsToDo.includes(0) && !$ignoredTutorials.includes(0)) {
flowTutorials?.runTutorialById('action')
}
}
@@ -1110,13 +1106,7 @@
<Dropdown items={moreItems} />
{/if}
</div>
{#if customUi?.topBar?.tutorials != false}
<FlowBuilderTutorials
on:reload={() => {
renderCount += 1
}}
/>
{/if}
<FlowEditorTutorial />
{#if customUi?.topBar?.diff != false}
<Button
variant="default"
@@ -1,82 +0,0 @@
<script lang="ts">
import { BookOpen, CheckCircle, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import Button from './common/button/Button.svelte'
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import FlowTutorials from './FlowTutorials.svelte'
import { tutorialsToDo } from '$lib/stores'
let targetTutorial: string | undefined = $state(undefined)
let flowTutorials: FlowTutorials | undefined = $state(undefined)
async function getTutorialItems() {
const tutorials = [
{ displayName: 'Simple flow tutorials', id: 'action' },
{ displayName: 'For loops tutorial', id: 'forloop' },
{ displayName: 'Branch one tutorial', id: 'branchone' },
{ displayName: 'Branch all tutorial', id: 'branchall' },
{ displayName: 'Error handler', id: 'error-handler' }
]
return [
...tutorials.map((tutorial, index) => ({
displayName: tutorial.displayName,
action: () => flowTutorials?.runTutorialById(tutorial.id),
icon: $tutorialsToDo.includes(index) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(index) ? undefined : 'green'
})),
{
displayName: 'Skip tutorials',
action: () => skipAllTodos(),
icon: CheckCheck
},
{
displayName: 'Reset tutorials',
action: () => resetAllTodos(),
icon: RefreshCw
}
]
}
</script>
{#key $tutorialsToDo}
<Dropdown items={getTutorialItems} class="w-fit">
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize="md"
variant="default"
id="tutorials-button"
startIcon={{ icon: BookOpen }}
/>
{/snippet}
</Dropdown>
{/key}
<FlowTutorials
bind:this={flowTutorials}
on:error={({ detail }) => {
targetTutorial = detail.detail
}}
on:skipAll={() => {
skipAllTodos()
}}
on:reload
/>
<ConfirmationModal
open={targetTutorial !== undefined}
title="Tutorial error"
confirmationText="Open new tab"
on:canceled={() => {
targetTutorial = undefined
}}
on:confirmed={async () => {
window.open(`/flows/add?tutorial=${targetTutorial}&nodraft=true`, '_blank')
}}
>
<div class="flex flex-col w-full space-y-4">
<span> You need to create a new flow before starting the tutorial.</span>
</div>
</ConfirmationModal>
@@ -1,64 +1,25 @@
<script lang="ts">
import { skipAllTodos } from '$lib/tutorialUtils'
import FlowBuilderTutorialBranchAll from './tutorials/FlowBuilderTutorialBranchAll.svelte'
import FlowBuilderTutorialBranchOne from './tutorials/FlowBuilderTutorialBranchOne.svelte'
import FlowBuilderTutorialSimpleFlow from './tutorials/FlowBuilderTutorialSimpleFlow.svelte'
import FlowBuilderTutorialForLoop from './tutorials/FlowBuilderTutorialForLoop.svelte'
import FlowBuilderTutorialErrorHandler from './tutorials/FlowBuilderTutorialErrorHandler.svelte'
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import FlowBuilderLiveTutorial from './tutorials/FlowBuilderLiveTutorial.svelte'
import TroubleshootFlowTutorial from './tutorials/TroubleshootFlowTutorial.svelte'
let flowBuilderTutorialSimpleFlow: FlowBuilderTutorialSimpleFlow | undefined = $state(undefined)
let flowBuilderTutorialForLoop: FlowBuilderTutorialForLoop | undefined = $state(undefined)
let flowBuilderTutorialBranchOne: FlowBuilderTutorialBranchOne | undefined = $state(undefined)
let flowBuilderTutorialBranchAll: FlowBuilderTutorialBranchAll | undefined = $state(undefined)
let flowBuilderTutorialErrorHandler: FlowBuilderTutorialErrorHandler | undefined =
$state(undefined)
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
export function runTutorialById(id: string, indexToInsertAt?: number | undefined) {
if (id === 'forloop') {
flowBuilderTutorialForLoop?.runTutorial(indexToInsertAt)
} else if (id === 'branchone') {
flowBuilderTutorialBranchOne?.runTutorial()
} else if (id === 'branchall') {
flowBuilderTutorialBranchAll?.runTutorial()
} else if (id === 'action') {
flowBuilderTutorialSimpleFlow?.runTutorial()
} else if (id === 'error-handler') {
flowBuilderTutorialErrorHandler?.runTutorial()
}
}
function skipAll() {
skipAllTodos()
export function runTutorialById(id: string) {
tutorialRouter?.runTutorialById(id)
}
</script>
<FlowBuilderTutorialSimpleFlow
bind:this={flowBuilderTutorialSimpleFlow}
on:error
on:skipAll={skipAll}
on:reload
/>
<FlowBuilderTutorialForLoop
bind:this={flowBuilderTutorialForLoop}
on:error
on:skipAll={skipAll}
on:reload
/>
<FlowBuilderTutorialBranchOne
bind:this={flowBuilderTutorialBranchOne}
on:error
on:skipAll={skipAll}
on:reload
/>
<FlowBuilderTutorialBranchAll
bind:this={flowBuilderTutorialBranchAll}
on:error
on:skipAll={skipAll}
on:reload
/>
<FlowBuilderTutorialErrorHandler
bind:this={flowBuilderTutorialErrorHandler}
on:error
on:skipAll={skipAll}
on:reload
/>
<TutorialRouter
bind:this={tutorialRouter}
tutorials={[
{
id: 'flow-live-tutorial',
component: FlowBuilderLiveTutorial
},
{
id: 'troubleshoot-flow',
component: TroubleshootFlowTutorial
}
]}
/>
@@ -5,6 +5,7 @@
export let tooltip: string = ''
export let documentationLink: string | undefined = undefined
export let primary: boolean = true
export let childrenWrapperDivClasses: string = ''
</script>
<div class="flex flex-row flex-wrap justify-between pb-2 my-4 mr-2">
@@ -31,7 +32,7 @@
{/if}
{#if $$slots.default}
<div class="my-2">
<div class="my-2 {childrenWrapperDivClasses}">
<slot />
</div>
{/if}
@@ -0,0 +1,25 @@
<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
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import WorkspaceOnboardingTutorial from './tutorials/workspace/WorkspaceOnboardingTutorial.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
}
]}
/>
@@ -428,7 +428,12 @@
let appEditorHeader: AppEditorHeader | undefined = $state(undefined)
export function triggerTutorial() {
appEditorHeader?.toggleTutorial()
const urlParams = new URLSearchParams(window.location.search)
const tutorial = urlParams.get('tutorial')
if (tutorial) {
appEditorHeader?.runTutorialById(tutorial)
}
}
let box: HTMLElement | undefined = $state(undefined)
@@ -656,8 +656,8 @@
let appEditorTutorial: AppEditorTutorial | undefined = $state(undefined)
export function toggleTutorial() {
appEditorTutorial?.toggleTutorial()
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
appEditorTutorial?.runTutorialById(id, options)
}
let appReportingDrawerOpen = $state(false)
@@ -4,66 +4,35 @@
import { BookOpen, CheckCircle, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
import { getContext, onMount } from 'svelte'
import type { AppViewerContext } from '../types'
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
import { tutorialsToDo } from '$lib/stores'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { isAppTainted } from '$lib/components/tutorials/utils'
import { getTutorialIndex } from '$lib/tutorials/config'
let appTutorials: AppTutorials | undefined = $state(undefined)
let targetTutorial: string | undefined = $state(undefined)
const { app } = getContext<AppViewerContext>('AppViewerContext')
onMount(() => {
const urlParams = new URLSearchParams(window.location.search)
const forkedFromTheHub = urlParams.get('hub')
const forkedFromTemplate = urlParams.get('template')
if (
!isAppTainted($app) &&
!$ignoredTutorials.includes(7) &&
$tutorialsToDo.includes(7) &&
!forkedFromTheHub &&
!forkedFromTemplate
) {
appTutorials?.runTutorialById('simpleapptutorial')
}
})
export function toggleTutorial() {
const urlParams = new URLSearchParams(window.location.search)
const tutorial = urlParams.get('tutorial')
if (tutorial === 'simpleapptutorial') {
appTutorials?.runTutorialById('simpleapptutorial')
}
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
appTutorials?.runTutorialById(id, options)
}
async function getTutorialItems() {
const backgroundRunnablesIndex = getTutorialIndex('backgroundrunnables')
const connectionIndex = getTutorialIndex('connection')
return [
{
displayName: 'App tutorial',
action: () => appTutorials?.runTutorialById('simpleapptutorial'),
index: 7,
icon: $tutorialsToDo.includes(7) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(7) ? undefined : 'green'
},
{
displayName: 'Background runnables',
action: () => appTutorials?.runTutorialById('backgroundrunnables'),
index: 5,
icon: $tutorialsToDo.includes(5) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(5) ? undefined : 'green'
index: backgroundRunnablesIndex,
icon: $tutorialsToDo.includes(backgroundRunnablesIndex) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(backgroundRunnablesIndex) ? undefined : 'green'
},
{
displayName: 'Connection',
action: () => appTutorials?.runTutorialById('connection'),
index: 6,
icon: $tutorialsToDo.includes(6) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(6) ? undefined : 'green'
index: connectionIndex,
icon: $tutorialsToDo.includes(connectionIndex) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(connectionIndex) ? undefined : 'green'
},
{
displayName: 'Reset tutorials',
@@ -96,8 +65,8 @@
<AppTutorials
bind:this={appTutorials}
on:reload
on:error={({ detail }) => {
targetTutorial = detail.detail
on:error={(event: CustomEvent<{ detail: string }>) => {
targetTutorial = event.detail.detail
}}
/>
@@ -25,6 +25,7 @@
<!-- Buttons -->
<div class="flex flex-row gap-2">
<Button
id="create-app-button"
aiId="apps-create-actions-app"
aiDescription="Create a new low-code app"
unifiedSize="lg"
@@ -25,6 +25,7 @@
<!-- Buttons -->
<div class="flex flex-row gap-2">
<Button
id="create-flow-button"
aiId="flows-create-actions-flow"
aiDescription="Create a new flow"
unifiedSize="lg"
@@ -0,0 +1,62 @@
<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
/>
@@ -458,50 +458,52 @@
<AssetsDropdownButton {assets} />
{/if}
</div>
<Editor
loadAsync
folding
path={$pathStore + '/' + flowModule.id}
bind:websocketAlive
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
selected = 'test'
if (selectedId == flowModule.id) {
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
<div id="flow-editor-code-section" class="h-full relative">
<Editor
loadAsync
folding
path={$pathStore + '/' + flowModule.id}
bind:websocketAlive
bind:this={editor}
class="h-full relative"
code={flowModule.value.content}
scriptLang={flowModule?.value?.language}
automaticLayout={true}
cmdEnterAction={async () => {
selected = 'test'
if (selectedId == flowModule.id) {
if (flowModule.value.type === 'rawscript' && editor) {
flowModule.value.content = editor.getCode()
}
await reload(flowModule)
modulePreview?.runTestWithStepArgs()
}
await reload(flowModule)
modulePreview?.runTestWithStepArgs()
}
}}
on:change={async (event) => {
const content = event.detail
if (flowModule.value.type === 'rawscript') {
if (flowModule.value.content !== content) {
flowModule.value.content = content
}}
on:change={async (event) => {
const content = event.detail
if (flowModule.value.type === 'rawscript') {
if (flowModule.value.content !== content) {
flowModule.value.content = content
}
await reload(flowModule)
}
await reload(flowModule)
}
}}
formatAction={() => {
reload(flowModule)
saveDraft()
}}
fixedOverflowWidgets={true}
args={Object.entries(flowModule.value.input_transforms).reduce(
(acc, [key, obj]) => {
acc[key] = obj.type === 'static' ? obj.value : undefined
return acc
},
{}
)}
key={`flow-inline-${$workspaceStore}-${$pathStore}-${flowModule.id}`}
moduleId={flowModule.id}
/>
}}
formatAction={() => {
reload(flowModule)
saveDraft()
}}
fixedOverflowWidgets={true}
args={Object.entries(flowModule.value.input_transforms).reduce(
(acc, [key, obj]) => {
acc[key] = obj.type === 'static' ? obj.value : undefined
return acc
},
{}
)}
key={`flow-inline-${$workspaceStore}-${$pathStore}-${flowModule.id}`}
moduleId={flowModule.id}
/>
</div>
<DiffEditor
open={false}
bind:this={diffEditor}
@@ -38,6 +38,7 @@
</script>
<div
id={id || undefined}
class="relative flex gap-1 justify-between items-center w-full overflow-hidden rounded-sm
p-2 text-2xs module text-primary"
>
@@ -23,11 +23,9 @@
import Portal from '$lib/components/Portal.svelte'
import { getDependentComponents } from '../flowExplorer'
import { tutorialsToDo, workspaceStore } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
import FlowTutorials from '$lib/components/FlowTutorials.svelte'
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
import { tutorialInProgress } from '$lib/tutorialUtils'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import { replaceId } from '../flowStore.svelte'
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
@@ -104,8 +102,6 @@
flowHasChanged
}: Props = $props()
let flowTutorials: FlowTutorials | undefined = $state(undefined)
const { customUi, selectionManager, moving, history, flowStateStore, flowStore, pathStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
@@ -307,14 +303,6 @@
noteMode = !noteMode
}
function shouldRunTutorial(tutorialName: string, name: string, index: number) {
return (
$tutorialsToDo.includes(index) &&
name == tutorialName &&
!$ignoredTutorials.includes(index) &&
!tutorialInProgress()
)
}
const dispatch = createEventDispatcher<{
generateStep: { moduleId: string; instructions: string; lang: ScriptLang }
@@ -473,13 +461,7 @@
}
}}
onInsert={async (detail) => {
if (shouldRunTutorial('forloop', detail.detail, 1)) {
flowTutorials?.runTutorialById('forloop', detail.index)
} else if (shouldRunTutorial('branchone', detail.detail, 2)) {
flowTutorials?.runTutorialById('branchone')
} else if (shouldRunTutorial('branchall', detail.detail, 3)) {
flowTutorials?.runTutorialById('branchall')
} else {
{
let originalModules
let targetModules
if (
@@ -675,5 +657,5 @@
</div>
{#if !disableTutorials}
<FlowTutorials bind:this={flowTutorials} on:reload />
<FlowTutorials on:reload />
{/if}
@@ -527,7 +527,7 @@
<Skeleton layout={[[4], 0.5]} />
{/each}
{:else if filteredItems.length === 0}
<NoItemFound />
<NoItemFound hasFilters={filter !== '' || archived || filterUserFolders} />
{:else if treeView}
<TreeViewRoot
{items}
@@ -1,6 +1,24 @@
<div class="flex justify-center items-center h-48">
<div class="text-primary text-center">
<div class="text-lg font-semibold text-emphasis">No items found</div>
<div class="text-xs font-normal text-hint">Try changing your search or filters</div>
<script lang="ts">
interface Props {
hasFilters?: boolean
}
let { hasFilters = false }: Props = $props()
</script>
{#if hasFilters}
<div class="flex justify-center items-center h-48">
<div class="text-primary text-center">
<div class="text-lg font-semibold text-emphasis">No items found</div>
<div class="text-xs font-normal text-hint">Try changing your search or filters</div>
</div>
</div>
</div>
{:else}
<div class="flex justify-center items-center h-48">
<div class="text-primary text-center">
<div class="text-lg font-semibold text-emphasis">Welcome to Windmill</div>
<div class="text-xs font-normal text-hint">Get started by creating your first script, flow, or app</div>
</div>
</div>
{/if}
@@ -0,0 +1,81 @@
<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 } from '$lib/tutorialUtils'
import { onMount } from 'svelte'
const DISMISSED_KEY = 'tutorial_banner_dismissed'
let isDismissed = $state(false)
onMount(() => {
// Check if banner has been dismissed
isDismissed = getLocalSetting(DISMISSED_KEY) === 'true'
})
async function handleSkipAllTutorials() {
await skipAllTodos()
await syncTutorialsTodos()
storeLocalSetting(DISMISSED_KEY, 'true')
isDismissed = true
}
function dismissBanner() {
storeLocalSetting(DISMISSED_KEY, 'true')
isDismissed = true
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">
Learn with interactive tutorials
</div>
<div class="text-hint text-3xs truncate text-left font-normal">
Get started quickly with step-by-step guides on building flows, scripts, and more.
</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}
@@ -0,0 +1,124 @@
<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>
@@ -9,6 +9,7 @@
<!-- Buttons -->
<div class="flex flex-row gap-2">
<Button
id="create-script-button"
{aiId}
{aiDescription}
unifiedSize="lg"
@@ -11,7 +11,8 @@
LayoutDashboard,
Building,
Calendar,
ServerCog
ServerCog,
GraduationCap
} from 'lucide-svelte'
import { base } from '$lib/base'
@@ -21,7 +22,9 @@
enterpriseLicense,
superadmin,
userWorkspaces,
workspaceStore
workspaceStore,
tutorialsToDo,
skippedAll
} from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { USER_SETTINGS_HASH } from './settings'
@@ -52,10 +55,22 @@
[
{ 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 }
{ 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
}
]
: [])
].filter(
(link) =>
link.id === 'home' ||
link.id === 'tutorials' ||
($userWorkspaces &&
$workspaceStore &&
$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] ===
@@ -7,8 +7,11 @@
workspaceStore,
isCriticalAlertsUIOpen,
enterpriseLicense,
devopsRole
devopsRole,
tutorialsToDo,
skippedAll
} from '$lib/stores'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
import {
BookOpen,
@@ -21,6 +24,7 @@
FolderCog,
FolderOpen,
Github,
GraduationCap,
HelpCircle,
Home,
LogOut,
@@ -83,7 +87,7 @@
let recentChangelogs: Changelog[] = $state([])
let lastOpened = localStorage.getItem('changelogsLastOpened')
onMount(() => {
onMount(async () => {
if (lastOpened) {
// @ts-ignore
recentChangelogs = changelogs.filter((changelog) => changelog.date > lastOpened)
@@ -92,6 +96,8 @@
} else {
recentChangelogs = changelogs.slice(0, 3)
}
// Sync tutorial progress on mount
await syncTutorialsTodos()
})
function openChangelogs() {
@@ -105,33 +111,45 @@
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/',
icon: BookOpen,
aiId: 'sidebar-menu-link-docs',
aiDescription: 'Button to navigate to docs'
aiDescription: 'Button to navigate to docs',
external: true
},
{
label: 'Feedbacks',
href: 'https://discord.gg/V7PM2YHsPB',
icon: DiscordIcon,
aiId: 'sidebar-menu-link-feedbacks',
aiDescription: 'Button to navigate to feedbacks'
aiDescription: 'Button to navigate to feedbacks',
external: true
},
{
label: 'Issues',
href: 'https://github.com/windmill-labs/windmill/issues/new',
icon: Github,
aiId: 'sidebar-menu-link-issues',
aiDescription: 'Button to navigate to issues'
aiDescription: 'Button to navigate to issues',
external: true
},
{
label: 'Changelog',
href: 'https://www.windmill.dev/changelog/',
icon: Newspaper,
aiId: 'sidebar-menu-link-changelog',
aiDescription: 'Button to navigate to changelog'
aiDescription: 'Button to navigate to changelog',
external: true
}
]
}
@@ -202,7 +220,19 @@
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-assets',
aiDescription: 'Button to navigate to assets'
}
},
// 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'
}
]
: [])
])
let defaultExtraTriggerLinks = $derived([
{
@@ -619,7 +649,7 @@
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem href={subItem.href} class={itemClass} target="_blank" {item}>
<MenuItem href={subItem.href} class={itemClass} target={subItem.external !== false ? "_blank" : undefined} {item}>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
@@ -0,0 +1,741 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import { isFlowTainted, triggerPointerDown, clickButtonBySelector } 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)
// Constants for delays
const DELAY_SHORT = 100
const DELAY_MEDIUM = 300
const DELAY_LONG = 500
const DELAY_ANIMATION = 1500
const DELAY_ANIMATION_LONG = 2500
const DELAY_TYPING = 50
const DELAY_CODE_CHAR = 2
const DELAY_CODE_NEWLINE = 5
// Helper function to get driver overlay
function getDriverOverlay(): HTMLElement | null {
return document.querySelector('.driver-overlay') as HTMLElement | null
}
// 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 move cursor to element (for continuous cursor movement)
async function moveCursorToElement(
cursor: HTMLElement,
element: HTMLElement,
duration: number = DELAY_ANIMATION
): Promise<void> {
const rect = element.getBoundingClientRect()
cursor.style.transition = `all ${duration / 1000}s ease-in-out`
cursor.style.left = `${rect.left + rect.width / 2}px`
cursor.style.top = `${rect.top + rect.height / 2}px`
await wait(duration)
}
// Helper function to create and animate a fake cursor
async function createFakeCursor(
startElement: HTMLElement | null,
endElement: HTMLElement,
transitionDuration: number = 1.5
): Promise<HTMLElement> {
const fakeCursor = document.createElement('div')
fakeCursor.style.cssText = `
position: fixed;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(59, 130, 246, 0.8);
border: 2px solid white;
pointer-events: none;
z-index: 10000;
transition: all ${transitionDuration}s ease-in-out;
`
document.body.appendChild(fakeCursor)
const 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() {
try {
localStorage.removeItem('flow')
} catch (e) {
console.error('Error clearing localStorage', e)
}
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={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 createFakeCursor(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 createFakeCursor(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,101 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import { triggerPointerDown } from './utils'
import Tutorial from './Tutorial.svelte'
import { updateProgress } from '$lib/tutorialUtils'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
let tutorial: Tutorial | undefined = undefined
export function runTutorial(indexToInsertAt?: number | undefined) {
tutorial?.runTutorial({ indexToInsertAt })
}
</script>
<Tutorial
bind:this={tutorial}
index={3}
name="branchall"
on:error
on:skipAll
getSteps={(driver, options) => {
const index = options?.indexToInsertAt ?? flowStore.val.value.modules.length
const steps = [
{
popover: {
title: 'Branch all tutorial',
description:
'Learn how to build our first branch to be executed on a condition. You can use arrow keys to navigate'
}
},
{
element: `#flow-editor-add-step-${index}`,
popover: {
title: 'Add a step',
description: 'Click here to add a step to your flow',
onNextClick: () => {
triggerPointerDown(`#flow-editor-add-step-${index}`)
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
popover: {
title: 'Steps kind',
description: "Choose the kind of step you want to add. Let's start with a simple action"
},
element: '#flow-editor-insert-module'
},
{
popover: {
title: 'Insert Branch all',
description: "Let's pick branch all",
onNextClick: () => {
triggerPointerDown('#flow-editor-flow-kind-branch-to-all')
setTimeout(() => {
driver.moveNext()
})
}
},
element: '#flow-editor-flow-kind-branch-to-all'
},
{
element: '#flow-editor-branch-all-wrapper',
popover: {
title: 'Branches',
description:
'Here you can add a summary to a branch, or configure the branches to run in parallel',
onNextClick: () => {
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
popover: {
title: 'Add steps',
description: 'You can now add step to one of the branches',
onNextClick: () => {
setTimeout(() => {
driver.moveNext()
updateProgress(3)
})
}
}
}
]
return steps
}}
/>
@@ -1,121 +0,0 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import { clickButtonBySelector, updateFlowModuleById, triggerPointerDown } from './utils'
import Tutorial from './Tutorial.svelte'
import { updateProgress } from '$lib/tutorialUtils'
import { nextId } from '../flows/flowModuleNextId'
const { flowStore, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
const dispatch = createEventDispatcher()
let tutorial: Tutorial | undefined = undefined
export function runTutorial(indexToInsertAt?: number | undefined) {
tutorial?.runTutorial({ indexToInsertAt })
}
</script>
<Tutorial
bind:this={tutorial}
index={2}
name="branchone"
on:error
on:skipAll
getSteps={(driver, options) => {
const id = nextId(flowStateStore.val, flowStore.val)
const index = options?.indexToInsertAt ?? flowStore.val.value.modules.length
const steps = [
{
popover: {
title: 'Branch one tutorial',
description:
'Learn how to build our first branch to be executed on a condition. You can use arrow keys to navigate'
}
},
{
element: `#flow-editor-add-step-${index}`,
popover: {
title: 'Branch one',
description: 'Windmill supports branches, let us add one',
onNextClick: () => {
triggerPointerDown(`#flow-editor-add-step-${index}`)
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
popover: {
title: 'Steps kind',
description: 'Choose the kind of step you want to add.'
},
element: '#flow-editor-insert-module'
},
{
popover: {
title: 'Insert Branch one',
description: "Let's pick branch one",
onNextClick: () => {
triggerPointerDown('#flow-editor-flow-kind-branch-to-one')
setTimeout(() => {
driver.moveNext()
})
}
},
element: '#flow-editor-flow-kind-branch-to-one'
},
{
element: '#flow-editor-edit-predicate',
popover: {
title: 'Edit predicate',
description: 'Click here to edit the predicate of your branch',
onNextClick: () => {
clickButtonBySelector('#flow-editor-edit-predicate')
updateFlowModuleById(flowStore.val, id, (module) => {
if (module.value.type === 'branchone') {
module.value.branches[0].expr = "result.a === 'foo'"
}
})
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-branch-one-wrapper',
popover: {
title: 'Predicate saved',
description: 'You can now see the predicate of your branch',
onNextClick: () => {
dispatch('reload')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
popover: {
title: 'Add steps',
description: 'You can now add a step to one of the branches',
onNextClick: () => {
setTimeout(() => {
updateProgress(2)
driver.moveNext()
})
}
}
}
]
return steps
}}
/>
@@ -1,136 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import Tutorial from './Tutorial.svelte'
import { clickButtonBySelector, triggerPointerDown } from './utils'
import { updateProgress } from '$lib/tutorialUtils'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
let tutorial: Tutorial | undefined = undefined
export function runTutorial() {
tutorial?.runTutorial()
}
</script>
<Tutorial
bind:this={tutorial}
index={4}
name="error-handler"
tainted={false}
on:error
on:skipAll
getSteps={(driver) => [
{
popover: {
title: 'Error handler tutorial',
description: 'Learn how to recover from an error. You can use arrow keys to navigate.',
onNextClick: () => {
flowStore.val.value.modules = [
{
id: 'a',
value: {
type: 'rawscript',
content:
'// import * as wmill from "npm:windmill-client@1"\n\nexport async function main(x: string) {\n throw new Error("Fake error")\n}\n',
language: 'deno',
input_transforms: {
x: {
type: 'static',
value: ''
}
}
}
}
]
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-add-step-error-handler-button',
popover: {
title: 'Error handler',
description:
'You can add an error handler to your flow. It will be executed if any of the steps in the flow fails.',
onNextClick: () => {
triggerPointerDown('#flow-editor-add-step-error-handler-button')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
popover: {
title: 'Steps kind',
description: "Choose the kind of step you want to add. Let's start with a simple action"
},
element: '#flow-editor-insert-module'
},
{
element: '#flow-editor-flow-providers',
popover: {
title: 'Action configuration',
description: 'An action can be inlined, imported from your workspace or the Hub.'
}
},
{
element: '#flow-editor-flow-atoms',
popover: {
title: 'Supported languages',
description: 'Windmill support the following languages/runtimes.'
}
},
{
element: '#flow-editor-new-bun',
popover: {
title: 'Typescript',
description: "Let's create a Typescript error handler for your flow",
onNextClick: () => {
clickButtonBySelector('#flow-editor-new-bun')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-test-flow',
popover: {
title: 'Test your flow',
description: 'We can now test our flow',
onNextClick: () => {
clickButtonBySelector('#flow-editor-test-flow')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-test-flow-drawer',
popover: {
title: 'Test your flow',
description:
'Finally we can test our flow, and view how the error handler is executed when a step fails',
onNextClick: () => {
clickButtonBySelector('#flow-editor-test-flow-drawer')
setTimeout(() => {
driver.moveNext()
updateProgress(4)
})
}
}
}
]}
/>
@@ -1,279 +0,0 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import { emptyFlowModuleState } from '../flows/utils.svelte'
import { clickButtonBySelector, updateFlowModuleById, triggerPointerDown } from './utils'
import Tutorial from './Tutorial.svelte'
import { updateProgress } from '$lib/tutorialUtils'
import { nextId } from '../flows/flowModuleNextId'
const dispatch = createEventDispatcher()
const { flowStore, selectionManager, flowStateStore } =
getContext<FlowEditorContext>('FlowEditorContext')
let tutorial: Tutorial | undefined = undefined
export function runTutorial(indexToInsertAt?: number | undefined) {
tutorial?.runTutorial({ indexToInsertAt })
}
</script>
<Tutorial
bind:this={tutorial}
index={1}
name="forloop"
tainted={false}
on:error
on:skipAll
getSteps={(driver, options) => {
const id = nextId(flowStateStore.val, flowStore.val)
const index = options?.indexToInsertAt ?? flowStore.val.value.modules.length
let tempId = ''
return [
{
popover: {
title: 'For loops tutorial',
description:
'Learn how to build our first for loop to iterate on. You can use arrow keys to navigate.'
}
},
{
element: `#flow-editor-add-step-${index}`,
popover: {
title: 'Add a step',
description: 'Click here to add a step to your flow',
onNextClick: () => {
triggerPointerDown(`#flow-editor-add-step-${index}`)
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
popover: {
title: 'Steps kind',
description: "Choose the kind of step you want to add. Let's start with a simple action"
},
element: '#flow-editor-insert-module'
},
{
popover: {
title: 'Insert a loop',
description: "Let's pick a for loop",
onNextClick: () => {
triggerPointerDown('#flow-editor-flow-kind-for-loop')
setTimeout(() => {
driver.moveNext()
})
}
},
element: '#flow-editor-flow-kind-for-loop'
},
{
element: '#flow-editor-iterator-expression',
popover: {
title: 'Iterator expression',
description:
'The iterator expression is a JavaScript expression that respresents the array to iterate on. Here we will iterate on the firstname input letter by letter',
onNextClick: () => {
updateFlowModuleById(flowStore.val, id, (module) => {
if (module.value.type === 'forloopflow') {
if (module.value.iterator.type === 'javascript') {
module.value.iterator.expr = '[1,2,3]'
}
}
})
dispatch('reload')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-iterator-expression',
popover: {
title: 'Iterator expression',
description:
'We can refer to the result of previous steps using the results object: results.a or use static values like [1,2,3] in this case.',
onNextClick: () => {
updateFlowModuleById(flowStore.val, id, (module) => {
const newId = nextId(flowStateStore.val, flowStore.val)
tempId = newId
if (module.value.type === 'forloopflow') {
module.value.modules = [
{
id: newId,
value: {
type: 'rawscript',
content: 'def main(x):\n return x',
// @ts-ignore
language: 'python3',
input_transforms: {
x: {
type: 'javascript',
// @ts-ignore
value: '',
expr: ''
}
}
}
}
]
}
flowStateStore.val[newId] = emptyFlowModuleState()
let schema = flowStateStore.val[newId].schema ?? { properties: {} }
schema.properties = {
x: {
type: 'string',
description: '',
default: null
}
}
})
dispatch('reload')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
popover: {
title: 'Step of the loop',
description: 'We added an action to the loop. Lets configure it',
onNextClick: () => {
selectionManager.selectId(tempId)
dispatch('reload')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-editor',
popover: {
title: 'Python step',
description:
'We can write python code in the editor. In this example we will capitalize the letter'
}
},
{
element: '#flow-editor-step-input',
onHighlighted: () => {
document.querySelector('#flow-editor-plug')?.parentElement?.classList.remove('opacity-0')
},
popover: {
title: 'Flow inputs',
description: 'UI is autogenerated from your code.'
}
},
{
element: '#flow-editor-plug',
popover: {
title: 'Input configuration',
description: 'Lets connect the input to the letter input',
onNextClick: () => {
clickButtonBySelector('#flow-editor-plug')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '.key',
popover: {
title: 'Connect',
description: 'As we did before, we can connect to the iterator of the loop',
onNextClick: () => {
updateFlowModuleById(flowStore.val, id, (module) => {
if (
module.value.type === 'forloopflow' &&
module.value.modules[0].value.type === 'rawscript'
) {
module.value.modules[0].value.input_transforms = {
x: {
type: 'javascript',
expr: 'flow_input.iter.value'
}
}
}
})
dispatch('reload')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-step-input',
popover: {
title: 'Iterator',
description:
'Loops expose an iterator object that contains the current value of the loop and the index',
onNextClick: () => {
setTimeout(() => {
driver.moveNext()
updateProgress(1)
})
}
}
},
{
element: '#flow-editor-test-flow',
popover: {
title: 'Test your flow',
description: 'We can now test our flow',
onNextClick: () => {
clickButtonBySelector('#flow-editor-test-flow')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-test-flow-drawer',
popover: {
title: 'Test your flow',
description: 'Finally we can test our flow, and view the results!',
onNextClick: () => {
clickButtonBySelector('#flow-editor-test-flow-drawer')
setTimeout(() => {
driver.moveNext()
updateProgress(0)
})
}
}
}
]
}}
/>
@@ -1,291 +0,0 @@
<script lang="ts">
import { createEventDispatcher, getContext, tick } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import { updateProgress } from '$lib/tutorialUtils'
import {
clickButtonBySelector,
isFlowTainted,
setInputBySelector,
triggerPointerDown,
waitForElementLoading
} from './utils'
import Tutorial from './Tutorial.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import { wait } from '$lib/utils'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
const dispatch = createEventDispatcher()
let tutorial: Tutorial | undefined = undefined
export function runTutorial() {
tutorial?.runTutorial()
}
</script>
<Tutorial
bind:this={tutorial}
index={0}
name="action"
tainted={isFlowTainted(flowStore.val)}
on:error
on:skipAll
getSteps={(driver) => [
{
popover: {
title: 'Flow builder tutorial',
description:
'Learn how to build powerful flows in a few steps. You can use arrow keys to navigate.'
}
},
{
popover: {
title: 'Flows inputs',
description: 'Flows have inputs that can be used in the flow',
onNextClick: async () => {
triggerPointerDown('#flow-editor-virtual-Input')
await wait(20)
clickButtonBySelector('#add-flow-input-btn')
await wait(0)
setInputBySelector('input[placeholder="Field name"]', 'firstname')
setTimeout(() => driver.moveNext())
}
},
element: '#flow-editor-virtual-Input'
},
{
element: 'input[placeholder="Field name"]',
popover: {
title: 'Name your property',
description: 'Give a name to your property. Here we will call it firstname',
onNextClick: () => {
setTimeout(() => {
driver.moveNext()
})
},
onPrevClick: () => {
clickButtonBySelector('#add-flow-input-btn') // Close the input drawer
setTimeout(() => driver.movePrevious())
}
}
},
{
element: '*:has(> #flow-editor-add-property)',
popover: {
title: 'Add your property',
description: 'Click here to save your property',
onNextClick: () => {
clickButtonBySelector('#flow-editor-add-property')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-add-step-0',
popover: {
title: 'Add a step',
description: 'Click here to add a step to your flow',
onNextClick: () => {
triggerPointerDown(`#flow-editor-add-step-0`)
setTimeout(() => driver.moveNext())
},
onPrevClick: async () => {
clickButtonBySelector('.delete-schema-field-button')
await wait(0)
clickButtonBySelector('#add-flow-input-btn')
await wait(0)
setInputBySelector('input[placeholder="Field name"]', 'firstname')
setTimeout(() => driver.movePrevious())
}
}
},
{
popover: {
title: 'Steps kind',
description: "Choose the kind of step you want to add. Let's start with a simple action",
onPrevClick: () => {
triggerPointerDown(`#flow-editor-add-step-0`)
setTimeout(() => driver.movePrevious())
}
},
element: '#flow-editor-insert-module'
},
{
popover: {
title: 'Pick an action',
description: 'Lets pick an action to add to your flow',
onNextClick: () => {
triggerPointerDown('#flow-editor-insert-module > div > div > button:nth-child(1)')
setTimeout(() => {
driver.moveNext()
})
}
},
element: '#flow-editor-insert-module > div > div > button:nth-child(1)'
},
{
element: '#flow-editor-flow-providers',
popover: {
title: 'Action configuration',
description: 'An action can be inlined, imported from your workspace or the Hub.'
}
},
{
element: '#flow-editor-flow-atoms',
popover: {
title: 'Supported languages',
description: 'Windmill support the following languages/runtimes.'
}
},
{
element: '#flow-editor-new-bun',
popover: {
title: 'Typescript',
description: "Let's write a script for your flow",
onNextClick: () => {
clickButtonBySelector('#flow-editor-new-bun')
tick().then(() =>
waitForElementLoading('#flow-editor-editor', () => {
driver.moveNext()
})
)
}
}
},
{
element: '#flow-editor-editor',
popover: {
title: 'Action editor',
description: 'Windmill provides a full code editor to write your actions',
onPrevClick: () => {
triggerPointerDown(`#flow-editor-virtual-Input`)
triggerPointerDown(`#flow-editor-add-step-0`)
setTimeout(() => {
driver.movePrevious()
})
}
}
},
{
element: '#flow-editor-step-input',
onHighlighted: () => {
document.querySelector('#flow-editor-plug')?.parentElement?.classList.remove('opacity-0')
},
popover: {
title: 'Autogenerated schema',
description: 'The schema and the UI is autogenerated from your code'
}
},
{
element: '#flow-editor-plug',
onHighlighted: () => {
document.querySelector('#flow-editor-plug')?.parentElement?.classList.remove('opacity-0')
},
popover: {
title: 'Connect',
description:
'You can provide static values or connect to other nodes result. Here we will connect to the firstname input',
onNextClick: () => {
clickButtonBySelector('#flow-editor-plug')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#flow-editor-step-input .prop-picker-inputs',
popover: {
title: 'Connection mode',
description: 'Once you pressed the connect button, you can choose what to connect to.',
onNextClick: () => {
if (flowStore.val.value.modules[0].value.type === 'rawscript') {
flowStore.val.value.modules[0].value.input_transforms = {
x: {
type: 'javascript',
expr: 'flow_input.firstname'
}
}
}
refreshStateStore(flowStore)
dispatch('reload')
setTimeout(() => {
driver.moveNext()
})
},
onPrevClick: () => {
clickButtonBySelector('#flow-editor-plug')
setTimeout(() => driver.movePrevious())
}
}
},
{
element: '#flow-editor-step-input',
popover: {
title: 'Input connected!',
description: 'The input is now connected to the firstname input',
onPrevClick: () => {
clickButtonBySelector('#flow-editor-plug')
setTimeout(() => driver.movePrevious())
}
}
},
{
element: '#flow-editor-test-flow',
popover: {
title: 'Test your flow',
description: 'We can now test our flow',
onNextClick: () => {
clickButtonBySelector('#flow-editor-test-flow')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: 'textarea.w-full',
popover: {
title: 'Flow input',
description: 'Lets provide an input to our flow',
onNextClick: () => {
setInputBySelector('textarea.w-full', 'Hello World!')
setTimeout(() => {
driver.moveNext()
})
},
onPrevClick: () => {
clickButtonBySelector('#flow-editor-test-flow') // Close the test drawer
setTimeout(() => driver.movePrevious())
}
}
},
{
element: '#flow-editor-test-flow-drawer',
popover: {
title: 'Test your flow',
description: 'Finally we can test our flow, and view the results!',
onNextClick: () => {
clickButtonBySelector('#flow-editor-test-flow-drawer')
setTimeout(() => {
driver.moveNext()
updateProgress(0)
})
}
}
}
]}
/>
@@ -0,0 +1,457 @@
<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'
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 delays
const DELAY_SHORT = 100
const DELAY_MEDIUM = 300
const DELAY_LONG = 500
// Constants for cursor animation
const CURSOR_START_OFFSET = -100
const CURSOR_CLICK_SCALE = 0.8
// 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
async function createFakeCursor(
startElement: HTMLElement | null,
endElement: HTMLElement,
transitionDuration: number = 1.5
): Promise<HTMLElement> {
const fakeCursor = document.createElement('div')
fakeCursor.style.cssText = `
position: fixed;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(59, 130, 246, 0.8);
border: 2px solid white;
pointer-events: none;
z-index: 10000;
transition: all ${transitionDuration}s ease-in-out;
`
document.body.appendChild(fakeCursor)
const 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 createFakeCursor(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
}}
/>
@@ -0,0 +1,29 @@
<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>
@@ -0,0 +1,64 @@
<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}
@@ -0,0 +1,35 @@
<script lang="ts">
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(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,267 +0,0 @@
<script lang="ts">
import { insertNewGridItem, appComponentFromType } from '$lib/components/apps/editor/appUtils'
import type { AppComponent, TypedComponent } 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,
clickFirstButtonBySelector,
connectInlineRunnableInputToComponentOutput,
isAppTainted,
updateInlineRunnableCode
} from '../utils'
import { updateProgress } from '$lib/tutorialUtils'
import { type DriveStep } from 'driver.js'
import { wait } from '$lib/utils'
export let name: string
export let index: number
let tutorial: Tutorial | undefined = undefined
const { app, selectedComponent, focusedGrid } = getContext<AppViewerContext>('AppViewerContext')
const { history } = getContext<AppEditorContext>('AppEditorContext')
export function runTutorial() {
tutorial?.runTutorial()
}
function addComponent(appComponentType: TypedComponent['type']): void {
push(history, $app)
const id = insertNewGridItem(
$app,
appComponentFromType(appComponentType) as (id: string) => AppComponent,
$focusedGrid
)
$selectedComponent = [id]
$app = $app
}
</script>
<Tutorial
bind:this={tutorial}
{index}
{name}
on:error
on:skipAll
tainted={isAppTainted($app)}
getSteps={(driver) => {
const steps: DriveStep[] = [
{
popover: {
title: 'App editor tutorial',
description:
'This tutorial will show you how to use the App editor, add components, background scripts and connect them.',
onNextClick: () => {
addComponent('textinputcomponent')
setTimeout(() => {
clickButtonBySelector('#app-editor-component-library-tab')
})
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#app-editor-component-list',
popover: {
title: 'Components panel',
description:
'This is the components panel. Here you can add components to your app. Components are the building blocks of your app. You can add as many components as you want.'
}
},
{
element: '#displaycomponent',
popover: {
title: 'Adding a component',
description:
'Click on a component to add it to your app. Here we will add a display component.',
onNextClick: () => {
if (!$selectedComponent?.includes('e')) {
addComponent('displaycomponent')
}
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '.wm-app-viewer',
popover: {
title: 'App canvas',
description:
'In the canvas, you can move components around, resize them, and organize them in grids. In this example, we already added a text input and a display component.',
onNextClick: () => {
driver.moveNext()
}
}
},
{
element: '#component-input',
popover: {
title: 'Component input',
description:
'There are several ways to set the input of a component. It can be static, the result of a JS expression, connected to the output of another component, or the result of an inline runnable. Here we will create an inline runnable that will convert the text to uppercase.',
onNextClick: () => {
clickFirstButtonBySelector('#component-input')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#data-source-compute',
popover: {
title: 'Compute',
description: 'Click on the compute button to create a new inline runnable.',
onNextClick: () => {
clickButtonBySelector('#data-source-compute')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#app-editor-create-inline-script',
popover: {
title: 'Create an inline script',
description: "Let's create an inline script.",
onNextClick: () => {
clickButtonBySelector('#app-editor-create-inline-script')
setTimeout(() => driver.moveNext())
}
}
},
{
element: '#app-editor-empty-runnable',
popover: {
title: 'Choose a language',
description:
'You can choose the language of your runnable. There are two type of runnables: frontend and backend.'
}
},
{
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.'
}
},
{
element: '#create-deno-script',
onHighlighted: () => {
document.querySelector('#schema-plug-x')?.parentElement?.classList.remove('opacity-0')
},
popover: {
title: 'Create a deno script',
description:
"Let's create a simple deno script. For the sake of this tutorial, we will create a script that converts the text to uppercase.",
onNextClick: async () => {
clickButtonBySelector('#create-deno-script')
await wait(50)
if ($selectedComponent?.[0]) {
updateInlineRunnableCode(
$app,
$selectedComponent[0],
'export function main(x: string) {\n return x?.toLocaleUpperCase();\n}'
)
}
driver.moveNext()
}
}
},
{
element: '#schema-plug-x',
onHighlighted: () => {
document.querySelector('#schema-plug-x')?.parentElement?.classList.remove('opacity-0')
},
popover: {
title: 'Connect the function input',
description:
"The function we created has an string input 'x'. We can connect the output of the text component to it.",
onNextClick: () => {
clickButtonBySelector('#schema-plug-x')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#connect-output-a',
popover: {
title: 'Select the output',
description: 'Open the output selector of the text input component.',
onNextClick: () => {
clickButtonBySelector('#connect-output-a')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '.component-output-viewer-a li *:has(> button[title="result"])',
popover: {
title: 'Select the output',
description: "Let's select the result of the text input component.",
onNextClick: () => {
setTimeout(async () => {
clickButtonBySelector('.component-output-viewer-a li button[title="result"]')
driver.moveNext()
})
}
}
},
{
element: '.wm-app-viewer',
popover: {
title: "Let's test out the app !",
description:
'We can now type in the text input and see the result in the display component',
onNextClick: () => {
connectInlineRunnableInputToComponentOutput($app, 'e', 'x', 'd', 'result', 'integer')
$app = $app
updateProgress(7)
setTimeout(() => {
driver.moveNext()
})
}
}
}
]
return steps
}}
/>
@@ -0,0 +1,114 @@
<script lang="ts">
import { updateProgress } from '$lib/tutorialUtils'
import Tutorial from '../Tutorial.svelte'
import type { DriveStep } from 'driver.js'
import { goto } from '$app/navigation'
import { base } from '$lib/base'
import { page } from '$app/stores'
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={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: () => {
// Wait a bit to ensure the page is fully rendered before moving to next step
setTimeout(() => {
const button = document.querySelector('#create-script-button') as HTMLElement | null
if (button) {
driver.moveNext()
} else {
alert('Could not find the Create Script button. Please make sure you are on the home page.')
}
}, 100)
}
}
},
{
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>Scripts turn code into tools. Write in Python, TypeScript, Go, Bash, SQL and more. Run them manually, on schedule, or via webhooks.</p>',
onNextClick: async () => {
// Move to the next step (Create Flow button)
setTimeout(() => {
const button = document.querySelector('#create-flow-button') as HTMLElement | null
if (button) {
driver.moveNext()
} else {
alert('Could not find the Create Flow button. Please make sure you are on the home page.')
}
}, 100)
}
},
element: '#create-script-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>Flows orchestrate multiple scripts. Chain them together with branching, loops, and error handling to build complex workflows.</p>',
onNextClick: async () => {
// Move to the next step (Create App button)
setTimeout(() => {
const button = document.querySelector('#create-app-button') as HTMLElement | null
if (button) {
driver.moveNext()
} else {
alert('Could not find the Create App button. Please make sure you are on the home page.')
}
}, 100)
}
},
element: '#create-flow-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>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-app-button',
}
]
return steps
}}
/>
+1
View File
@@ -55,6 +55,7 @@ export function clearWorkspaceFromStorage() {
}
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)
+166 -6
View File
@@ -1,13 +1,51 @@
import { get } from 'svelte/store'
import { tutorialsToDo } from './stores'
import { tutorialsToDo, skippedAll } from './stores'
import { UserService } from './gen'
import { TUTORIALS_CONFIG } from './tutorials/config'
const MAX_TUTORIAL_ID = 7
/**
* 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
@@ -15,7 +53,7 @@ export async function updateProgress(id: number) {
bits = bits | mask
}
}
await UserService.updateTutorialProgress({ requestBody: { progress: bits } })
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: false } })
}
export async function skipAllTodos() {
@@ -25,7 +63,8 @@ export async function skipAllTodos() {
bits = bits | mask
}
tutorialsToDo.set([])
await UserService.updateTutorialProgress({ requestBody: { progress: bits } })
skippedAll.set(true)
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } })
}
export async function resetAllTodos() {
@@ -34,12 +73,121 @@ export async function resetAllTodos() {
todos.push(i)
}
tutorialsToDo.set(todos)
skippedAll.set(false)
await UserService.updateTutorialProgress({ requestBody: { progress: 0 } })
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 bits: number = (await UserService.getTutorialProgress()).progress!
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
@@ -48,6 +196,7 @@ export async function syncTutorialsTodos() {
}
}
tutorialsToDo.set(todos)
skippedAll.set(skipped)
}
export function tutorialInProgress() {
@@ -55,3 +204,14 @@ export function tutorialInProgress() {
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
}
+131
View File
@@ -0,0 +1,131 @@
import type { ComponentType } from 'svelte'
import { Workflow, GraduationCap, Wrench, PlayCircle, Link2 } 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'],
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&nodraft=true`
},
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&nodraft=true`
},
index: 3,
active: true,
comingSoon: false,
roles: ['admin','developer'],
order: 3
}
]
},
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&nodraft=true`
},
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&nodraft=true`
},
index: 5,
active: true,
comingSoon: false,
roles: ['developer', 'admin'],
order: 5
}
]
}
} as const
+64
View File
@@ -0,0 +1,64 @@
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.
* This is a convenience wrapper around hasRoleAccess for preview mode.
*/
export function hasRoleAccessForPreview(
previewRole: Role,
roles?: Role[]
): boolean {
return hasRoleAccess(null, roles, previewRole)
}
@@ -34,28 +34,34 @@
import { setQuery } from '$lib/navigation'
import { page } from '$app/stores'
import { goto, replaceState } from '$app/navigation'
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'
type Tab = 'hub' | 'workspace'
let tab: Tab =
let tab: Tab = $state(
window.location.hash == '#workspace' || window.location.hash == '#hub'
? (window.location.hash?.replace('#', '') as Tab)
: 'workspace'
)
let subtab: 'flow' | 'script' | 'app' = 'script'
let subtab: 'flow' | 'script' | 'app' = $state('script')
let filter: string = ''
let filter: string = $state('')
let flowViewer: Drawer
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined
let flowViewer: Drawer | undefined = $state(undefined)
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined = $state(undefined)
let appViewer: Drawer
let appViewerApp: { app?: any & { id?: number } } | undefined
let appViewer: Drawer | undefined = $state(undefined)
let appViewerApp: { app?: any & { id?: number } } | undefined = $state(undefined)
let codeViewer: Drawer
let codeViewerContent: string = ''
let codeViewerLanguage: Script['language'] = 'deno'
let codeViewerObj: HubItem | undefined = undefined
let codeViewer: Drawer | undefined = $state(undefined)
let codeViewerContent: string = $state('')
let codeViewerLanguage: Script['language'] = $state('deno')
let codeViewerObj: HubItem | undefined = $state(undefined)
const breakpoint = writable<EditorBreakpoint>('lg')
@@ -68,7 +74,7 @@
codeViewerObj = obj
})
codeViewer.openDrawer?.()
codeViewer?.openDrawer?.()
}
async function viewFlow(obj: { flow_id: number }): Promise<void> {
@@ -77,7 +83,7 @@
delete hub['comments']
flowViewerFlow = hub
})
flowViewer.openDrawer?.()
flowViewer?.openDrawer?.()
}
async function viewApp(obj: { app_id: number }): Promise<void> {
@@ -86,8 +92,31 @@
delete hub['comments']
appViewerApp = hub
})
appViewer.openDrawer?.()
appViewer?.openDrawer?.()
}
let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined)
// Provide workspaceTutorials to child components via a reactive wrapper
let workspaceTutorialsContext = $derived(workspaceTutorials)
setContext('workspaceTutorials', { get value() { return workspaceTutorialsContext } })
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 (!$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">
@@ -233,17 +262,22 @@
Windmill instance, such as keeping resource types up to date.
</Alert>
{/if}
<PageHeader title="Home">
<div class="flex flex-row gap-4 flex-wrap justify-end items-center">
{#if !$userStore?.operator}
<span class="text-xs font-normal text-primary">Create a</span>
<CreateActionsScript aiId="create-script-button" aiDescription="Creates a new script" />
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
{/if}
</div>
<PageHeader
title="Home"
childrenWrapperDivClasses="flex-1 flex flex-row gap-4 flex-wrap justify-end items-center"
>
{#if !$userStore?.operator}
<span class="text-xs font-normal text-primary">Create a</span>
<CreateActionsScript aiId="create-script-button" aiDescription="Creates a new script" />
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
{/if}
</PageHeader>
{#if !$userStore?.operator}
<TutorialBanner />
{/if}
{#if !$userStore?.operator}
<div class="w-full overflow-auto scrollbar-hidden pb-2">
<Tabs values={['hub', 'workspace']} hashNavigation bind:selected={tab}>
@@ -320,3 +354,5 @@
{#if tab == 'workspace'}
<ItemsList bind:filter bind:subtab />
{/if}
<WorkspaceTutorials bind:this={workspaceTutorials} />
@@ -14,8 +14,10 @@
import { sendUserToast } from '$lib/toast'
import { DEFAULT_THEME } from '$lib/components/apps/editor/componentsPanel/themeUtils'
import { emptyApp } from '$lib/components/apps/editor/appUtils'
import { tick } from 'svelte'
let nodraft = $page.url.searchParams.get('nodraft')
let appEditor: AppEditor | undefined = $state(undefined)
const hubId = $page.url.searchParams.get('hub')
const templatePath = $page.url.searchParams.get('template')
const templateId = $page.url.searchParams.get('template_id')
@@ -111,6 +113,19 @@
} else {
value = emptyApp()
}
// Trigger tutorial after everything is initialized
const tutorialParam = $page.url.searchParams.get('tutorial')
if (tutorialParam) {
// Wait for critical elements to be ready before triggering tutorial
await tick()
let attempts = 0
while (attempts < 20 && !document.querySelector('#app-editor-runnable-panel')) {
await new Promise(resolve => setTimeout(resolve, 100))
attempts++
}
appEditor?.triggerTutorial()
}
}
</script>
@@ -118,6 +133,7 @@
<div class="h-screen">
{#key value}
<AppEditor
bind:this={appEditor}
onSavedNewAppPath={(path) => {
goto(`/apps/edit/${path}`)
}}
@@ -143,15 +143,24 @@
flow = flow
goto('?', { replaceState: true })
selectedId = 'constants'
} else {
tick().then(() => {
flowBuilder?.triggerTutorial()
})
}
}
await initFlow(flow, flowStore, flowStateStore)
flowBuilder?.loadFlowState()
loading = false
// Trigger tutorial after everything is initialized
const tutorialParam = $page.url.searchParams.get('tutorial')
if (tutorialParam) {
// Wait for critical elements to be ready before triggering tutorial
await tick()
let attempts = 0
while (attempts < 20 && !document.querySelector('#flow-editor-virtual-Input')) {
await new Promise(resolve => setTimeout(resolve, 100))
attempts++
}
flowBuilder?.triggerTutorial()
}
}
loadFlow()
@@ -0,0 +1,431 @@
<script lang="ts">
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Tab } from '$lib/components/common'
import Tooltip from '$lib/components/Tooltip.svelte'
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'
// 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
const usePreview = user?.is_admin && selectedPreviewRole !== userEffectiveRole
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>
<div class="flex flex-col gap-4 pb-2 my-4 mr-2">
<div class="flex flex-row flex-wrap justify-between items-start">
<span class="flex items-center gap-2">
<h1 class="text-2xl font-semibold text-emphasis whitespace-nowrap leading-6 tracking-tight"
>Tutorials</h1
>
<Tooltip documentationLink="https://www.windmill.dev/docs/intro">
Learn how to use Windmill with our interactive tutorials
</Tooltip>
</span>
{#if activeTabs.length > 0}
<div class="flex items-start gap-2 pt-1">
<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}
</div>
{#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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB