-
Notifications
You must be signed in to change notification settings - Fork 850
feat: new live onboarding for flows #7194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Deploying windmill with
|
| Latest commit: |
70a0717
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://05b81e11.windmill.pages.dev |
| Branch Preview URL: | https://tl-workspace-onboarding.windmill.pages.dev |
|
Claude finished @tristantr's task —— View job Code Review Complete ✓I've completed a comprehensive review of PR #7194 which adds a new live onboarding feature for flows. Here's my assessment: 🚨 Critical Issues1. Svelte 4 Syntax in New File (Must Fix)
Required changes:
|
| import { base } from '$lib/base' | ||
| export let name: string = 'workspace-onboarding' | ||
| export let index: number = 8 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Use Svelte 5 Runes Instead of export let
This file uses Svelte 4 syntax (export let) instead of the required Svelte 5 runes. According to the project's Svelte 5 best practices:
| export let index: number = 8 | |
| let { name = 'workspace-onboarding', index = 8 } = $props() |
Additional issues to fix:
- Line 10: Change
let tutorial: Tutorial | undefined = undefinedtolet tutorial: Tutorial | undefined = $state(undefined) - Line 24: Remove the
bind:this={tutorial}binding on the Tutorial component and use a different pattern if needed - Lines 28-29: Replace event dispatchers
on:errorandon:skipAllwith callback props
You can use the Svelte 4 to Svelte 5 migration script from VS Code/Cursor with the Svelte extension to automatically convert this file.
| // Helper function to find the create script button | ||
| const findScriptButton = (): HTMLElement | null => { | ||
| const button = document.querySelector('#create-script-button') as HTMLElement | null | ||
| if (button) { | ||
| console.log('Found script button:', button) | ||
| } else { | ||
| console.error('Could not find script button') | ||
| } | ||
| return button | ||
| } | ||
|
|
||
| // Helper function to find the create flow button | ||
| const findFlowButton = (): HTMLElement | null => { | ||
| const button = document.querySelector('#create-flow-button') as HTMLElement | null | ||
| if (button) { | ||
| console.log('Found flow button:', button) | ||
| } else { | ||
| console.error('Could not find flow button') | ||
| } | ||
| return button | ||
| } | ||
|
|
||
| // Helper function to find the create app button | ||
| const findAppButton = (): HTMLElement | null => { | ||
| const button = document.querySelector('#create-app-button') as HTMLElement | null | ||
| if (button) { | ||
| console.log('Found app button:', button) | ||
| } else { | ||
| console.error('Could not find app button') | ||
| } | ||
| return button | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verbose Helper Functions
As noted in the review, these helper functions are unnecessarily verbose since you still check if the button exists later. Consider simplifying to:
| // Helper function to find the create script button | |
| const findScriptButton = (): HTMLElement | null => { | |
| const button = document.querySelector('#create-script-button') as HTMLElement | null | |
| if (button) { | |
| console.log('Found script button:', button) | |
| } else { | |
| console.error('Could not find script button') | |
| } | |
| return button | |
| } | |
| // Helper function to find the create flow button | |
| const findFlowButton = (): HTMLElement | null => { | |
| const button = document.querySelector('#create-flow-button') as HTMLElement | null | |
| if (button) { | |
| console.log('Found flow button:', button) | |
| } else { | |
| console.error('Could not find flow button') | |
| } | |
| return button | |
| } | |
| // Helper function to find the create app button | |
| const findAppButton = (): HTMLElement | null => { | |
| const button = document.querySelector('#create-app-button') as HTMLElement | null | |
| if (button) { | |
| console.log('Found app button:', button) | |
| } else { | |
| console.error('Could not find app button') | |
| } | |
| return button | |
| } | |
| const steps: DriveStep[] = [ | |
| { | |
| popover: { | |
| title: 'Welcome to your Windmill workspace! 🎉', | |
| description: | |
| "Let's take a quick tour! In this tutorial, we'll create a simple flow so you", | |
| 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) | |
| } | |
| } | |
| }, |
Then use this pattern directly in each step instead of calling the helper functions.
| <WorkspaceOnboardingTutorial | ||
| bind:this={workspaceOnboardingTutorial} | ||
| on:error | ||
| on:skipAll={skipAll} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use on: prefix for event handlers in Svelte 5
While using on:error and on:skipAll works in compatibility mode, Svelte 5 best practices recommend using callback props instead of event dispatchers. Consider changing the Tutorial component to accept callback props like onerror and onskipAll.
However, if you're keeping the on: pattern for backwards compatibility with existing code, this is acceptable.
| 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: {} | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential Performance Issue: Large Embedded Flow Data
This file contains a hardcoded flow structure with all the code content embedded (lines 148-220). This significantly increases the bundle size for this component. Consider:
- Lazy loading: Load this data dynamically when the tutorial starts
- External file: Move the flow definition to a separate JSON file that can be imported
- Code splitting: Ensure this large component is only loaded when the tutorial is actually triggered
Example:
// Create a separate file: flowTemplates.ts
export const temperatureConverterFlow = { ... }
// Then import it here
import { temperatureConverterFlow } from './flowTemplates'
const flowJson: Flow = temperatureConverterFlow| onHighlighted: async () => { | ||
| // 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_VERY_LONG) | ||
|
|
||
| // 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_MEDIUM) | ||
| testTabButton.click() | ||
| } | ||
|
|
||
| await wait(DELAY_VERY_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() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excessive DOM Manipulation and Animations
This tutorial has extensive DOM manipulation with fake cursors, custom overlays, and complex animations. While this creates a nice user experience, there are several concerns:
- Performance: Creating/removing DOM elements frequently (lines 473-587) can cause layout thrashing
- Maintainability: Hardcoded selectors like
'button[title="flow_input.celsius"]'are fragile and will break if the UI changes - Timing dependencies: Heavy reliance on
setTimeoutandwait()calls makes this brittle
Recommendations:
- Consider using CSS animations instead of JS-based transitions where possible
- Use more robust selectors or data attributes (e.g.,
data-tutorial-target) instead of hardcoded titles - Add error handling for when elements aren't found
- Consider if some of these animations could be simplified without losing the educational value
| onNextClick: () => { | ||
| // Only proceed if code writing is complete | ||
| if (!step4Complete) { | ||
| return | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Race Condition: Blocking on step4Complete flag
The onNextClick handler blocks if step4Complete is false, but there's no user feedback or timeout. If the code typing animation fails or takes longer than expected, the user will be stuck without knowing why the "Next" button doesn't work.
Recommendation:
onNextClick: () => {
if (!step4Complete) {
// Show a toast or visual feedback
sendUserToast('Please wait for the code to finish typing...', false)
return
}
// ... rest of the code
}| duration: number = DELAY_ANIMATION | ||
| ): Promise<void> { | ||
| const rect = element.getBoundingClientRect() | ||
| cursor.style.transition = `all ${duration / 1000}s ease-in-out` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential XSS Risk: Unescaped HTML in Description
HTML is being injected directly into the popover description. While this appears to be static content, it's a potential security risk if any dynamic content is ever added here.
Recommendation:
Use Svelte's snippet feature or a safer approach:
{#snippet popoverDescription()}
<img src="/languages.png" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px;" />
<p>Click to create your first script!</p>
{/snippet}Or at minimum, add a comment documenting that this HTML must never contain user input.
| onMount(() => { | ||
| // Check if user hasn't completed or ignored the workspace onboarding tutorial | ||
| if (!$ignoredTutorials.includes(8) && $tutorialsToDo.includes(8)) { | ||
| // Small delay to ensure page is fully loaded | ||
| setTimeout(() => { | ||
| workspaceTutorials?.runTutorialById('workspace-onboarding') | ||
| }, 500) | ||
| } | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tutorial Auto-Start on Page Load
The tutorial automatically starts 500ms after page load if the user hasn't completed or ignored it. While this is intentional, consider:
- User experience: Some users might find auto-starting tutorials intrusive, especially on subsequent visits
- Consider a preference: Add a user preference to disable auto-start
- Analytics: Track if users are dismissing the tutorial immediately to gauge if the auto-start is helpful
The 500ms delay is good to ensure the page is loaded, but you might want to also check if critical elements exist before starting.
| // Trigger tutorial after everything is initialized | ||
| const tutorialParam = $page.url.searchParams.get('tutorial') | ||
| if (tutorialParam) { | ||
| // Wait a bit to ensure FlowBuilder and FlowTutorials are fully initialized | ||
| setTimeout(() => { | ||
| flowBuilder?.triggerTutorial() | ||
| }, 500) | ||
| } else if (!templatePath && !hubId && !state && !$importFlowStore) { | ||
| tick().then(() => { | ||
| flowBuilder?.triggerTutorial() | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tutorial Trigger Timing
The tutorial trigger logic has two paths:
- URL parameter: 500ms delay
- Fresh flow creation: Uses
tick()which is immediate
Potential Issue: The 500ms delay might not be sufficient if there are slow network requests or heavy rendering. Consider:
// Use a more robust approach
await tick()
// Wait for critical elements to be ready
let attempts = 0
while (attempts < 20 && !document.querySelector('#flow-editor-virtual-Input')) {
await new Promise(resolve => setTimeout(resolve, 100))
attempts++
}
flowBuilder?.triggerTutorial()This ensures the tutorial only starts when the page is actually ready.
| import { BookOpen, Users, Workflow } from 'lucide-svelte' | ||
| import { base } from '$lib/base' | ||
| let tab: 'quickstart' | 'team' = $state('quickstart') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good: Proper Svelte 5 Usage
This file correctly uses Svelte 5 syntax with $state() and the onclick event handler (not on:click). This is a good example of following the project's Svelte 5 best practices.
| let tab: 'quickstart' | 'team' = $state('quickstart') | ||
| function startFlowTutorial() { | ||
| window.location.href = `${base}/flows/add?tutorial=workspace-onboarding-continue&nodraft=true` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider: Direct navigation might skip cleanup
Using window.location.href forces a full page reload. While this ensures a fresh state, consider if goto() from $app/navigation would be more appropriate to maintain SPA behavior and avoid unnecessary full page reloads.
However, if the full reload is intentional to ensure localStorage is cleared and a completely fresh tutorial start, this is fine.
No description provided.