mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 00:01:34 +00:00
dbf8d47b29
* 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>
458 lines
14 KiB
Svelte
458 lines
14 KiB
Svelte
<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
|
||
}}
|
||
/>
|