Files
windmill/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte
T
Tristan TR dbf8d47b29 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>
2025-12-08 16:42:35 +00:00

458 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
}}
/>