From 1b522cffbeebedd1f605a8acdceb37315bed5826 Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 19 Nov 2025 11:12:45 +0100 Subject: [PATCH 001/137] Start workspace onboarding --- .../lib/components/WorkspaceTutorials.svelte | 24 +++++++ .../scripts/CreateActionsScript.svelte | 1 + .../WorkspaceOnboardingTutorial.svelte | 72 +++++++++++++++++++ .../src/routes/(root)/(logged)/+page.svelte | 27 +++++++ 4 files changed, 124 insertions(+) create mode 100644 frontend/src/lib/components/WorkspaceTutorials.svelte create mode 100644 frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte diff --git a/frontend/src/lib/components/WorkspaceTutorials.svelte b/frontend/src/lib/components/WorkspaceTutorials.svelte new file mode 100644 index 0000000000000..06df5378c0b1f --- /dev/null +++ b/frontend/src/lib/components/WorkspaceTutorials.svelte @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 7d42f4a72b7bb..d77ab9d7acf43 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -9,6 +9,7 @@
{/if}
@@ -320,3 +345,5 @@ {#if tab == 'workspace'} {/if} + + From 2a9e367154b5b771fd31c2cd47b104586494c9dd Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 19 Nov 2025 11:35:08 +0100 Subject: [PATCH 002/137] Add pictures to tutorial steps --- .../components/flows/CreateActionsFlow.svelte | 1 + .../WorkspaceOnboardingTutorial.svelte | 38 ++++++++++++++++-- frontend/static/app.png | Bin 0 -> 54866 bytes frontend/static/flow.png | Bin 0 -> 25884 bytes frontend/static/languages.png | Bin 0 -> 54285 bytes 5 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 frontend/static/app.png create mode 100644 frontend/static/flow.png create mode 100644 frontend/static/languages.png diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index c8d8f0c55ca00..128c15070ce07 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -25,6 +25,7 @@
+ +
+ {:else if tab === 'team'} {/if} From ca1d90f4ba255b4335088886c112ffdeac8616b4 Mon Sep 17 00:00:00 2001 From: tristantr Date: Thu, 20 Nov 2025 14:02:13 +0100 Subject: [PATCH 017/137] Add step to show data connector --- ...TutorialWorkspaceOnboardingContinue.svelte | 74 ++++++++++++++++++- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 58a3b40e33e3c..3cb6466a07ee6 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -1,7 +1,7 @@
@@ -31,7 +32,7 @@ {/if} {#if $$slots.default} -
+
{/if} diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index 8bf6633ad85f9..59c7e868b5a39 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -20,7 +20,8 @@ Globe2, Loader2, Code, - LayoutDashboard + LayoutDashboard, + GraduationCap } from 'lucide-svelte' import { hubBaseUrlStore } from '$lib/stores' import { base } from '$lib/base' @@ -249,24 +250,24 @@ Windmill instance, such as keeping resource types up to date. {/if} - -
- {#if !$userStore?.operator} - Create a - - {#if HOME_SHOW_CREATE_FLOW}{/if} - {#if HOME_SHOW_CREATE_APP}{/if} - - - {/if} -
+ + + {#if !$userStore?.operator} + Create a + + {#if HOME_SHOW_CREATE_FLOW}{/if} + {#if HOME_SHOW_CREATE_APP}{/if} + {/if} {#if !$userStore?.operator} From fc1b996da68783988d6bd7e33330ac938e96c91e Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Fri, 21 Nov 2025 10:53:37 +0100 Subject: [PATCH 031/137] guidelines nits --- .../src/routes/(root)/(logged)/guides/+page.svelte | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/guides/+page.svelte b/frontend/src/routes/(root)/(logged)/guides/+page.svelte index 9eee00b91d474..6669e68485ac9 100644 --- a/frontend/src/routes/(root)/(logged)/guides/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/guides/+page.svelte @@ -31,15 +31,13 @@
From 59eeeeeb0b18bd83e0611620d4ac23acacb2f477 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 10:04:06 +0100 Subject: [PATCH 032/137] Automate onNext() trigger on step 3 --- ...TutorialWorkspaceOnboardingContinue.svelte | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index e31f1026a6e8e..8209ecbaa926f 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -237,18 +237,17 @@ const fakeCursor = await createFakeCursor(null, bunSpan, 1.5) await wait(1000) fakeCursor.remove() - } - }, - popover: { - title: 'Select a language', - description: 'Now we need to create our first script. We\'ll use TypeScript for this example.', - side: 'top', - onNextClick: async () => { + + // Automatically trigger next step after cursor animation + await wait(500) + + // Restore overlay const overlay = document.querySelector('.driver-overlay') as HTMLElement if (overlay) { overlay.style.display = '' } + // Add module const moduleData = flowJson.value.modules[0] const module: FlowModule = { id: moduleData.id, @@ -266,6 +265,11 @@ driver.moveNext() } + }, + popover: { + title: 'Select a language', + description: 'Now we need to create our first script. We\'ll use TypeScript for this example.', + side: 'top' } }, { @@ -450,7 +454,7 @@ flowStore.val = { ...flowStore.val } } - await wait(300) + await wait(700) driver.moveNext() } From b948e583b13b2f3f3fa9ca6a94f54917636b97d1 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 10:08:38 +0100 Subject: [PATCH 033/137] Improve fakr cursor for Test this step button --- ...TutorialWorkspaceOnboardingContinue.svelte | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 8209ecbaa926f..9ec9f86261616 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -391,7 +391,11 @@ }) as HTMLElement if (testTabButton) { + // Animate cursor to Test this step tab + const fakeCursor1 = await createFakeCursor(null, testTabButton, 1.5) + await wait(300) testTabButton.click() + fakeCursor1.remove() } await wait(800) @@ -403,18 +407,17 @@ }) as HTMLElement if (testActionButton) { - const buttonRect = testActionButton.getBoundingClientRect() - const startElement = document.createElement('div') - startElement.style.position = 'fixed' - startElement.style.left = `${buttonRect.left + buttonRect.width / 2}px` - startElement.style.top = `${buttonRect.top - 100}px` - document.body.appendChild(startElement) - - const fakeCursor = await createFakeCursor(startElement, testActionButton, 1.5) - testActionButton.click() + // Animate cursor from Test this step to Run button + const testTabButton2 = buttons.find(btn => { + return btn.textContent?.includes('Test this step') && + btn.classList.contains('border-b-2') && + btn.classList.contains('cursor-pointer') + }) as HTMLElement + + const fakeCursor2 = await createFakeCursor(testTabButton2, testActionButton, 1.5) await wait(300) - fakeCursor.remove() - startElement.remove() + testActionButton.click() + fakeCursor2.remove() } }, popover: { From d2a8007f34c2adbc3a66821c76e181b918ea550a Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 10:23:24 +0100 Subject: [PATCH 034/137] Improve overlay transitions --- ...TutorialWorkspaceOnboardingContinue.svelte | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 9ec9f86261616..ec6c566cf91c9 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -217,15 +217,19 @@ { element: '#flow-editor-add-step-0', onHighlighted: async () => { - const overlay = document.querySelector('.driver-overlay') as HTMLElement - if (overlay) { - overlay.style.display = 'none' - } - await wait(500) + // 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(300) button.click() + fakeCursor1.remove() + } + + const overlay = document.querySelector('.driver-overlay') as HTMLElement + if (overlay) { + overlay.style.display = 'none' } await wait(800) @@ -234,19 +238,14 @@ const bunSpan = spans.find(span => span.textContent?.includes('TypeScript (Bun)')) as HTMLElement if (bunSpan) { - const fakeCursor = await createFakeCursor(null, bunSpan, 1.5) + // Animate cursor from add step button to TypeScript (Bun) span + const fakeCursor2 = await createFakeCursor(button, bunSpan, 1.5) await wait(1000) - fakeCursor.remove() + fakeCursor2.remove() // Automatically trigger next step after cursor animation await wait(500) - // Restore overlay - const overlay = document.querySelector('.driver-overlay') as HTMLElement - if (overlay) { - overlay.style.display = '' - } - // Add module const moduleData = flowJson.value.modules[0] const module: FlowModule = { @@ -263,6 +262,12 @@ await wait(700) + // Restore overlay + const overlay = document.querySelector('.driver-overlay') as HTMLElement + if (overlay) { + overlay.style.display = '' + } + driver.moveNext() } }, From da5fcfc40577bac7a277262e8bdbe4e8ef8326b3 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 10:41:36 +0100 Subject: [PATCH 035/137] Merge data connectors and test step steps --- ...TutorialWorkspaceOnboardingContinue.svelte | 101 +++++++++++------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index ec6c566cf91c9..b76ff9de2eac3 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -355,39 +355,61 @@ }, { 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(100) - clickButtonBySelector('#flow-editor-plug') + 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(100) + // Move to plug button + fakeCursor.style.left = `${plugRect.left + plugRect.width / 2}px` + fakeCursor.style.top = `${plugRect.top + plugRect.height / 2}px` + await wait(1500) + await wait(300) + clickButtonBySelector('#flow-editor-plug') + } await wait(800) + // Step 2: Move to and click flow_input.celsius const targetButton = document.querySelector('button[title="flow_input.celsius"]') as HTMLElement if (targetButton) { - const plugButton = document.querySelector('#flow-editor-plug') as HTMLElement - if (plugButton) { - const fakeCursor = await createFakeCursor(plugButton, targetButton, 2.5) - const clickEvent = new MouseEvent('click', { - bubbles: true, - cancelable: true, - view: window - }) - targetButton.dispatchEvent(clickEvent) - await wait(500) - fakeCursor.remove() - } - } - }, - popover: { - title: 'Connect input data to the script', - description: 'Now we need to connect the input data to our script. We use data connectors to pass data between our flow steps.', - onNextClick: async () => { - driver.moveNext() + const targetRect = targetButton.getBoundingClientRect() + fakeCursor.style.transition = 'all 2.5s ease-in-out' + fakeCursor.style.left = `${targetRect.left + targetRect.width / 2}px` + fakeCursor.style.top = `${targetRect.top + targetRect.height / 2}px` + await wait(2500) + await wait(300) + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window + }) + targetButton.dispatchEvent(clickEvent) } - } - }, - { - onHighlighted: async () => { + await wait(500) + + // Step 3: Move to and click Test this step tab const buttons = Array.from(document.querySelectorAll('button')) const testTabButton = buttons.find(btn => { return btn.textContent?.includes('Test this step') && @@ -396,15 +418,18 @@ }) as HTMLElement if (testTabButton) { - // Animate cursor to Test this step tab - const fakeCursor1 = await createFakeCursor(null, testTabButton, 1.5) + const tabRect = testTabButton.getBoundingClientRect() + fakeCursor.style.transition = 'all 1.5s ease-in-out' + fakeCursor.style.left = `${tabRect.left + tabRect.width / 2}px` + fakeCursor.style.top = `${tabRect.top + tabRect.height / 2}px` + await wait(1500) await wait(300) testTabButton.click() - fakeCursor1.remove() } await wait(800) + // Step 4: Move to and click Run button const testActionButton = Array.from(document.querySelectorAll('button')).find(btn => { return btn.textContent?.includes('Run') && btn.classList.contains('bg-surface-accent-primary') && @@ -412,22 +437,22 @@ }) as HTMLElement if (testActionButton) { - // Animate cursor from Test this step to Run button - const testTabButton2 = buttons.find(btn => { - return btn.textContent?.includes('Test this step') && - btn.classList.contains('border-b-2') && - btn.classList.contains('cursor-pointer') - }) as HTMLElement - - const fakeCursor2 = await createFakeCursor(testTabButton2, testActionButton, 1.5) + const runRect = testActionButton.getBoundingClientRect() + fakeCursor.style.transition = 'all 1.5s ease-in-out' + fakeCursor.style.left = `${runRect.left + runRect.width / 2}px` + fakeCursor.style.top = `${runRect.top + runRect.height / 2}px` + await wait(1500) await wait(300) testActionButton.click() - fakeCursor2.remove() + await wait(300) } + + // Remove cursor at the end + fakeCursor.remove() }, popover: { - title: 'Test the script', - description: 'We test the script to ensure the validation logic is working correctly. Once validated, we to complete our flow with scripts b and c.', + title: 'Connect and test', + description: 'We connect the input data to our script and test it to ensure the validation logic is working correctly. Once validated, we complete our flow with scripts b and c.', onNextClick: async () => { // Clean up custom overlay immediately const customOverlay = document.querySelector('.tutorial-custom-overlay') From ccf5098fbd52dbe104f5f3c6f08b5b1e13c61cfe Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 11:00:24 +0100 Subject: [PATCH 036/137] Improve live code writing in step 3 --- ...TutorialWorkspaceOnboardingContinue.svelte | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index b76ff9de2eac3..8072e0b0cf2ee 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -246,12 +246,15 @@ // Automatically trigger next step after cursor animation await wait(500) - // Add module + // Add module with empty summary and empty content const moduleData = flowJson.value.modules[0] const module: FlowModule = { id: moduleData.id, - summary: moduleData.summary, - value: moduleData.value + summary: '', // Start with empty summary + value: { + ...moduleData.value, + content: '' // Start with empty content + } } const state = await loadFlowModuleState(module) @@ -290,6 +293,31 @@ overlay.style.left = '0' } + // First, type the summary + await wait(300) + const summaryInput = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement + if (summaryInput) { + const summaryText = 'Validate temperature input' + summaryInput.value = '' + summaryInput.focus() + + for (let i = 0; i < summaryText.length; i++) { + summaryInput.value += summaryText[i] + summaryInput.dispatchEvent(new Event('input', { bubbles: true })) + await wait(50) + } + + // Update the flow store with the summary + const moduleIndex = flowStore.val.value.modules.findIndex(m => m.id === 'a') + if (moduleIndex !== -1) { + flowStore.val.value.modules[moduleIndex].summary = summaryText + flowStore.val = { ...flowStore.val } + } + + await wait(500) + } + + // Then, type the code let editorState = get(currentEditor) let attempts = 0 while (attempts < 20) { @@ -318,6 +346,26 @@ const delay = char === '\n' ? 5 : 2 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(300) + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + bubbles: true + }) + editor.getModel()?.setValue(currentText + '\n') } } }, From f57f01c1bc51914ac250557365bf360f452f9fae Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 11:15:18 +0100 Subject: [PATCH 037/137] Add a step to complete the flow --- ...TutorialWorkspaceOnboardingContinue.svelte | 127 ++++++++++++++++-- 1 file changed, 117 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 8072e0b0cf2ee..61181e5576d3b 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -508,15 +508,7 @@ customOverlay.remove() } - // Reset the driver.js overlay to full screen - const driverOverlay = document.querySelector('.driver-overlay') as HTMLElement - if (driverOverlay) { - driverOverlay.style.display = '' - driverOverlay.style.width = '' - driverOverlay.style.right = '' - driverOverlay.style.left = '' - } - + // 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)) @@ -524,7 +516,7 @@ const moduleData = modulesToAdd[i] const module: FlowModule = { id: moduleData.id, - summary: moduleData.summary, + summary: '', // Start with empty summary value: moduleData.value } @@ -541,6 +533,121 @@ } } }, + { + onHighlighted: async () => { + // 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(300) + 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(100) + // Move to script b + fakeCursor.style.left = `${bRect.left + bRect.width / 2}px` + fakeCursor.style.top = `${bRect.top + bRect.height / 2}px` + await wait(1500) + await wait(300) + selectionManager.selectId('b') + } + + await wait(500) + + // Type summary for script 'b' + const summaryInputB = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement + if (summaryInputB) { + const summaryTextB = 'Convert to Fahrenheit' + summaryInputB.value = '' + summaryInputB.focus() + + for (let i = 0; i < summaryTextB.length; i++) { + summaryInputB.value += summaryTextB[i] + summaryInputB.dispatchEvent(new Event('input', { bubbles: true })) + await wait(50) + } + + // Update the flow store with the summary + const moduleIndexB = flowStore.val.value.modules.findIndex(m => m.id === 'b') + if (moduleIndexB !== -1) { + flowStore.val.value.modules[moduleIndexB].summary = summaryTextB + flowStore.val = { ...flowStore.val } + } + + await wait(500) + } + + // Step 2: Move to and click on script 'c' + const scriptC = document.querySelector('#c') as HTMLElement + if (scriptC) { + const cRect = scriptC.getBoundingClientRect() + fakeCursor.style.transition = 'all 1.5s ease-in-out' + fakeCursor.style.left = `${cRect.left + cRect.width / 2}px` + fakeCursor.style.top = `${cRect.top + cRect.height / 2}px` + await wait(1500) + await wait(300) + selectionManager.selectId('c') + } + + await wait(500) + + // Type summary for script 'c' + const summaryInputC = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement + if (summaryInputC) { + const summaryTextC = 'Categorize temperature' + summaryInputC.value = '' + summaryInputC.focus() + + for (let i = 0; i < summaryTextC.length; i++) { + summaryInputC.value += summaryTextC[i] + summaryInputC.dispatchEvent(new Event('input', { bubbles: true })) + await wait(50) + } + + // Update the flow store with the summary + const moduleIndexC = flowStore.val.value.modules.findIndex(m => m.id === 'c') + if (moduleIndexC !== -1) { + flowStore.val.value.modules[moduleIndexC].summary = summaryTextC + flowStore.val = { ...flowStore.val } + } + + await wait(500) + } + + // Remove cursor at the end + fakeCursor.remove() + }, + popover: { + title: 'Complete the flow', + description: 'Now we add summaries to our remaining scripts to complete the flow.', + onNextClick: () => { + // Reset the driver.js overlay to full screen + const driverOverlay = document.querySelector('.driver-overlay') as HTMLElement + if (driverOverlay) { + driverOverlay.style.display = '' + driverOverlay.style.width = '' + driverOverlay.style.right = '' + driverOverlay.style.left = '' + } + driver.moveNext() + } + } + }, { element: '#flow-editor-test-flow', popover: { From e66552aa1f9bc690f3b88f277a2fbc6813ac815f Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 11:19:28 +0100 Subject: [PATCH 038/137] Improve the step where we generate remaining scripts --- ...TutorialWorkspaceOnboardingContinue.svelte | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 61181e5576d3b..e66d157eed8cb 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -508,33 +508,33 @@ customOverlay.remove() } - // 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 - } - - const state = await loadFlowModuleState(module) - flowStateStore.val[module.id] = state - - flowStore.val.value.modules.push(module) - flowStore.val = { ...flowStore.val } - } - - await wait(700) - driver.moveNext() } } }, { onHighlighted: async () => { + // 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 + } + + const state = await loadFlowModuleState(module) + flowStateStore.val[module.id] = state + + flowStore.val.value.modules.push(module) + flowStore.val = { ...flowStore.val } + } + + await wait(700) + // Create a single cursor for continuous movement const fakeCursor = document.createElement('div') fakeCursor.style.cssText = ` From 8d54ab511c425da5d529d83eba29bbf5680db9a9 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 11:34:58 +0100 Subject: [PATCH 039/137] Refactor --- ...TutorialWorkspaceOnboardingContinue.svelte | 268 +++++++++--------- 1 file changed, 132 insertions(+), 136 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index e66d157eed8cb..00b7fb360d864 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -13,6 +13,81 @@ let tutorial: Tutorial | undefined = undefined + // Constants for delays + const DELAY_SHORT = 100 + const DELAY_MEDIUM = 300 + const DELAY_LONG = 500 + const DELAY_VERY_LONG = 800 + 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 { + 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 { + 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 { + 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, @@ -177,13 +252,13 @@ { element: '#flow-editor-virtual-Input', onHighlighted: async () => { - await wait(300) + await wait(DELAY_MEDIUM) triggerPointerDown('#flow-editor-virtual-Input') - await wait(100) + await wait(DELAY_SHORT) selectionManager.selectId('Input') await wait(200) - const overlay = document.querySelector('.driver-overlay') as HTMLElement + const overlay = getDriverOverlay() if (overlay) { overlay.style.width = '50%' overlay.style.right = 'auto' @@ -194,7 +269,7 @@ if (celsiusInput) { celsiusInput.value = '' celsiusInput.dispatchEvent(new Event('input', { bubbles: true })) - await wait(300) + await wait(DELAY_MEDIUM) celsiusInput.value = '2' celsiusInput.dispatchEvent(new Event('input', { bubbles: true })) @@ -222,17 +297,17 @@ const button = document.querySelector('#flow-editor-add-step-0') as HTMLElement if (button) { const fakeCursor1 = await createFakeCursor(null, button, 1.5) - await wait(300) + await wait(DELAY_MEDIUM) button.click() fakeCursor1.remove() } - const overlay = document.querySelector('.driver-overlay') as HTMLElement + const overlay = getDriverOverlay() if (overlay) { overlay.style.display = 'none' } - await wait(800) + await wait(DELAY_VERY_LONG) const spans = Array.from(document.querySelectorAll('span')) const bunSpan = spans.find(span => span.textContent?.includes('TypeScript (Bun)')) as HTMLElement @@ -244,29 +319,26 @@ fakeCursor2.remove() // Automatically trigger next step after cursor animation - await wait(500) + await wait(DELAY_LONG) // 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, - content: '' // Start with empty content - } + 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 } - const state = await loadFlowModuleState(module) - flowStateStore.val[module.id] = state - - flowStore.val.value.modules.push(module) - flowStore.val = { ...flowStore.val } + await addModuleToFlow(module) await wait(700) // Restore overlay - const overlay = document.querySelector('.driver-overlay') as HTMLElement + const overlay = getDriverOverlay() if (overlay) { overlay.style.display = '' } @@ -284,9 +356,9 @@ element: '#a', onHighlighted: async () => { selectionManager.selectId('a') - await wait(500) + await wait(DELAY_LONG) - const overlay = document.querySelector('.driver-overlay') as HTMLElement + const overlay = getDriverOverlay() if (overlay) { overlay.style.width = '50%' overlay.style.right = 'auto' @@ -294,27 +366,13 @@ } // First, type the summary - await wait(300) + await wait(DELAY_MEDIUM) const summaryInput = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement if (summaryInput) { const summaryText = 'Validate temperature input' - summaryInput.value = '' - summaryInput.focus() - - for (let i = 0; i < summaryText.length; i++) { - summaryInput.value += summaryText[i] - summaryInput.dispatchEvent(new Event('input', { bubbles: true })) - await wait(50) - } - - // Update the flow store with the summary - const moduleIndex = flowStore.val.value.modules.findIndex(m => m.id === 'a') - if (moduleIndex !== -1) { - flowStore.val.value.modules[moduleIndex].summary = summaryText - flowStore.val = { ...flowStore.val } - } - - await wait(500) + await typeText(summaryInput, summaryText) + updateModuleSummary('a', summaryText) + await wait(DELAY_LONG) } // Then, type the code @@ -343,7 +401,7 @@ const char = codeToType[i] currentText += char editor.setCode(currentText, true) - const delay = char === '\n' ? 5 : 2 + const delay = char === '\n' ? DELAY_CODE_NEWLINE : DELAY_CODE_CHAR await wait(delay) } @@ -358,7 +416,7 @@ } // Press Enter after finishing typing - await wait(300) + await wait(DELAY_MEDIUM) const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', @@ -375,7 +433,7 @@ "Then, we write the code for this script. Its purpose is to collect the temperature input and determine if it is a valid value.", side: 'bottom', onNextClick: () => { - const driverOverlay = document.querySelector('.driver-overlay') as HTMLElement + const driverOverlay = getDriverOverlay() if (driverOverlay) { driverOverlay.style.display = 'none' } @@ -420,33 +478,29 @@ // Step 1: Move to and click plug button document.querySelector('#flow-editor-plug')?.parentElement?.classList.remove('opacity-0') - await wait(100) + 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(100) + 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(1500) - await wait(300) + await wait(DELAY_ANIMATION) + await wait(DELAY_MEDIUM) clickButtonBySelector('#flow-editor-plug') } - await wait(800) + 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) { - const targetRect = targetButton.getBoundingClientRect() - fakeCursor.style.transition = 'all 2.5s ease-in-out' - fakeCursor.style.left = `${targetRect.left + targetRect.width / 2}px` - fakeCursor.style.top = `${targetRect.top + targetRect.height / 2}px` - await wait(2500) - await wait(300) + await moveCursorToElement(fakeCursor, targetButton, DELAY_ANIMATION_LONG) + await wait(DELAY_MEDIUM) const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true, @@ -455,44 +509,27 @@ targetButton.dispatchEvent(clickEvent) } - await wait(500) + await wait(DELAY_LONG) // Step 3: Move to and click Test this step tab - const buttons = Array.from(document.querySelectorAll('button')) - const testTabButton = buttons.find(btn => { - return btn.textContent?.includes('Test this step') && - btn.classList.contains('border-b-2') && - btn.classList.contains('cursor-pointer') - }) as HTMLElement + const testTabButton = findButtonByText('Test this step', ['border-b-2', 'cursor-pointer']) if (testTabButton) { - const tabRect = testTabButton.getBoundingClientRect() - fakeCursor.style.transition = 'all 1.5s ease-in-out' - fakeCursor.style.left = `${tabRect.left + tabRect.width / 2}px` - fakeCursor.style.top = `${tabRect.top + tabRect.height / 2}px` - await wait(1500) - await wait(300) + await moveCursorToElement(fakeCursor, testTabButton, DELAY_ANIMATION) + await wait(DELAY_MEDIUM) testTabButton.click() } - await wait(800) + await wait(DELAY_VERY_LONG) // Step 4: Move to and click Run button - const testActionButton = Array.from(document.querySelectorAll('button')).find(btn => { - return btn.textContent?.includes('Run') && - btn.classList.contains('bg-surface-accent-primary') && - btn.classList.contains('w-full') - }) as HTMLElement + const testActionButton = findButtonByText('Run', ['bg-surface-accent-primary', 'w-full']) if (testActionButton) { - const runRect = testActionButton.getBoundingClientRect() - fakeCursor.style.transition = 'all 1.5s ease-in-out' - fakeCursor.style.left = `${runRect.left + runRect.width / 2}px` - fakeCursor.style.top = `${runRect.top + runRect.height / 2}px` - await wait(1500) - await wait(300) + await moveCursorToElement(fakeCursor, testActionButton, DELAY_ANIMATION) + await wait(DELAY_MEDIUM) testActionButton.click() - await wait(300) + await wait(DELAY_MEDIUM) } // Remove cursor at the end @@ -502,12 +539,7 @@ title: 'Connect and test', description: 'We connect the input data to our script and test it to ensure the validation logic is working correctly. Once validated, we complete our flow with scripts b and c.', onNextClick: async () => { - // Clean up custom overlay immediately - const customOverlay = document.querySelector('.tutorial-custom-overlay') - if (customOverlay) { - customOverlay.remove() - } - + cleanupCustomOverlay() driver.moveNext() } } @@ -526,11 +558,7 @@ value: moduleData.value } - const state = await loadFlowModuleState(module) - flowStateStore.val[module.id] = state - - flowStore.val.value.modules.push(module) - flowStore.val = { ...flowStore.val } + await addModuleToFlow(module) } await wait(700) @@ -551,82 +579,50 @@ document.body.appendChild(fakeCursor) // Step 1: Click on script 'b' - await wait(300) + 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(100) + 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(1500) - await wait(300) + await wait(DELAY_ANIMATION) + await wait(DELAY_MEDIUM) selectionManager.selectId('b') } - await wait(500) + 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' - summaryInputB.value = '' - summaryInputB.focus() - - for (let i = 0; i < summaryTextB.length; i++) { - summaryInputB.value += summaryTextB[i] - summaryInputB.dispatchEvent(new Event('input', { bubbles: true })) - await wait(50) - } - - // Update the flow store with the summary - const moduleIndexB = flowStore.val.value.modules.findIndex(m => m.id === 'b') - if (moduleIndexB !== -1) { - flowStore.val.value.modules[moduleIndexB].summary = summaryTextB - flowStore.val = { ...flowStore.val } - } - - await wait(500) + 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) { - const cRect = scriptC.getBoundingClientRect() - fakeCursor.style.transition = 'all 1.5s ease-in-out' - fakeCursor.style.left = `${cRect.left + cRect.width / 2}px` - fakeCursor.style.top = `${cRect.top + cRect.height / 2}px` - await wait(1500) - await wait(300) + await moveCursorToElement(fakeCursor, scriptC, DELAY_ANIMATION) + await wait(DELAY_MEDIUM) selectionManager.selectId('c') } - await wait(500) + await wait(DELAY_LONG) // Type summary for script 'c' const summaryInputC = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement if (summaryInputC) { const summaryTextC = 'Categorize temperature' - summaryInputC.value = '' - summaryInputC.focus() - - for (let i = 0; i < summaryTextC.length; i++) { - summaryInputC.value += summaryTextC[i] - summaryInputC.dispatchEvent(new Event('input', { bubbles: true })) - await wait(50) - } - - // Update the flow store with the summary - const moduleIndexC = flowStore.val.value.modules.findIndex(m => m.id === 'c') - if (moduleIndexC !== -1) { - flowStore.val.value.modules[moduleIndexC].summary = summaryTextC - flowStore.val = { ...flowStore.val } - } - - await wait(500) + await typeText(summaryInputC, summaryTextC) + updateModuleSummary('c', summaryTextC) + await wait(DELAY_LONG) } // Remove cursor at the end @@ -637,7 +633,7 @@ description: 'Now we add summaries to our remaining scripts to complete the flow.', onNextClick: () => { // Reset the driver.js overlay to full screen - const driverOverlay = document.querySelector('.driver-overlay') as HTMLElement + const driverOverlay = getDriverOverlay() if (driverOverlay) { driverOverlay.style.display = '' driverOverlay.style.width = '' From e950eedb18461c6629f620c3fa065974de828a2a Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 12:09:06 +0100 Subject: [PATCH 040/137] Add blocking behavior on step 3 --- ...ilderTutorialWorkspaceOnboardingContinue.svelte | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 00b7fb360d864..1f63f6cb3599e 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -13,6 +13,9 @@ let tutorial: Tutorial | undefined = undefined + // Flag to track if step 4 code writing is complete + let step4Complete = $state(false) + // Constants for delays const DELAY_SHORT = 100 const DELAY_MEDIUM = 300 @@ -355,6 +358,9 @@ { element: '#a', onHighlighted: async () => { + // Reset the flag when step starts + step4Complete = false + selectionManager.selectId('a') await wait(DELAY_LONG) @@ -424,6 +430,9 @@ bubbles: true }) editor.getModel()?.setValue(currentText + '\n') + + // Mark step 4 as complete + step4Complete = true } } }, @@ -433,6 +442,11 @@ "Then, we write the code for this script. Its purpose is to collect the temperature input and determine if it is a valid value.", side: 'bottom', onNextClick: () => { + // Only proceed if code writing is complete + if (!step4Complete) { + return + } + const driverOverlay = getDriverOverlay() if (driverOverlay) { driverOverlay.style.display = 'none' From 59e80b720d7a99e813bca7688c4bb141b7412311 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 12:13:37 +0100 Subject: [PATCH 041/137] nit about delay --- .../FlowBuilderTutorialWorkspaceOnboardingContinue.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 1f63f6cb3599e..a9205055e8d18 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -310,7 +310,7 @@ overlay.style.display = 'none' } - await wait(DELAY_VERY_LONG) + await wait(DELAY_LONG) const spans = Array.from(document.querySelectorAll('span')) const bunSpan = spans.find(span => span.textContent?.includes('TypeScript (Bun)')) as HTMLElement @@ -322,7 +322,7 @@ fakeCursor2.remove() // Automatically trigger next step after cursor animation - await wait(DELAY_LONG) + await wait(DELAY_SHORT) // Add module with empty summary and empty content const moduleData = flowJson.value.modules[0] From f774fa69f26f7f75d0b7fdd35bf6e1db83af4529 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 12:37:15 +0100 Subject: [PATCH 042/137] Prevent clicking on Next while code not generated --- .../FlowBuilderTutorialWorkspaceOnboardingContinue.svelte | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index a9205055e8d18..b21de8b849ef7 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -13,7 +13,7 @@ let tutorial: Tutorial | undefined = undefined - // Flag to track if step 4 code writing is complete + // Flags to track if steps are complete let step4Complete = $state(false) // Constants for delays @@ -423,12 +423,6 @@ // Press Enter after finishing typing await wait(DELAY_MEDIUM) - const enterEvent = new KeyboardEvent('keydown', { - key: 'Enter', - code: 'Enter', - keyCode: 13, - bubbles: true - }) editor.getModel()?.setValue(currentText + '\n') // Mark step 4 as complete From b46783e2403e1d2c8f498eb8a0f4fa9ad67b45f6 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 14:08:37 +0100 Subject: [PATCH 043/137] Sharpen wordings --- ...TutorialWorkspaceOnboardingContinue.svelte | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index b21de8b849ef7..acde005519d78 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -231,9 +231,9 @@ const steps: DriveStep[] = [ { popover: { - title: 'Welcome to this flow tutorial', + title: 'Build your first flow', description: - "In this tutorial, we will create a simple flow that validates a temperature in Celsius and converts it to Fahrenheit.", + "Let's create a temperature converter that validates input and converts Celsius to Fahrenheit.", onNextClick: async () => { const emptyFlow: Flow = { summary: '', @@ -283,8 +283,8 @@ } }, popover: { - title: 'Define the User Input', - description: 'First, define the data the flow will receive. For this example, we\'ll configure the input to accept a temperature in Celsius.', + title: 'Set the input', + description: 'Every flow starts with input. Here we define a temperature in Celsius.', side: 'bottom', align: 'start', onNextClick: () => { @@ -350,8 +350,8 @@ } }, popover: { - title: 'Select a language', - description: 'Now we need to create our first script. We\'ll use TypeScript for this example.', + title: 'Choose TypeScript', + description: 'Pick TypeScript (Bun) to write our validation script.', side: 'top' } }, @@ -431,9 +431,9 @@ } }, popover: { - title: 'Write our script', + title: 'Add validation logic', description: - "Then, we write the code for this script. Its purpose is to collect the temperature input and determine if it is a valid value.", + "Watch as we write code to validate the temperature input.", side: 'bottom', onNextClick: () => { // Only proceed if code writing is complete @@ -544,8 +544,8 @@ fakeCursor.remove() }, popover: { - title: 'Connect and test', - description: 'We connect the input data to our script and test it to ensure the validation logic is working correctly. Once validated, we complete our flow with scripts b and c.', + title: 'Wire it up and test', + description: 'Connect the input, then run a quick test to verify the validation works.', onNextClick: async () => { cleanupCustomOverlay() driver.moveNext() @@ -637,8 +637,8 @@ fakeCursor.remove() }, popover: { - title: 'Complete the flow', - description: 'Now we add summaries to our remaining scripts to complete the flow.', + title: 'Add the final steps', + description: 'Two more scripts to convert and categorize the temperature.', onNextClick: () => { // Reset the driver.js overlay to full screen const driverOverlay = getDriverOverlay() @@ -655,8 +655,8 @@ { element: '#flow-editor-test-flow', popover: { - title: 'The flow is now complete!', - description: 'Click the Test Flow button to execute the entire process and view the final results.', + title: 'Ready to test!', + description: 'Run the complete flow and see your temperature converter in action.', onNextClick: () => { driver.moveNext() } From 43714a8df03eee477caf3eb53f6a8ea7a8e13104 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 14:25:55 +0100 Subject: [PATCH 044/137] Remove Svelte 4 and migrate to Svelte 5 --- .../workspace/WorkspaceOnboardingTutorial.svelte | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte index 6a6da6e4e57ee..ed66da8e0b5c9 100644 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte @@ -4,10 +4,9 @@ import type { DriveStep } from 'driver.js' import { base } from '$lib/base' - export let name: string = 'workspace-onboarding' - export let index: number = 8 + let { name = 'workspace-onboarding', index = 8, onerror, onskipAll } = $props() - let tutorial: Tutorial | undefined = undefined + let tutorial: Tutorial | undefined = $state(undefined) function hideOverlay() { const overlay = document.querySelector('.driver-overlay') as HTMLElement @@ -25,8 +24,8 @@ bind:this={tutorial} {index} {name} - on:error - on:skipAll + {onerror} + onskipAll={onskipAll} tainted={false} getSteps={(driver) => { // Helper function to find the create script button From b131531d4219d551763ce7f070c4097065ca931a Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 14:29:34 +0100 Subject: [PATCH 045/137] Remove unecesary helper function --- .../WorkspaceOnboardingTutorial.svelte | 39 ++----------------- 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte index ed66da8e0b5c9..f5a5053612025 100644 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte @@ -28,39 +28,6 @@ onskipAll={onskipAll} tainted={false} getSteps={(driver) => { - // 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: { @@ -70,7 +37,7 @@ onNextClick: () => { // Wait a bit to ensure the page is fully rendered before moving to next step setTimeout(() => { - const button = findScriptButton() + const button = document.querySelector('#create-script-button') as HTMLElement | null if (button) { driver.moveNext() } else { @@ -88,7 +55,7 @@ onNextClick: async () => { // Move to the next step (Create Flow button) setTimeout(() => { - const button = findFlowButton() + const button = document.querySelector('#create-flow-button') as HTMLElement | null if (button) { driver.moveNext() } else { @@ -107,7 +74,7 @@ onNextClick: async () => { // Move to the next step (Create App button) setTimeout(() => { - const button = findAppButton() + const button = document.querySelector('#create-app-button') as HTMLElement | null if (button) { driver.moveNext() } else { From 6da825a5969a93b7f52f256fff7a95df4dace0e7 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 14:36:17 +0100 Subject: [PATCH 046/137] Add toast if the user clicks on Next button before code finished generating --- .../FlowBuilderTutorialWorkspaceOnboardingContinue.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index acde005519d78..1ea885369a1b6 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -9,6 +9,7 @@ import { loadFlowModuleState } from '../flows/flowStateUtils.svelte' import { wait, type StateStore } from '$lib/utils' import { get } from 'svelte/store' + import { sendUserToast } from '$lib/toast' const { flowStore, flowStateStore, selectionManager, currentEditor } = getContext('FlowEditorContext') let tutorial: Tutorial | undefined = undefined @@ -438,6 +439,7 @@ onNextClick: () => { // Only proceed if code writing is complete if (!step4Complete) { + sendUserToast('Please wait for the code to finish typing...', false, [], undefined, 3000) return } From 920e0c71490a96cce3e433537c1db627bce9a701 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 14:54:19 +0100 Subject: [PATCH 047/137] Add toasts to each step --- ...TutorialWorkspaceOnboardingContinue.svelte | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 1ea885369a1b6..c2603518ceb6a 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -15,7 +15,11 @@ 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 @@ -256,6 +260,8 @@ { element: '#flow-editor-virtual-Input', onHighlighted: async () => { + step2Complete = false + await wait(DELAY_MEDIUM) triggerPointerDown('#flow-editor-virtual-Input') await wait(DELAY_SHORT) @@ -281,6 +287,8 @@ celsiusInput.value = '25' celsiusInput.dispatchEvent(new Event('input', { bubbles: true })) + + step2Complete = true } }, popover: { @@ -289,6 +297,10 @@ side: 'bottom', align: 'start', onNextClick: () => { + if (!step2Complete) { + sendUserToast('Please wait for the input to be filled...', false, [], undefined, 3000) + return + } driver.moveNext() } } @@ -296,6 +308,7 @@ { 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 @@ -347,13 +360,21 @@ overlay.style.display = '' } + step3Complete = true driver.moveNext() } }, popover: { title: 'Choose TypeScript', description: 'Pick TypeScript (Bun) to write our validation script.', - side: 'top' + side: 'top', + onNextClick: () => { + if (!step3Complete) { + sendUserToast('Please wait for the script to be created...', false, [], undefined, 3000) + return + } + driver.moveNext() + } } }, { @@ -471,6 +492,8 @@ }, { onHighlighted: async () => { + step5Complete = false + // Create a single cursor that will move continuously const fakeCursor = document.createElement('div') fakeCursor.style.cssText = ` @@ -544,11 +567,17 @@ // 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() } @@ -556,6 +585,8 @@ }, { 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++) { @@ -637,11 +668,18 @@ // 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) { From c388425153dcd16eb40ace2da80215bcb81fd803 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 14:58:15 +0100 Subject: [PATCH 048/137] Improve tutorial trigger timing --- .../(root)/(logged)/flows/add/+page.svelte | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index a6e6580d593a4..2fca0641de835 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -152,14 +152,23 @@ // 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) + // 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() } else if (!templatePath && !hubId && !state && !$importFlowStore) { - tick().then(() => { - flowBuilder?.triggerTutorial() - }) + // 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() } } From b7fe4db7def64d7302b3eecd8ff37a990a447f79 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 15:06:28 +0100 Subject: [PATCH 049/137] Improve delays --- ...BuilderTutorialWorkspaceOnboardingContinue.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index c2603518ceb6a..a3558c2ecd626 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -314,7 +314,7 @@ const button = document.querySelector('#flow-editor-add-step-0') as HTMLElement if (button) { const fakeCursor1 = await createFakeCursor(null, button, 1.5) - await wait(DELAY_MEDIUM) + await wait(DELAY_SHORT) button.click() fakeCursor1.remove() } @@ -332,7 +332,7 @@ if (bunSpan) { // Animate cursor from add step button to TypeScript (Bun) span const fakeCursor2 = await createFakeCursor(button, bunSpan, 1.5) - await wait(1000) + await wait(DELAY_MEDIUM) fakeCursor2.remove() // Automatically trigger next step after cursor animation @@ -527,7 +527,7 @@ clickButtonBySelector('#flow-editor-plug') } - await wait(DELAY_VERY_LONG) + await wait(DELAY_MEDIUM) // Step 2: Move to and click flow_input.celsius const targetButton = document.querySelector('button[title="flow_input.celsius"]') as HTMLElement @@ -549,11 +549,11 @@ if (testTabButton) { await moveCursorToElement(fakeCursor, testTabButton, DELAY_ANIMATION) - await wait(DELAY_MEDIUM) + await wait(DELAY_SHORT) testTabButton.click() } - await wait(DELAY_VERY_LONG) + await wait(DELAY_LONG) // Step 4: Move to and click Run button const testActionButton = findButtonByText('Run', ['bg-surface-accent-primary', 'w-full']) @@ -651,7 +651,7 @@ const scriptC = document.querySelector('#c') as HTMLElement if (scriptC) { await moveCursorToElement(fakeCursor, scriptC, DELAY_ANIMATION) - await wait(DELAY_MEDIUM) + await wait(DELAY_SHORT) selectionManager.selectId('c') } From 6818d327210081b11e928e074c488175e197dd10 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 15:10:47 +0100 Subject: [PATCH 050/137] Add cursor movement to Test Flow button --- .../FlowBuilderTutorialWorkspaceOnboardingContinue.svelte | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index a3558c2ecd626..56bd5434922bb 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -666,6 +666,13 @@ 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() From d13c0ee9247ae7278d424866b9a5316e6be377f2 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 15:31:47 +0100 Subject: [PATCH 051/137] Block previous on certain steps to prevent bug --- ...derTutorialWorkspaceOnboardingContinue.svelte | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 56bd5434922bb..567143ba322fa 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -25,7 +25,6 @@ const DELAY_SHORT = 100 const DELAY_MEDIUM = 300 const DELAY_LONG = 500 - const DELAY_VERY_LONG = 800 const DELAY_ANIMATION = 1500 const DELAY_ANIMATION_LONG = 2500 const DELAY_TYPING = 50 @@ -374,6 +373,9 @@ return } driver.moveNext() + }, + onPrevClick: () => { + sendUserToast('Previous is not available for this step', true, [], undefined, 3000) } } }, @@ -487,6 +489,9 @@ document.body.appendChild(customOverlay) driver.moveNext() + }, + onPrevClick: () => { + sendUserToast('Previous is not available for this step', true, [], undefined, 3000) } } }, @@ -580,6 +585,9 @@ } cleanupCustomOverlay() driver.moveNext() + }, + onPrevClick: () => { + sendUserToast('Previous is not available for this step', true, [], undefined, 3000) } } }, @@ -696,6 +704,9 @@ driverOverlay.style.left = '' } driver.moveNext() + }, + onPrevClick: () => { + sendUserToast('Previous is not available for this step', true, [], undefined, 3000) } } }, @@ -706,6 +717,9 @@ description: 'Run the complete flow and see your temperature converter in action.', onNextClick: () => { driver.moveNext() + }, + onPrevClick: () => { + sendUserToast('Previous is not available for this step', true, [], undefined, 3000) } } }, From 8fb52fe23a09e641cc10b22b5533e5beb581a686 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 15:42:05 +0100 Subject: [PATCH 052/137] Fix for github npm check --- frontend/src/lib/components/WorkspaceTutorials.svelte | 4 ---- .../FlowBuilderTutorialWorkspaceOnboardingContinue.svelte | 5 ++++- .../workspace/WorkspaceOnboardingTutorial.svelte | 8 ++------ frontend/src/routes/(root)/(logged)/guides/+page.svelte | 2 +- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/WorkspaceTutorials.svelte b/frontend/src/lib/components/WorkspaceTutorials.svelte index 06df5378c0b1f..7c3e96cb543b0 100644 --- a/frontend/src/lib/components/WorkspaceTutorials.svelte +++ b/frontend/src/lib/components/WorkspaceTutorials.svelte @@ -17,8 +17,4 @@ diff --git a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte index 567143ba322fa..612776e36280d 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte @@ -447,7 +447,10 @@ // Press Enter after finishing typing await wait(DELAY_MEDIUM) - editor.getModel()?.setValue(currentText + '\n') + const model = editor.getModel() + if (model && 'setValue' in model) { + model.setValue(currentText + '\n') + } // Mark step 4 as complete step4Complete = true diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte index f5a5053612025..c711c88c3fde6 100644 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte @@ -4,8 +4,6 @@ import type { DriveStep } from 'driver.js' import { base } from '$lib/base' - let { name = 'workspace-onboarding', index = 8, onerror, onskipAll } = $props() - let tutorial: Tutorial | undefined = $state(undefined) function hideOverlay() { @@ -22,10 +20,8 @@ { const steps: DriveStep[] = [ diff --git a/frontend/src/routes/(root)/(logged)/guides/+page.svelte b/frontend/src/routes/(root)/(logged)/guides/+page.svelte index 6669e68485ac9..5f7b037c7389c 100644 --- a/frontend/src/routes/(root)/(logged)/guides/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/guides/+page.svelte @@ -1,7 +1,7 @@ Date: Fri, 21 Nov 2025 15:56:57 +0100 Subject: [PATCH 054/137] Unlike workspace onboarding and flow tutorial --- .../WorkspaceOnboardingTutorial.svelte | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte index 0fe221cb99655..4bcd6f57d036a 100644 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte @@ -2,17 +2,9 @@ import { updateProgress } from '$lib/tutorialUtils' import Tutorial from '../Tutorial.svelte' import type { DriveStep } from 'driver.js' - import { base } from '$lib/base' let tutorial: Tutorial | undefined = $state(undefined) - function hideOverlay() { - const overlay = document.querySelector('.driver-overlay') as HTMLElement - if (overlay) { - overlay.style.display = 'none' - } - } - export function runTutorial() { tutorial?.runTutorial() } @@ -85,21 +77,11 @@ popover: { title: 'Create your first app', description: - 'App

Build low-code applications with Windmill

', + 'App

Build low-code applications with Windmill. That\'s it for the tour!

', onNextClick: async () => { - // Clear any existing flow drafts from localStorage to ensure fresh start - try { - localStorage.removeItem('flow') - } catch (e) { - console.error('Error clearing localStorage', e) - } - // Hide overlay before navigation - hideOverlay() // Mark tutorial as complete updateProgress(8) driver.destroy() - // Navigate to the flow creation page with tutorial continuation parameter and nodraft - window.location.href = `${base}/flows/add?tutorial=workspace-onboarding-continue&nodraft=true` } }, element: '#create-app-button', From dd6b01e73d36b6d4b393e7c8267f780d13b75d4a Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 16:06:01 +0100 Subject: [PATCH 055/137] Rename flow tutorial with better name --- frontend/src/lib/components/FlowTutorials.svelte | 12 ++++++------ ...ontinue.svelte => FlowBuilderLiveTutorial.svelte} | 2 +- .../src/routes/(root)/(logged)/guides/+page.svelte | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) rename frontend/src/lib/components/tutorials/{FlowBuilderTutorialWorkspaceOnboardingContinue.svelte => FlowBuilderLiveTutorial.svelte} (99%) diff --git a/frontend/src/lib/components/FlowTutorials.svelte b/frontend/src/lib/components/FlowTutorials.svelte index 152cd29e2f241..ac7b0c9b0e7d8 100644 --- a/frontend/src/lib/components/FlowTutorials.svelte +++ b/frontend/src/lib/components/FlowTutorials.svelte @@ -5,7 +5,7 @@ import FlowBuilderTutorialSimpleFlow from './tutorials/FlowBuilderTutorialSimpleFlow.svelte' import FlowBuilderTutorialForLoop from './tutorials/FlowBuilderTutorialForLoop.svelte' import FlowBuilderTutorialErrorHandler from './tutorials/FlowBuilderTutorialErrorHandler.svelte' - import FlowBuilderTutorialWorkspaceOnboardingContinue from './tutorials/FlowBuilderTutorialWorkspaceOnboardingContinue.svelte' + import FlowBuilderLiveTutorial from './tutorials/FlowBuilderLiveTutorial.svelte' let flowBuilderTutorialSimpleFlow: FlowBuilderTutorialSimpleFlow | undefined = $state(undefined) let flowBuilderTutorialForLoop: FlowBuilderTutorialForLoop | undefined = $state(undefined) @@ -13,7 +13,7 @@ let flowBuilderTutorialBranchAll: FlowBuilderTutorialBranchAll | undefined = $state(undefined) let flowBuilderTutorialErrorHandler: FlowBuilderTutorialErrorHandler | undefined = $state(undefined) - let flowBuilderTutorialWorkspaceOnboardingContinue: FlowBuilderTutorialWorkspaceOnboardingContinue | undefined = + let flowBuilderLiveTutorial: FlowBuilderLiveTutorial | undefined = $state(undefined) export function runTutorialById(id: string, indexToInsertAt?: number | undefined) { @@ -27,8 +27,8 @@ flowBuilderTutorialSimpleFlow?.runTutorial() } else if (id === 'error-handler') { flowBuilderTutorialErrorHandler?.runTutorial() - } else if (id === 'workspace-onboarding-continue') { - flowBuilderTutorialWorkspaceOnboardingContinue?.runTutorial() + } else if (id === 'flow-live-tutorial') { + flowBuilderLiveTutorial?.runTutorial() } } @@ -67,8 +67,8 @@ on:skipAll={skipAll} on:reload /> - From 563e5a338ee16dd457d0cf7066b6d331f14b6d51 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 16:21:55 +0100 Subject: [PATCH 056/137] Remove the automatic trigger for flow previous and broken tutorial --- .../src/routes/(root)/(logged)/flows/add/+page.svelte | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 2fca0641de835..a3e95354748ed 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -148,7 +148,7 @@ await initFlow(flow, flowStore, flowStateStore) flowBuilder?.loadFlowState() loading = false - + // Trigger tutorial after everything is initialized const tutorialParam = $page.url.searchParams.get('tutorial') if (tutorialParam) { @@ -160,15 +160,6 @@ attempts++ } flowBuilder?.triggerTutorial() - } else if (!templatePath && !hubId && !state && !$importFlowStore) { - // 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() } } From f05c290ef10358a436eca1854149956d163bd3d0 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 17:01:41 +0100 Subject: [PATCH 057/137] Push tutorials to Help sectionof the sidebar --- .../lib/components/sidebar/SidebarContent.svelte | 15 ++++++++------- .../(logged)/{guides => tutorials}/+page.svelte | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) rename frontend/src/routes/(root)/(logged)/{guides => tutorials}/+page.svelte (94%) diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index 1841e11502538..c741e263f7606 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -21,6 +21,7 @@ FolderCog, FolderOpen, Github, + GraduationCap, HelpCircle, Home, LogOut, @@ -105,6 +106,13 @@ label: 'Help', icon: HelpCircle, subItems: [ + { + label: 'Tutorials', + href: `${base}/tutorials`, + icon: GraduationCap, + aiId: 'sidebar-menu-link-tutorials', + aiDescription: 'Button to navigate to tutorials' + }, { label: 'Docs', href: 'https://www.windmill.dev/docs/intro/', @@ -312,13 +320,6 @@ // icon: Cog, // disabled: !$userStore?.is_admin && !$userStore?.is_super_admin // }, - { - label: 'Guides', - href: `${base}/guides`, - icon: BookOpen, - aiId: 'sidebar-menu-link-guides', - aiDescription: 'Button to navigate to guides and documentation' - }, { label: 'Settings', icon: Settings, diff --git a/frontend/src/routes/(root)/(logged)/guides/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte similarity index 94% rename from frontend/src/routes/(root)/(logged)/guides/+page.svelte rename to frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 55179aa523274..c567dae720d74 100644 --- a/frontend/src/routes/(root)/(logged)/guides/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -15,8 +15,8 @@
From bda24eaf85c64eedf57a370df81312fcecae55b7 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 17:34:39 +0100 Subject: [PATCH 058/137] Fix redirection t /tutorials page --- .../components/sidebar/SidebarContent.svelte | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index c741e263f7606..8b91d468ecbf1 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -111,35 +111,40 @@ href: `${base}/tutorials`, icon: GraduationCap, aiId: 'sidebar-menu-link-tutorials', - aiDescription: 'Button to navigate to 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 } ] } @@ -627,7 +632,7 @@ {/snippet} {#snippet children({ item })} {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} - +
{#if subItem.icon} From ff8199e6e1549be16190fe630f72d2426831e617 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 21 Nov 2025 17:45:52 +0100 Subject: [PATCH 059/137] Add tutorials page and update workspace onboarding flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../WorkspaceOnboardingTutorial.svelte | 16 ++++++++++++- .../src/routes/(root)/(logged)/+page.svelte | 19 ++++++++------- .../(root)/(logged)/tutorials/+page.svelte | 23 ++++++++++++++++++- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte index 4bcd6f57d036a..0db2d9426ffe5 100644 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte @@ -2,11 +2,20 @@ 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' let tutorial: Tutorial | undefined = $state(undefined) export function runTutorial() { - tutorial?.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() + } } @@ -82,6 +91,11 @@ // Mark tutorial as complete updateProgress(8) driver.destroy() + + // Clean up URL parameter if present + if ($page.url.searchParams.has('tutorial')) { + goto(`${base}/`, { replaceState: true }) + } } }, element: '#create-app-button', diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index 59c7e868b5a39..e507e447cf52d 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -97,8 +97,15 @@ let workspaceTutorials: WorkspaceTutorials | undefined = undefined onMount(() => { - // Check if user hasn't completed or ignored the workspace onboarding tutorial - if (!$ignoredTutorials.includes(8) && $tutorialsToDo.includes(8)) { + // 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') @@ -254,14 +261,6 @@ title="Home" childrenWrapperDivClasses="flex-1 flex flex-row gap-4 flex-wrap justify-end items-center" > - {#if !$userStore?.operator} Create a diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index c567dae720d74..0c3ce91957be1 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -3,11 +3,18 @@ import PageHeader from '$lib/components/PageHeader.svelte' import { Tab } from '$lib/components/common' import Tabs from '$lib/components/common/tabs/Tabs.svelte' - import { BookOpen, Users, Workflow } from 'lucide-svelte' + import { BookOpen, Users, Workflow, GraduationCap } from 'lucide-svelte' import { base } from '$lib/base' + import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte' let tab: 'quickstart' | 'team' = $state('quickstart') + let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined) + + function startWorkspaceOnboarding() { + workspaceTutorials?.runTutorialById('workspace-onboarding') + } + function startFlowTutorial() { window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial&nodraft=true` } @@ -29,6 +36,18 @@ {#if tab === 'quickstart'}
+ + + +
+
+{/if} diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index e507e447cf52d..fb83ec6bd3072 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -36,7 +36,7 @@ import { page } from '$app/stores' import { goto, replaceState } from '$app/navigation' import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte' - import { onMount } from 'svelte' + import { onMount, setContext } from 'svelte' import { tutorialsToDo } from '$lib/stores' import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials' @@ -94,7 +94,11 @@ appViewer.openDrawer?.() } - let workspaceTutorials: WorkspaceTutorials | undefined = undefined + 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 From dbb212b2ef107f777ad09f4f602a0138b4b3efb5 Mon Sep 17 00:00:00 2001 From: tristantr Date: Mon, 1 Dec 2025 16:41:44 +0100 Subject: [PATCH 061/137] Start tutorials for Run/logs section --- .../src/lib/components/FlowTutorials.svelte | 5 + .../lib/components/RunPageTutorials.svelte | 18 + .../lib/components/home/NoItemFound.svelte | 4 +- .../tutorials/ExploreRunsTutorial.svelte | 194 ++++++++++ .../tutorials/RunPageTutorial.svelte | 346 ++++++++++++++++++ 5 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/RunPageTutorials.svelte create mode 100644 frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte create mode 100644 frontend/src/lib/components/tutorials/RunPageTutorial.svelte diff --git a/frontend/src/lib/components/FlowTutorials.svelte b/frontend/src/lib/components/FlowTutorials.svelte index ac7b0c9b0e7d8..da8bc0fe3b388 100644 --- a/frontend/src/lib/components/FlowTutorials.svelte +++ b/frontend/src/lib/components/FlowTutorials.svelte @@ -6,6 +6,7 @@ import FlowBuilderTutorialForLoop from './tutorials/FlowBuilderTutorialForLoop.svelte' import FlowBuilderTutorialErrorHandler from './tutorials/FlowBuilderTutorialErrorHandler.svelte' import FlowBuilderLiveTutorial from './tutorials/FlowBuilderLiveTutorial.svelte' + import ExploreRunsTutorial from './tutorials/ExploreRunsTutorial.svelte' let flowBuilderTutorialSimpleFlow: FlowBuilderTutorialSimpleFlow | undefined = $state(undefined) let flowBuilderTutorialForLoop: FlowBuilderTutorialForLoop | undefined = $state(undefined) @@ -15,6 +16,7 @@ $state(undefined) let flowBuilderLiveTutorial: FlowBuilderLiveTutorial | undefined = $state(undefined) + let exploreRunsTutorial: ExploreRunsTutorial | undefined = $state(undefined) export function runTutorialById(id: string, indexToInsertAt?: number | undefined) { if (id === 'forloop') { @@ -29,6 +31,8 @@ flowBuilderTutorialErrorHandler?.runTutorial() } else if (id === 'flow-live-tutorial') { flowBuilderLiveTutorial?.runTutorial() + } else if (id === 'explore-runs') { + exploreRunsTutorial?.runTutorial() } } @@ -73,3 +77,4 @@ on:skipAll={skipAll} on:reload /> + diff --git a/frontend/src/lib/components/RunPageTutorials.svelte b/frontend/src/lib/components/RunPageTutorials.svelte new file mode 100644 index 0000000000000..c1aa9a720428e --- /dev/null +++ b/frontend/src/lib/components/RunPageTutorials.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/home/NoItemFound.svelte b/frontend/src/lib/components/home/NoItemFound.svelte index 01379d248a42a..2ee5555d44915 100644 --- a/frontend/src/lib/components/home/NoItemFound.svelte +++ b/frontend/src/lib/components/home/NoItemFound.svelte @@ -21,8 +21,8 @@ } function startRunsTutorial() { - // Placeholder for future runs tutorial - window.location.href = `${base}/runs` + // Navigate to flow editor with pre-built flow for the runs tutorial + window.location.href = `${base}/flows/add?tutorial=explore-runs&nodraft=true` } diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte new file mode 100644 index 0000000000000..f4782f78073fd --- /dev/null +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -0,0 +1,194 @@ + + + { + const steps: DriveStep[] = [ + { + element: '#flow-editor-test-flow', + onHighlighted: async () => { + step1Complete = false + await wait(DELAY_SHORT) + step1Complete = true + }, + popover: { + title: 'Test your flow', + description: + 'Your temperature converter flow is ready with an input of 25°C. Let\'s test it!', + side: 'bottom', + onNextClick: async () => { + if (!step1Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + + // Click the Test Flow button to open the drawer + const testFlowButton = document.querySelector('#flow-editor-test-flow') as HTMLElement + if (testFlowButton) { + testFlowButton.click() + await wait(DELAY_LONG) + } + + driver.moveNext() + } + } + }, + { + element: '#flow-editor-test-flow-drawer', + onHighlighted: async () => { + step2Complete = false + await wait(DELAY_SHORT) + step2Complete = true + }, + popover: { + title: 'Run the flow', + description: + 'Click "Next" to execute the flow. You\'ll see it validate the temperature, convert to Fahrenheit, and categorize the result!', + side: 'left', + onNextClick: async () => { + if (!step2Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + + // Click the Test button to execute the flow + const testButton = document.querySelector('#flow-editor-test-flow-drawer') as HTMLElement + if (testButton) { + testButton.click() + } + + driver.destroy() + sendUserToast( + 'Flow is running! Watch the execution and explore the results.', + false, + [], + undefined, + 5000 + ) + } + } + } + ] + + return steps + }} +/> diff --git a/frontend/src/lib/components/tutorials/RunPageTutorial.svelte b/frontend/src/lib/components/tutorials/RunPageTutorial.svelte new file mode 100644 index 0000000000000..34d817092b4aa --- /dev/null +++ b/frontend/src/lib/components/tutorials/RunPageTutorial.svelte @@ -0,0 +1,346 @@ + + + { + const steps: DriveStep[] = [ + { + popover: { + title: 'Explore flow execution', + description: + "Let's explore a completed flow run and see what information is available about the execution.", + onNextClick: async () => { + driver.moveNext() + } + } + }, + { + element: '[data-testid="result-panel"]', + onHighlighted: async () => { + step1Complete = false + await wait(DELAY_MEDIUM) + step1Complete = true + }, + popover: { + title: 'View the result', + description: + 'This panel shows the final result of the flow. Our temperature converter returned the temperature in Fahrenheit with a category and emoji.', + side: 'left', + onNextClick: () => { + if (!step1Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + driver.moveNext() + } + } + }, + { + element: '[data-testid="logs-tab"]', + onHighlighted: async () => { + step2Complete = false + + await wait(DELAY_MEDIUM) + + const logsTab = findTabButton('Logs') + if (logsTab) { + logsTab.click() + await wait(DELAY_LONG) + } + + step2Complete = true + }, + popover: { + title: 'Check the logs', + description: 'The logs show detailed execution information for each step of the flow.', + side: 'bottom', + onNextClick: () => { + if (!step2Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + driver.moveNext() + } + } + }, + { + element: '[data-testid="flow-status-viewer"]', + onHighlighted: async () => { + step3Complete = false + await wait(DELAY_MEDIUM) + step3Complete = true + }, + popover: { + title: 'Flow visualization', + description: + 'This diagram shows all the steps in your flow. You can click on individual steps to see their specific results and logs.', + side: 'top', + onNextClick: () => { + if (!step3Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + driver.moveNext() + } + } + }, + { + element: '[data-testid="job-metadata"]', + onHighlighted: async () => { + step4Complete = false + await wait(DELAY_MEDIUM) + step4Complete = true + }, + popover: { + title: 'Execution metadata', + description: + 'Here you can see important details like execution time, who ran it, when it started, and resource usage.', + side: 'left', + onNextClick: () => { + if (!step4Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + driver.moveNext() + } + } + } + ] + + return steps + }} +/> From e5d93cce69aeaf2915080a24e2aa4d383ecc7ae2 Mon Sep 17 00:00:00 2001 From: tristantr Date: Mon, 1 Dec 2025 16:48:41 +0100 Subject: [PATCH 062/137] Fix data connector --- .../lib/components/tutorials/ExploreRunsTutorial.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index f4782f78073fd..f5b376e9f180d 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -51,7 +51,12 @@ 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: {} + input_transforms: { + celsius: { + expr: 'flow_input.celsius', + type: 'javascript' + } + } }, summary: 'Validate temperature input' }, From 020da0a8c0f0fbedd9d005bbc8cfec776f470d17 Mon Sep 17 00:00:00 2001 From: tristantr Date: Mon, 1 Dec 2025 19:06:19 +0100 Subject: [PATCH 063/137] Add flow execution graph from Run drawer --- .../tutorials/ExploreRunsTutorial.svelte | 92 +++++++++++++++---- 1 file changed, 75 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index f5b376e9f180d..44bce81a3526a 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -16,6 +16,7 @@ let step1Complete = $state(false) let step2Complete = $state(false) let step3Complete = $state(false) + let step4Complete = $state(false) // Constants for delays const DELAY_SHORT = 100 @@ -138,7 +139,7 @@ popover: { title: 'Test your flow', description: - 'Your temperature converter flow is ready with an input of 25°C. Let\'s test it!', + 'Your temperature converter flow is ready with an input of 25°C. Click "Next" to test it and see the results!', side: 'bottom', onNextClick: async () => { if (!step1Complete) { @@ -153,42 +154,99 @@ await wait(DELAY_LONG) } + // Click the Test button to execute the flow + const testButton = document.querySelector('#flow-editor-test-flow-drawer') as HTMLElement + if (testButton) { + testButton.click() + await wait(DELAY_LONG) + } + driver.moveNext() } } }, { - element: '#flow-editor-test-flow-drawer', + element: '.border.rounded-md.shadow.p-2', onHighlighted: async () => { step2Complete = false await wait(DELAY_SHORT) step2Complete = true }, popover: { - title: 'Run the flow', + title: 'View the result', description: - 'Click "Next" to execute the flow. You\'ll see it validate the temperature, convert to Fahrenheit, and categorize the result!', + 'Here you can see the final result of your flow execution. The temperature has been converted and categorized!', side: 'left', - onNextClick: async () => { + onNextClick: () => { if (!step2Complete) { sendUserToast('Please wait...', false, [], undefined, 3000) return } - - // Click the Test button to execute the flow - const testButton = document.querySelector('#flow-editor-test-flow-drawer') as HTMLElement - if (testButton) { - testButton.click() + driver.moveNext() + } + } + }, + { + element: '.border-b.flex.flex-row.whitespace-nowrap.scrollbar-hidden.mx-auto', + onHighlighted: async () => { + step3Complete = false + await wait(DELAY_SHORT) + step3Complete = true + }, + popover: { + title: 'Explore the tabs', + description: + 'Use these tabs to navigate between different views: Result, Logs, and Graph.', + side: 'bottom', + onNextClick: () => { + if (!step3Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + driver.moveNext() + } + } + }, + { + element: '.grid.grid-cols-3.border.h-full', + onHighlighted: async () => { + step4Complete = false + await wait(DELAY_SHORT) + step4Complete = true + }, + popover: { + title: 'Flow execution graph', + description: + 'This graph shows a visual representation of your flow execution. You can see each step and how data flows between them.', + side: 'top', + onNextClick: () => { + if (!step4Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + driver.moveNext() + } + } + }, + { + element: '.text-primary.whitespace-nowrap.truncate.text-xs', + onHighlighted: async () => { + step4Complete = false + await wait(DELAY_SHORT) + step4Complete = true + }, + popover: { + title: 'Your time to explore logs!', + description: + 'Click here to open the full screen view and discover all the execution details.', + side: 'left', + onNextClick: () => { + if (!step4Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return } driver.destroy() - sendUserToast( - 'Flow is running! Watch the execution and explore the results.', - false, - [], - undefined, - 5000 - ) } } } From cdfecb0667d16d5d3fce465c580dedd5d8b835b7 Mon Sep 17 00:00:00 2001 From: tristantr Date: Mon, 1 Dec 2025 21:59:21 +0100 Subject: [PATCH 064/137] Add tabs highlighting in drawer --- .../tutorials/ExploreRunsTutorial.svelte | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 44bce81a3526a..753439794958a 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -219,6 +219,66 @@ description: 'This graph shows a visual representation of your flow execution. You can see each step and how data flows between them.', side: 'top', + onNextClick: async () => { + if (!step4Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + + // Click on the Logs tab + const tabs = Array.from(document.querySelectorAll('.border-b-2.py-1.cursor-pointer')) + const logsTab = tabs.find((tab) => tab.textContent?.trim() === 'Logs') as HTMLElement + if (logsTab) { + logsTab.click() + await wait(DELAY_MEDIUM) + } + + driver.moveNext() + } + } + }, + { + element: '.w-full.rounded-md.overflow-hidden.border', + onHighlighted: async () => { + step4Complete = false + await wait(DELAY_SHORT) + step4Complete = true + }, + popover: { + title: 'Execution logs', + description: + 'Here you can see detailed logs from each step of your flow execution. This is useful for debugging and understanding what happened during the run.', + side: 'top', + onNextClick: () => { + if (!step4Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + driver.moveNext() + } + } + }, + { + element: 'ul.w-full', + onHighlighted: async () => { + step4Complete = false + + // Click on the Details tab first + const tabs = Array.from(document.querySelectorAll('.border-b-2.py-1.cursor-pointer')) + const detailsTab = tabs.find((tab) => tab.textContent?.trim() === 'Details') as HTMLElement + if (detailsTab) { + detailsTab.click() + await wait(DELAY_MEDIUM) + } + + await wait(DELAY_SHORT) + step4Complete = true + }, + popover: { + title: 'Execution details', + description: + 'This section shows important metadata about your flow execution, including timing, user, and resource usage.', + side: 'top', onNextClick: () => { if (!step4Complete) { sendUserToast('Please wait...', false, [], undefined, 3000) From 84f830c1e6f816df9cd0386419dbb38ce4cad090 Mon Sep 17 00:00:00 2001 From: tristantr Date: Mon, 1 Dec 2025 22:27:49 +0100 Subject: [PATCH 065/137] Improve tutorial on run drawer --- .../tutorials/ExploreRunsTutorial.svelte | 130 +++++++----------- 1 file changed, 47 insertions(+), 83 deletions(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 753439794958a..4fec7a1253af8 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -22,6 +22,52 @@ const DELAY_SHORT = 100 const DELAY_MEDIUM = 300 const DELAY_LONG = 500 + const DELAY_ANIMATION = 1500 + + // Helper function to create and animate a fake cursor + async function createFakeCursor( + startElement: HTMLElement | null, + endElement: HTMLElement, + transitionDuration: number = 1.5 + ): Promise { + 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 async function runTutorial() { // Load the pre-built flow immediately when tutorial starts @@ -217,95 +263,13 @@ popover: { title: 'Flow execution graph', description: - 'This graph shows a visual representation of your flow execution. You can see each step and how data flows between them.', - side: 'top', - onNextClick: async () => { - if (!step4Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } - - // Click on the Logs tab - const tabs = Array.from(document.querySelectorAll('.border-b-2.py-1.cursor-pointer')) - const logsTab = tabs.find((tab) => tab.textContent?.trim() === 'Logs') as HTMLElement - if (logsTab) { - logsTab.click() - await wait(DELAY_MEDIUM) - } - - driver.moveNext() - } - } - }, - { - element: '.w-full.rounded-md.overflow-hidden.border', - onHighlighted: async () => { - step4Complete = false - await wait(DELAY_SHORT) - step4Complete = true - }, - popover: { - title: 'Execution logs', - description: - 'Here you can see detailed logs from each step of your flow execution. This is useful for debugging and understanding what happened during the run.', + 'This graph shows a visual representation of your flow execution. You can see each step and how data flows between them. Click on any step to see its specific results and logs.', side: 'top', onNextClick: () => { if (!step4Complete) { sendUserToast('Please wait...', false, [], undefined, 3000) return } - driver.moveNext() - } - } - }, - { - element: 'ul.w-full', - onHighlighted: async () => { - step4Complete = false - - // Click on the Details tab first - const tabs = Array.from(document.querySelectorAll('.border-b-2.py-1.cursor-pointer')) - const detailsTab = tabs.find((tab) => tab.textContent?.trim() === 'Details') as HTMLElement - if (detailsTab) { - detailsTab.click() - await wait(DELAY_MEDIUM) - } - - await wait(DELAY_SHORT) - step4Complete = true - }, - popover: { - title: 'Execution details', - description: - 'This section shows important metadata about your flow execution, including timing, user, and resource usage.', - side: 'top', - onNextClick: () => { - if (!step4Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } - driver.moveNext() - } - } - }, - { - element: '.text-primary.whitespace-nowrap.truncate.text-xs', - onHighlighted: async () => { - step4Complete = false - await wait(DELAY_SHORT) - step4Complete = true - }, - popover: { - title: 'Your time to explore logs!', - description: - 'Click here to open the full screen view and discover all the execution details.', - side: 'left', - onNextClick: () => { - if (!step4Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } - driver.destroy() } } From ff92339ab8e312518355698b47f0d34e406a9ee5 Mon Sep 17 00:00:00 2001 From: tristantr Date: Mon, 1 Dec 2025 22:31:01 +0100 Subject: [PATCH 066/137] Add mouse cursor moving from graph tab --- .../tutorials/ExploreRunsTutorial.svelte | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 4fec7a1253af8..6bd89a042eedc 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -258,12 +258,40 @@ onHighlighted: async () => { step4Complete = false await wait(DELAY_SHORT) + + // Find the step 'a' button inside the drawer and click it with fake cursor + const flowPreviewContent = document.getElementById('flow-preview-content') + if (flowPreviewContent) { + // Find the module element + const stepButton = flowPreviewContent.querySelector('.relative.flex.gap-1.justify-between.items-center.w-full.overflow-hidden.rounded-sm.p-2.text-2xs.module.text-primary') as HTMLElement + + if (stepButton) { + // Create fake cursor and animate it to the button + const fakeCursor = await createFakeCursor(null, stepButton, 1.5) + await wait(DELAY_MEDIUM) + + // Animate click (shrink cursor briefly) + fakeCursor.style.transform = 'scale(0.8)' + await wait(100) + fakeCursor.style.transform = 'scale(1)' + await wait(100) + + // Click the button + stepButton.click() + await wait(DELAY_SHORT) + + // Remove fake cursor + fakeCursor.remove() + await wait(DELAY_MEDIUM) + } + } + step4Complete = true }, popover: { title: 'Flow execution graph', description: - 'This graph shows a visual representation of your flow execution. You can see each step and how data flows between them. Click on any step to see its specific results and logs.', + 'Watch as we click on a step to explore its details. You can click on any step to see its specific results and logs.', side: 'top', onNextClick: () => { if (!step4Complete) { From fad9a3dfb6f78135fbca1e3fb468d2e98f2358f4 Mon Sep 17 00:00:00 2001 From: tristantr Date: Mon, 1 Dec 2025 22:44:27 +0100 Subject: [PATCH 067/137] Add cursor click on script in Drawer Graph tabs --- .../components/tutorials/ExploreRunsTutorial.svelte | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 6bd89a042eedc..689019d7b399b 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -7,6 +7,7 @@ import type { Flow } from '$lib/gen' import { wait, type StateStore } from '$lib/utils' import { sendUserToast } from '$lib/toast' + import { triggerPointerDown } from './utils' const { flowStore, flowStateStore } = getContext('FlowEditorContext') @@ -262,8 +263,11 @@ // Find the step 'a' button inside the drawer and click it with fake cursor const flowPreviewContent = document.getElementById('flow-preview-content') if (flowPreviewContent) { - // Find the module element - const stepButton = flowPreviewContent.querySelector('.relative.flex.gap-1.justify-between.items-center.w-full.overflow-hidden.rounded-sm.p-2.text-2xs.module.text-primary') as HTMLElement + // Find the button containing "Validate temperature input" text + const buttons = Array.from(flowPreviewContent.querySelectorAll('button')) + const stepButton = buttons.find(btn => + btn.textContent?.includes('Validate temperature input') + ) as HTMLElement if (stepButton) { // Create fake cursor and animate it to the button @@ -276,7 +280,9 @@ fakeCursor.style.transform = 'scale(1)' await wait(100) - // Click the button + // Trigger pointer events (flow graph uses pointer events instead of click) + stepButton.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + stepButton.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) stepButton.click() await wait(DELAY_SHORT) From 2dad0e0019b48e92f443c168340b653dc3f9052f Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 10:39:36 +0100 Subject: [PATCH 068/137] Add troubleshooting flow in tutorial --- .../tutorials/ExploreRunsTutorial.svelte | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 689019d7b399b..a6c25099acd10 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -18,6 +18,7 @@ let step2Complete = $state(false) let step3Complete = $state(false) let step4Complete = $state(false) + let step5Complete = $state(false) // Constants for delays const DELAY_SHORT = 100 @@ -113,7 +114,7 @@ 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}', + '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: { @@ -186,7 +187,7 @@ popover: { title: 'Test your flow', description: - 'Your temperature converter flow is ready with an input of 25°C. Click "Next" to test it and see the results!', + 'Your temperature converter flow is ready with an input of 25°C. Let\'s test it!', side: 'bottom', onNextClick: async () => { if (!step1Complete) { @@ -201,13 +202,35 @@ await wait(DELAY_LONG) } + driver.moveNext() + } + } + }, + { + element: '#flow-editor-test-flow-drawer', + onHighlighted: async () => { + step2Complete = false + await wait(DELAY_SHORT) + step2Complete = true + }, + popover: { + title: 'Run the flow', + description: + 'Click "Next" to execute the flow. You\'ll see how to troubleshoot when something goes wrong!', + side: 'left', + onNextClick: async () => { + if (!step2Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } + // Click the Test button to execute the flow const testButton = document.querySelector('#flow-editor-test-flow-drawer') as HTMLElement if (testButton) { testButton.click() - await wait(DELAY_LONG) } + await wait(DELAY_LONG) driver.moveNext() } } @@ -215,17 +238,17 @@ { element: '.border.rounded-md.shadow.p-2', onHighlighted: async () => { - step2Complete = false + step3Complete = false await wait(DELAY_SHORT) - step2Complete = true + step3Complete = true }, popover: { title: 'View the result', description: - 'Here you can see the final result of your flow execution. The temperature has been converted and categorized!', + 'Notice step b failed! The flow encountered an error. Let\'s explore what happened.', side: 'left', onNextClick: () => { - if (!step2Complete) { + if (!step3Complete) { sendUserToast('Please wait...', false, [], undefined, 3000) return } @@ -236,9 +259,9 @@ { element: '.border-b.flex.flex-row.whitespace-nowrap.scrollbar-hidden.mx-auto', onHighlighted: async () => { - step3Complete = false + step4Complete = false await wait(DELAY_SHORT) - step3Complete = true + step4Complete = true }, popover: { title: 'Explore the tabs', @@ -246,7 +269,7 @@ 'Use these tabs to navigate between different views: Result, Logs, and Graph.', side: 'bottom', onNextClick: () => { - if (!step3Complete) { + if (!step4Complete) { sendUserToast('Please wait...', false, [], undefined, 3000) return } @@ -257,16 +280,16 @@ { element: '.grid.grid-cols-3.border.h-full', onHighlighted: async () => { - step4Complete = false + step5Complete = false await wait(DELAY_SHORT) - // Find the step 'a' button inside the drawer and click it with fake cursor + // Find the step 'b' button inside the drawer and click it with fake cursor const flowPreviewContent = document.getElementById('flow-preview-content') if (flowPreviewContent) { // Find the button containing "Validate temperature input" text const buttons = Array.from(flowPreviewContent.querySelectorAll('button')) const stepButton = buttons.find(btn => - btn.textContent?.includes('Validate temperature input') + btn.textContent?.includes('Convert to Fahrenheit') ) as HTMLElement if (stepButton) { @@ -292,7 +315,7 @@ } } - step4Complete = true + step5Complete = true }, popover: { title: 'Flow execution graph', @@ -300,7 +323,7 @@ 'Watch as we click on a step to explore its details. You can click on any step to see its specific results and logs.', side: 'top', onNextClick: () => { - if (!step4Complete) { + if (!step5Complete) { sendUserToast('Please wait...', false, [], undefined, 3000) return } From 2e03f7d59434ec57696d6e7b57286a6ab882a0a1 Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 10:53:08 +0100 Subject: [PATCH 069/137] Add step to show logs of failed step --- .../tutorials/ExploreRunsTutorial.svelte | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index a6c25099acd10..44ec6465fd190 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -19,6 +19,7 @@ let step3Complete = $state(false) let step4Complete = $state(false) let step5Complete = $state(false) + let step6Complete = $state(false) // Constants for delays const DELAY_SHORT = 100 @@ -327,6 +328,27 @@ sendUserToast('Please wait...', false, [], undefined, 3000) 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 () => { + step6Complete = false + await wait(DELAY_SHORT) + step6Complete = true + }, + popover: { + title: 'Step details', + description: + 'Here you can see the code, result, and logs for this specific step. This is where you can debug and understand what went wrong!', + side: 'left', + onNextClick: () => { + if (!step6Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } driver.destroy() } } From 6f38c165d4b7b997adfd756ef5c01c0d0f9e9d74 Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 11:31:23 +0100 Subject: [PATCH 070/137] add step 7 to invite the user to fix by himself and se the new results --- .../tutorials/ExploreRunsTutorial.svelte | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 44ec6465fd190..5ebdc9bfaade6 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -20,6 +20,7 @@ let step4Complete = $state(false) let step5Complete = $state(false) let step6Complete = $state(false) + let step7Complete = $state(false) // Constants for delays const DELAY_SHORT = 100 @@ -344,11 +345,62 @@ description: 'Here you can see the code, result, and logs for this specific step. This is where you can debug and understand what went wrong!', side: 'left', - onNextClick: () => { + onNextClick: async () => { if (!step6Complete) { sendUserToast('Please wait...', false, [], undefined, 3000) return } + + // Click the close button inside the drawer + const drawer = document.getElementById('flow-preview-content') + if (drawer) { + const closeButton = Array.from(drawer.querySelectorAll('button')).find(btn => { + const svg = btn.querySelector('svg.lucide-x') + return svg !== null + }) as HTMLElement + + if (closeButton) { + // Create fake cursor and animate it to the close button + const fakeCursor = await createFakeCursor(null, closeButton, 1.5) + await wait(DELAY_MEDIUM) + + // Animate click (shrink cursor briefly) + fakeCursor.style.transform = 'scale(0.8)' + await wait(100) + fakeCursor.style.transform = 'scale(1)' + await wait(100) + + // Click the button + closeButton.click() + await wait(DELAY_SHORT) + + // Remove fake cursor + fakeCursor.remove() + } + } + + await wait(DELAY_LONG) + driver.moveNext() + } + } + }, + { + element: '#b', + onHighlighted: async () => { + step7Complete = false + await wait(DELAY_SHORT) + step7Complete = true + }, + popover: { + title: 'Open the failed step', + description: + 'Now click on step b to open it and see the code where the error occurred.', + side: 'top', + onNextClick: () => { + if (!step7Complete) { + sendUserToast('Please wait...', false, [], undefined, 3000) + return + } driver.destroy() } } From 7ec392b61d241ab53730c164702c7d80d230efae Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 11:43:15 +0100 Subject: [PATCH 071/137] Improve wording --- .../tutorials/ExploreRunsTutorial.svelte | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 5ebdc9bfaade6..6579911d31eb2 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -187,9 +187,9 @@ step1Complete = true }, popover: { - title: 'Test your flow', + title: 'Troubleshoot a broken flow', description: - 'Your temperature converter flow is ready with an input of 25°C. Let\'s test it!', + 'This flow is intentionally broken. Let’s run it with an input of 25°C so you can see what needs to be fixed.', side: 'bottom', onNextClick: async () => { if (!step1Complete) { @@ -218,7 +218,7 @@ popover: { title: 'Run the flow', description: - 'Click "Next" to execute the flow. You\'ll see how to troubleshoot when something goes wrong!', + 'Click "Next" to execute the flow. We\'ll use the results to troubleshoot the error.', side: 'left', onNextClick: async () => { if (!step2Complete) { @@ -245,9 +245,9 @@ step3Complete = true }, popover: { - title: 'View the result', + title: 'Review the error', description: - 'Notice step b failed! The flow encountered an error. Let\'s explore what happened.', + 'Step b failed during the run. Let’s review the error and understand why this step didn’t work.', side: 'left', onNextClick: () => { if (!step3Complete) { @@ -268,7 +268,7 @@ popover: { title: 'Explore the tabs', description: - 'Use these tabs to navigate between different views: Result, Logs, and Graph.', + 'Use these tabs to navigate between different views: Result, Logs, and Graph. We’ll focus on the Result tab to review the error.', side: 'bottom', onNextClick: () => { if (!step4Complete) { @@ -320,9 +320,9 @@ step5Complete = true }, popover: { - title: 'Flow execution graph', + title: 'Inspect the flow graph', description: - 'Watch as we click on a step to explore its details. You can click on any step to see its specific results and logs.', + 'Here’s the full execution graph. Let’s select step b—the one that failed—to take a closer look at its behavior.', side: 'top', onNextClick: () => { if (!step5Complete) { @@ -341,9 +341,9 @@ step6Complete = true }, popover: { - title: 'Step details', + title: 'Check step details', description: - 'Here you can see the code, result, and logs for this specific step. This is where you can debug and understand what went wrong!', + 'This panel shows the code, output, and logs for the selected step. It’s the best place to spot mistakes and understand how to fix them.', side: 'left', onNextClick: async () => { if (!step6Complete) { @@ -392,9 +392,9 @@ step7Complete = true }, popover: { - title: 'Open the failed step', + title: 'Your turn now!', description: - 'Now click on step b to open it and see the code where the error occurred.', + 'Open step b, fix the issue in the code, and run the flow again to confirm everything works.', side: 'top', onNextClick: () => { if (!step7Complete) { From 2f16c9b56fa2b223eae9fd397c12677137c13f83 Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 11:53:01 +0100 Subject: [PATCH 072/137] Nit improvements --- .../tutorials/ExploreRunsTutorial.svelte | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 6579911d31eb2..127f704605f86 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -341,9 +341,9 @@ step6Complete = true }, popover: { - title: 'Check step details', + title: 'We found our issue!', description: - 'This panel shows the code, output, and logs for the selected step. It’s the best place to spot mistakes and understand how to fix them.', + 'We made a typo in the code. Let’s fix it and run the flow again.', side: 'left', onNextClick: async () => { if (!step6Complete) { @@ -389,12 +389,34 @@ onHighlighted: async () => { step7Complete = false await wait(DELAY_SHORT) + + // Click on div id="b" to open the editor + const stepBDiv = document.getElementById('b') as HTMLElement + if (stepBDiv) { + // Create fake cursor and animate it to the div + const fakeCursor = await createFakeCursor(null, stepBDiv, 1.5) + await wait(DELAY_MEDIUM) + + // Animate click (shrink cursor briefly) + fakeCursor.style.transform = 'scale(0.8)' + await wait(100) + fakeCursor.style.transform = 'scale(1)' + await wait(100) + + // Click the div + stepBDiv.click() + await wait(DELAY_LONG) + + // Remove fake cursor + fakeCursor.remove() + } + step7Complete = true }, popover: { title: 'Your turn now!', description: - 'Open step b, fix the issue in the code, and run the flow again to confirm everything works.', + 'Fix the issue in the code, and run the flow again to confirm everything works.', side: 'top', onNextClick: () => { if (!step7Complete) { From 6d238de888ac5c61ebee6c944beb095a267a8f21 Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 12:00:16 +0100 Subject: [PATCH 073/137] Nits --- .../components/tutorials/ExploreRunsTutorial.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 127f704605f86..977ded660baf0 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -247,7 +247,7 @@ popover: { title: 'Review the error', description: - 'Step b failed during the run. Let’s review the error and understand why this step didn’t work.', + 'Our flow failed. Let’s review the error and understand what happened.', side: 'left', onNextClick: () => { if (!step3Complete) { @@ -268,7 +268,7 @@ popover: { title: 'Explore the tabs', description: - 'Use these tabs to navigate between different views: Result, Logs, and Graph. We’ll focus on the Result tab to review the error.', + '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 (!step4Complete) { @@ -322,7 +322,7 @@ popover: { title: 'Inspect the flow graph', description: - 'Here’s the full execution graph. Let’s select step b—the one that failed—to take a closer look at its behavior.', + 'B step failed during the run. Let’s take a closer look at its behavior.', side: 'top', onNextClick: () => { if (!step5Complete) { @@ -341,7 +341,7 @@ step6Complete = true }, popover: { - title: 'We found our issue!', + title: 'Error spotted!', description: 'We made a typo in the code. Let’s fix it and run the flow again.', side: 'left', @@ -362,7 +362,7 @@ if (closeButton) { // Create fake cursor and animate it to the close button const fakeCursor = await createFakeCursor(null, closeButton, 1.5) - await wait(DELAY_MEDIUM) + await wait(DELAY_SHORT) // Animate click (shrink cursor briefly) fakeCursor.style.transform = 'scale(0.8)' From 0d1ed7ca8b339414d25a58a293e72b43f7f8302e Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 12:10:03 +0100 Subject: [PATCH 074/137] Refactor --- .../tutorials/ExploreRunsTutorial.svelte | 207 +++++++++--------- 1 file changed, 105 insertions(+), 102 deletions(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 977ded660baf0..0ac1b4e0de67c 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -14,13 +14,15 @@ let tutorial: Tutorial | undefined = undefined // Flags to track if steps are complete - let step1Complete = $state(false) - let step2Complete = $state(false) - let step3Complete = $state(false) - let step4Complete = $state(false) - let step5Complete = $state(false) - let step6Complete = $state(false) - let step7Complete = $state(false) + let stepComplete = $state>({ + 1: false, + 2: false, + 3: false, + 4: false, + 5: false, + 6: false, + 7: false + }) // Constants for delays const DELAY_SHORT = 100 @@ -28,6 +30,28 @@ const DELAY_LONG = 500 const DELAY_ANIMATION = 1500 + // 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, @@ -73,6 +97,42 @@ return fakeCursor } + // Helper function to animate a fake cursor click + async function animateFakeCursorClick( + element: HTMLElement, + transitionDuration: number = 1.5 + ): Promise { + const fakeCursor = await createFakeCursor(null, element, transitionDuration) + await wait(DELAY_MEDIUM) + + // Animate click (shrink cursor briefly) + fakeCursor.style.transform = 'scale(0.8)' + await wait(100) + fakeCursor.style.transform = 'scale(1)' + await wait(100) + + // 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, flowStateStore) @@ -180,25 +240,22 @@ getSteps={(driver) => { const steps: DriveStep[] = [ { - element: '#flow-editor-test-flow', + element: SELECTORS.testFlowButton, onHighlighted: async () => { - step1Complete = false + stepComplete[1] = false await wait(DELAY_SHORT) - step1Complete = true + stepComplete[1] = true }, popover: { title: 'Troubleshoot a broken flow', description: - 'This flow is intentionally broken. Let’s run it with an input of 25°C so you can see what needs to be fixed.', + 'This flow is intentionally broken. Let\'s run it with an input of 25°C so you can see what needs to be fixed.', side: 'bottom', onNextClick: async () => { - if (!step1Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } + if (!checkStepComplete(1)) return // Click the Test Flow button to open the drawer - const testFlowButton = document.querySelector('#flow-editor-test-flow') as HTMLElement + const testFlowButton = document.querySelector(SELECTORS.testFlowButton) as HTMLElement if (testFlowButton) { testFlowButton.click() await wait(DELAY_LONG) @@ -209,11 +266,11 @@ } }, { - element: '#flow-editor-test-flow-drawer', + element: SELECTORS.testFlowDrawer, onHighlighted: async () => { - step2Complete = false + stepComplete[2] = false await wait(DELAY_SHORT) - step2Complete = true + stepComplete[2] = true }, popover: { title: 'Run the flow', @@ -221,13 +278,10 @@ 'Click "Next" to execute the flow. We\'ll use the results to troubleshoot the error.', side: 'left', onNextClick: async () => { - if (!step2Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } + if (!checkStepComplete(2)) return // Click the Test button to execute the flow - const testButton = document.querySelector('#flow-editor-test-flow-drawer') as HTMLElement + const testButton = document.querySelector(SELECTORS.testFlowDrawer) as HTMLElement if (testButton) { testButton.click() } @@ -240,20 +294,17 @@ { element: '.border.rounded-md.shadow.p-2', onHighlighted: async () => { - step3Complete = false + stepComplete[3] = false await wait(DELAY_SHORT) - step3Complete = true + stepComplete[3] = true }, popover: { title: 'Review the error', description: - 'Our flow failed. Let’s review the error and understand what happened.', + 'Our flow failed. Let\'s review the error and understand what happened.', side: 'left', onNextClick: () => { - if (!step3Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } + if (!checkStepComplete(3)) return driver.moveNext() } } @@ -261,20 +312,17 @@ { element: '.border-b.flex.flex-row.whitespace-nowrap.scrollbar-hidden.mx-auto', onHighlighted: async () => { - step4Complete = false + stepComplete[4] = false await wait(DELAY_SHORT) - step4Complete = true + 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.', + '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 (!step4Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } + if (!checkStepComplete(4)) return driver.moveNext() } } @@ -282,17 +330,13 @@ { element: '.grid.grid-cols-3.border.h-full', onHighlighted: async () => { - step5Complete = false + stepComplete[5] = false await wait(DELAY_SHORT) // Find the step 'b' button inside the drawer and click it with fake cursor - const flowPreviewContent = document.getElementById('flow-preview-content') + const flowPreviewContent = document.getElementById(SELECTORS.flowPreviewContent.slice(1)) if (flowPreviewContent) { - // Find the button containing "Validate temperature input" text - const buttons = Array.from(flowPreviewContent.querySelectorAll('button')) - const stepButton = buttons.find(btn => - btn.textContent?.includes('Convert to Fahrenheit') - ) as HTMLElement + const stepButton = findButtonByText(flowPreviewContent, TEXT.convertToFahrenheit) if (stepButton) { // Create fake cursor and animate it to the button @@ -317,18 +361,15 @@ } } - step5Complete = true + 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.', + 'B step failed during the run. Let\'s take a closer look at its behavior.', side: 'top', onNextClick: () => { - if (!step5Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } + if (!checkStepComplete(5)) return driver.moveNext() } } @@ -336,46 +377,25 @@ { element: '.rounded-md.grow.bg-surface-tertiary.text-xs.flex.flex-col.max-h-screen.gap-2.overflow-hidden.border', onHighlighted: async () => { - step6Complete = false + stepComplete[6] = false await wait(DELAY_SHORT) - step6Complete = true + stepComplete[6] = true }, popover: { title: 'Error spotted!', description: - 'We made a typo in the code. Let’s fix it and run the flow again.', + 'We made a typo in the code. Let\'s fix it and run the flow again.', side: 'left', onNextClick: async () => { - if (!step6Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } + if (!checkStepComplete(6)) return // Click the close button inside the drawer - const drawer = document.getElementById('flow-preview-content') + const drawer = document.getElementById(SELECTORS.flowPreviewContent.slice(1)) if (drawer) { - const closeButton = Array.from(drawer.querySelectorAll('button')).find(btn => { - const svg = btn.querySelector('svg.lucide-x') - return svg !== null - }) as HTMLElement + const closeButton = findCloseButton(drawer) if (closeButton) { - // Create fake cursor and animate it to the close button - const fakeCursor = await createFakeCursor(null, closeButton, 1.5) - await wait(DELAY_SHORT) - - // Animate click (shrink cursor briefly) - fakeCursor.style.transform = 'scale(0.8)' - await wait(100) - fakeCursor.style.transform = 'scale(1)' - await wait(100) - - // Click the button - closeButton.click() - await wait(DELAY_SHORT) - - // Remove fake cursor - fakeCursor.remove() + await animateFakeCursorClick(closeButton, 1.5) } } @@ -385,33 +405,19 @@ } }, { - element: '#b', + element: SELECTORS.stepB, onHighlighted: async () => { - step7Complete = false + stepComplete[7] = false await wait(DELAY_SHORT) // Click on div id="b" to open the editor - const stepBDiv = document.getElementById('b') as HTMLElement + const stepBDiv = document.getElementById(SELECTORS.stepB.slice(1)) as HTMLElement if (stepBDiv) { - // Create fake cursor and animate it to the div - const fakeCursor = await createFakeCursor(null, stepBDiv, 1.5) - await wait(DELAY_MEDIUM) - - // Animate click (shrink cursor briefly) - fakeCursor.style.transform = 'scale(0.8)' - await wait(100) - fakeCursor.style.transform = 'scale(1)' - await wait(100) - - // Click the div - stepBDiv.click() + await animateFakeCursorClick(stepBDiv, 1.5) await wait(DELAY_LONG) - - // Remove fake cursor - fakeCursor.remove() } - step7Complete = true + stepComplete[7] = true }, popover: { title: 'Your turn now!', @@ -419,10 +425,7 @@ 'Fix the issue in the code, and run the flow again to confirm everything works.', side: 'top', onNextClick: () => { - if (!step7Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } + if (!checkStepComplete(7)) return driver.destroy() } } From 8740091dc60685dd21539282b367b905f26a6667 Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 12:13:47 +0100 Subject: [PATCH 075/137] Refactor --- .../tutorials/ExploreRunsTutorial.svelte | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte index 0ac1b4e0de67c..1dcd7fef02fd0 100644 --- a/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte +++ b/frontend/src/lib/components/tutorials/ExploreRunsTutorial.svelte @@ -7,7 +7,6 @@ import type { Flow } from '$lib/gen' import { wait, type StateStore } from '$lib/utils' import { sendUserToast } from '$lib/toast' - import { triggerPointerDown } from './utils' const { flowStore, flowStateStore } = getContext('FlowEditorContext') @@ -28,7 +27,10 @@ const DELAY_SHORT = 100 const DELAY_MEDIUM = 300 const DELAY_LONG = 500 - const DELAY_ANIMATION = 1500 + + // Constants for cursor animation + const CURSOR_START_OFFSET = -100 + const CURSOR_CLICK_SCALE = 0.8 // DOM Selectors const SELECTORS = { @@ -80,14 +82,14 @@ startX = startRect.left + startRect.width / 2 startY = startRect.top + startRect.height / 2 } else { - startX = endRect.left - 100 + startX = endRect.left + CURSOR_START_OFFSET startY = endRect.top + endRect.height / 2 } fakeCursor.style.left = `${startX}px` fakeCursor.style.top = `${startY}px` - await wait(100) + await wait(DELAY_SHORT) fakeCursor.style.left = `${endRect.left + endRect.width / 2}px` fakeCursor.style.top = `${endRect.top + endRect.height / 2}px` @@ -97,19 +99,36 @@ 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 + transitionDuration: number = 1.5, + options?: { usePointerEvents?: boolean } ): Promise { const fakeCursor = await createFakeCursor(null, element, transitionDuration) await wait(DELAY_MEDIUM) // Animate click (shrink cursor briefly) - fakeCursor.style.transform = 'scale(0.8)' - await wait(100) + fakeCursor.style.transform = `scale(${CURSOR_CLICK_SCALE})` + await wait(DELAY_SHORT) fakeCursor.style.transform = 'scale(1)' - await wait(100) + 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() @@ -334,29 +353,12 @@ await wait(DELAY_SHORT) // Find the step 'b' button inside the drawer and click it with fake cursor - const flowPreviewContent = document.getElementById(SELECTORS.flowPreviewContent.slice(1)) + const flowPreviewContent = getElementBySelector(SELECTORS.flowPreviewContent) if (flowPreviewContent) { const stepButton = findButtonByText(flowPreviewContent, TEXT.convertToFahrenheit) if (stepButton) { - // Create fake cursor and animate it to the button - const fakeCursor = await createFakeCursor(null, stepButton, 1.5) - await wait(DELAY_MEDIUM) - - // Animate click (shrink cursor briefly) - fakeCursor.style.transform = 'scale(0.8)' - await wait(100) - fakeCursor.style.transform = 'scale(1)' - await wait(100) - - // Trigger pointer events (flow graph uses pointer events instead of click) - stepButton.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) - stepButton.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) - stepButton.click() - await wait(DELAY_SHORT) - - // Remove fake cursor - fakeCursor.remove() + await animateFakeCursorClick(stepButton, 1.5, { usePointerEvents: true }) await wait(DELAY_MEDIUM) } } @@ -390,7 +392,7 @@ if (!checkStepComplete(6)) return // Click the close button inside the drawer - const drawer = document.getElementById(SELECTORS.flowPreviewContent.slice(1)) + const drawer = getElementBySelector(SELECTORS.flowPreviewContent) if (drawer) { const closeButton = findCloseButton(drawer) @@ -411,7 +413,7 @@ await wait(DELAY_SHORT) // Click on div id="b" to open the editor - const stepBDiv = document.getElementById(SELECTORS.stepB.slice(1)) as HTMLElement + const stepBDiv = getElementBySelector(SELECTORS.stepB) if (stepBDiv) { await animateFakeCursorClick(stepBDiv, 1.5) await wait(DELAY_LONG) From 04ffeb5f71a3491b8f54e437476ba26f55607cdc Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 12:21:46 +0100 Subject: [PATCH 076/137] Rename the tutorial --- frontend/src/lib/components/FlowTutorials.svelte | 10 +++++----- frontend/src/lib/components/RunPageTutorials.svelte | 10 +++++----- frontend/src/lib/components/home/NoItemFound.svelte | 4 ++-- .../lib/components/tutorials/RunPageTutorial.svelte | 2 +- ...Tutorial.svelte => TroubleshootFlowTutorial.svelte} | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) rename frontend/src/lib/components/tutorials/{ExploreRunsTutorial.svelte => TroubleshootFlowTutorial.svelte} (99%) diff --git a/frontend/src/lib/components/FlowTutorials.svelte b/frontend/src/lib/components/FlowTutorials.svelte index da8bc0fe3b388..f3f42059849e0 100644 --- a/frontend/src/lib/components/FlowTutorials.svelte +++ b/frontend/src/lib/components/FlowTutorials.svelte @@ -6,7 +6,7 @@ import FlowBuilderTutorialForLoop from './tutorials/FlowBuilderTutorialForLoop.svelte' import FlowBuilderTutorialErrorHandler from './tutorials/FlowBuilderTutorialErrorHandler.svelte' import FlowBuilderLiveTutorial from './tutorials/FlowBuilderLiveTutorial.svelte' - import ExploreRunsTutorial from './tutorials/ExploreRunsTutorial.svelte' + import TroubleshootFlowTutorial from './tutorials/TroubleshootFlowTutorial.svelte' let flowBuilderTutorialSimpleFlow: FlowBuilderTutorialSimpleFlow | undefined = $state(undefined) let flowBuilderTutorialForLoop: FlowBuilderTutorialForLoop | undefined = $state(undefined) @@ -16,7 +16,7 @@ $state(undefined) let flowBuilderLiveTutorial: FlowBuilderLiveTutorial | undefined = $state(undefined) - let exploreRunsTutorial: ExploreRunsTutorial | undefined = $state(undefined) + let troubleshootFlowTutorial: TroubleshootFlowTutorial | undefined = $state(undefined) export function runTutorialById(id: string, indexToInsertAt?: number | undefined) { if (id === 'forloop') { @@ -31,8 +31,8 @@ flowBuilderTutorialErrorHandler?.runTutorial() } else if (id === 'flow-live-tutorial') { flowBuilderLiveTutorial?.runTutorial() - } else if (id === 'explore-runs') { - exploreRunsTutorial?.runTutorial() + } else if (id === 'troubleshoot-flow') { + troubleshootFlowTutorial?.runTutorial() } } @@ -77,4 +77,4 @@ on:skipAll={skipAll} on:reload /> - + diff --git a/frontend/src/lib/components/RunPageTutorials.svelte b/frontend/src/lib/components/RunPageTutorials.svelte index c1aa9a720428e..49c74be3846f4 100644 --- a/frontend/src/lib/components/RunPageTutorials.svelte +++ b/frontend/src/lib/components/RunPageTutorials.svelte @@ -1,12 +1,12 @@ - + diff --git a/frontend/src/lib/components/home/NoItemFound.svelte b/frontend/src/lib/components/home/NoItemFound.svelte index 2ee5555d44915..22170d3343b56 100644 --- a/frontend/src/lib/components/home/NoItemFound.svelte +++ b/frontend/src/lib/components/home/NoItemFound.svelte @@ -22,7 +22,7 @@ function startRunsTutorial() { // Navigate to flow editor with pre-built flow for the runs tutorial - window.location.href = `${base}/flows/add?tutorial=explore-runs&nodraft=true` + window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow&nodraft=true` } @@ -70,7 +70,7 @@ >
-

Explore runs

+

Fix a broken flow

Learn how to monitor and debug your script and flow executions. diff --git a/frontend/src/lib/components/tutorials/RunPageTutorial.svelte b/frontend/src/lib/components/tutorials/RunPageTutorial.svelte index 34d817092b4aa..88b5707138785 100644 --- a/frontend/src/lib/components/tutorials/RunPageTutorial.svelte +++ b/frontend/src/lib/components/tutorials/RunPageTutorial.svelte @@ -232,7 +232,7 @@ Flow completed successfully in 1.25s Date: Tue, 2 Dec 2025 12:27:31 +0100 Subject: [PATCH 077/137] Remove deleted file --- .../tutorials/RunPageTutorial.svelte | 346 ------------------ 1 file changed, 346 deletions(-) delete mode 100644 frontend/src/lib/components/tutorials/RunPageTutorial.svelte diff --git a/frontend/src/lib/components/tutorials/RunPageTutorial.svelte b/frontend/src/lib/components/tutorials/RunPageTutorial.svelte deleted file mode 100644 index 88b5707138785..0000000000000 --- a/frontend/src/lib/components/tutorials/RunPageTutorial.svelte +++ /dev/null @@ -1,346 +0,0 @@ - - - { - const steps: DriveStep[] = [ - { - popover: { - title: 'Explore flow execution', - description: - "Let's explore a completed flow run and see what information is available about the execution.", - onNextClick: async () => { - driver.moveNext() - } - } - }, - { - element: '[data-testid="result-panel"]', - onHighlighted: async () => { - step1Complete = false - await wait(DELAY_MEDIUM) - step1Complete = true - }, - popover: { - title: 'View the result', - description: - 'This panel shows the final result of the flow. Our temperature converter returned the temperature in Fahrenheit with a category and emoji.', - side: 'left', - onNextClick: () => { - if (!step1Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } - driver.moveNext() - } - } - }, - { - element: '[data-testid="logs-tab"]', - onHighlighted: async () => { - step2Complete = false - - await wait(DELAY_MEDIUM) - - const logsTab = findTabButton('Logs') - if (logsTab) { - logsTab.click() - await wait(DELAY_LONG) - } - - step2Complete = true - }, - popover: { - title: 'Check the logs', - description: 'The logs show detailed execution information for each step of the flow.', - side: 'bottom', - onNextClick: () => { - if (!step2Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } - driver.moveNext() - } - } - }, - { - element: '[data-testid="flow-status-viewer"]', - onHighlighted: async () => { - step3Complete = false - await wait(DELAY_MEDIUM) - step3Complete = true - }, - popover: { - title: 'Flow visualization', - description: - 'This diagram shows all the steps in your flow. You can click on individual steps to see their specific results and logs.', - side: 'top', - onNextClick: () => { - if (!step3Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } - driver.moveNext() - } - } - }, - { - element: '[data-testid="job-metadata"]', - onHighlighted: async () => { - step4Complete = false - await wait(DELAY_MEDIUM) - step4Complete = true - }, - popover: { - title: 'Execution metadata', - description: - 'Here you can see important details like execution time, who ran it, when it started, and resource usage.', - side: 'left', - onNextClick: () => { - if (!step4Complete) { - sendUserToast('Please wait...', false, [], undefined, 3000) - return - } - driver.moveNext() - } - } - } - ] - - return steps - }} -/> From 47756ea01b65b27dcd19c875cc95a8a9c3ce3d76 Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 14:13:50 +0100 Subject: [PATCH 078/137] Improve wording --- .../lib/components/tutorials/TroubleshootFlowTutorial.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte b/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte index 2cc5dfe28e79c..e89bf7b9a0deb 100644 --- a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte +++ b/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte @@ -266,9 +266,9 @@ stepComplete[1] = true }, popover: { - title: 'Troubleshoot a broken flow', + title: '🛠️ Troubleshoot a broken flow', description: - 'This flow is intentionally broken. Let\'s run it with an input of 25°C so you can see what needs to be fixed.', + 'This flow is intentionally broken. Let\'s run it so you can see what needs to be fixed.', side: 'bottom', onNextClick: async () => { if (!checkStepComplete(1)) return From 1c970bb13a72e4002783e37bfd987328e203963a Mon Sep 17 00:00:00 2001 From: tristantr Date: Tue, 2 Dec 2025 14:17:53 +0100 Subject: [PATCH 079/137] Improve first step of troubleshooting flow tutorial --- .../tutorials/TroubleshootFlowTutorial.svelte | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte b/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte index e89bf7b9a0deb..9db16b008dc5e 100644 --- a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte +++ b/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte @@ -258,6 +258,16 @@ 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 () => { @@ -266,9 +276,9 @@ stepComplete[1] = true }, popover: { - title: '🛠️ Troubleshoot a broken flow', + title: 'Test our flow', description: - 'This flow is intentionally broken. Let\'s run it so you can see what needs to be fixed.', + 'Let\'s run it so you can see what needs to be fixed.', side: 'bottom', onNextClick: async () => { if (!checkStepComplete(1)) return From 7bb2bc0bb49815bf7e720d895bf0190ac01845e0 Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 10:25:08 +0100 Subject: [PATCH 080/137] Add tutorials to /tutorials page and create component --- .../lib/components/home/NoItemFound.svelte | 49 ++++++------------- .../lib/components/home/TutorialButton.svelte | 26 ++++++++++ .../(root)/(logged)/tutorials/+page.svelte | 47 +++++++++--------- 3 files changed, 65 insertions(+), 57 deletions(-) create mode 100644 frontend/src/lib/components/home/TutorialButton.svelte diff --git a/frontend/src/lib/components/home/NoItemFound.svelte b/frontend/src/lib/components/home/NoItemFound.svelte index 22170d3343b56..96df5f2d4ee28 100644 --- a/frontend/src/lib/components/home/NoItemFound.svelte +++ b/frontend/src/lib/components/home/NoItemFound.svelte @@ -3,6 +3,7 @@ import { base } from '$lib/base' import { getContext } from 'svelte' import type WorkspaceTutorials from '../WorkspaceTutorials.svelte' + import TutorialButton from './TutorialButton.svelte' interface Props { hasFilters?: boolean @@ -40,42 +41,24 @@

Get started with these tutorials
- - - + />
{/if} diff --git a/frontend/src/lib/components/home/TutorialButton.svelte b/frontend/src/lib/components/home/TutorialButton.svelte new file mode 100644 index 0000000000000..054fb353cc020 --- /dev/null +++ b/frontend/src/lib/components/home/TutorialButton.svelte @@ -0,0 +1,26 @@ + + + + diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 0c3ce91957be1..0761c7070227e 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -3,9 +3,10 @@ import PageHeader from '$lib/components/PageHeader.svelte' import { Tab } from '$lib/components/common' import Tabs from '$lib/components/common/tabs/Tabs.svelte' - import { BookOpen, Users, Workflow, GraduationCap } from 'lucide-svelte' + import { BookOpen, Users, Workflow, GraduationCap, Wrench } from 'lucide-svelte' import { base } from '$lib/base' import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte' + import TutorialButton from '$lib/components/home/TutorialButton.svelte' let tab: 'quickstart' | 'team' = $state('quickstart') @@ -18,6 +19,10 @@ function startFlowTutorial() { window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial&nodraft=true` } + + function startTroubleshootFlowTutorial() { + window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow&nodraft=true` + } @@ -35,31 +40,25 @@ {#if tab === 'quickstart'}
-
- - + /> +
{:else if tab === 'team'} From 9ff79233e5c4d5ad60e6204ebdeee5ed65691559 Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 11:06:31 +0100 Subject: [PATCH 081/137] Remove previous Flow tutorials --- .../src/lib/components/FlowBuilder.svelte | 12 - .../components/FlowBuilderTutorials.svelte | 82 ----- .../src/lib/components/FlowTutorials.svelte | 53 +--- .../flows/map/FlowModuleSchemaMap.svelte | 20 +- .../FlowBuilderTutorialBranchAll.svelte | 101 ------ .../FlowBuilderTutorialBranchOne.svelte | 121 -------- .../FlowBuilderTutorialErrorHandler.svelte | 136 -------- .../FlowBuilderTutorialForLoop.svelte | 279 ----------------- .../FlowBuilderTutorialSimpleFlow.svelte | 291 ------------------ 9 files changed, 3 insertions(+), 1092 deletions(-) delete mode 100644 frontend/src/lib/components/FlowBuilderTutorials.svelte delete mode 100644 frontend/src/lib/components/tutorials/FlowBuilderTutorialBranchAll.svelte delete mode 100644 frontend/src/lib/components/tutorials/FlowBuilderTutorialBranchOne.svelte delete mode 100644 frontend/src/lib/components/tutorials/FlowBuilderTutorialErrorHandler.svelte delete mode 100644 frontend/src/lib/components/tutorials/FlowBuilderTutorialForLoop.svelte delete mode 100644 frontend/src/lib/components/tutorials/FlowBuilderTutorialSimpleFlow.svelte diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 6367779894fce..5704805d66455 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -13,7 +13,6 @@ import { initHistory, redo, undo } from '$lib/history.svelte' import { enterpriseLicense, - tutorialsToDo, userStore, workspaceStore, usedTriggerKinds @@ -61,10 +60,8 @@ 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 Summary from './Summary.svelte' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' @@ -805,8 +802,6 @@ if (tutorial) { flowTutorials?.runTutorialById(tutorial) - } else if ($tutorialsToDo.includes(0) && !$ignoredTutorials.includes(0)) { - flowTutorials?.runTutorialById('action') } } @@ -1110,13 +1105,6 @@ {/if}
- {#if customUi?.topBar?.tutorials != false} - { - renderCount += 1 - }} - /> - {/if} {#if customUi?.topBar?.diff != false}
-
+
diff --git a/frontend/src/lib/components/home/TutorialButton.svelte b/frontend/src/lib/components/home/TutorialButton.svelte index 054fb353cc020..bfb1d8c493f2b 100644 --- a/frontend/src/lib/components/home/TutorialButton.svelte +++ b/frontend/src/lib/components/home/TutorialButton.svelte @@ -1,4 +1,5 @@ diff --git a/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte b/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte index 61ad0964b3631..ce6ce9b420c92 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte @@ -10,6 +10,7 @@ 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') let tutorial: Tutorial | undefined = undefined @@ -226,7 +227,7 @@ { - driver.moveNext() + updateProgress(2) + driver.destroy() }, onPrevClick: () => { sendUserToast('Previous is not available for this step', true, [], undefined, 3000) diff --git a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte b/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte index 9db16b008dc5e..8446ca9a1682f 100644 --- a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte +++ b/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte @@ -7,6 +7,7 @@ import type { Flow } from '$lib/gen' import { wait, type StateStore } from '$lib/utils' import { sendUserToast } from '$lib/toast' + import { updateProgress } from '$lib/tutorialUtils' const { flowStore, flowStateStore } = getContext('FlowEditorContext') @@ -251,7 +252,7 @@ { if (!checkStepComplete(7)) return + updateProgress(3) driver.destroy() } } diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte index 0db2d9426ffe5..249cf90e3118c 100644 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte @@ -21,7 +21,7 @@ { @@ -89,7 +89,7 @@ 'App

Build low-code applications with Windmill. That\'s it for the tour!

', onNextClick: async () => { // Mark tutorial as complete - updateProgress(8) + updateProgress(1) driver.destroy() // Clean up URL parameter if present diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 0761c7070227e..447df92e695aa 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -7,11 +7,32 @@ import { base } from '$lib/base' import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte' import TutorialButton from '$lib/components/home/TutorialButton.svelte' + import { tutorialsToDo } from '$lib/stores' + import { onMount } from 'svelte' + import { syncTutorialsTodos } from '$lib/tutorialUtils' let tab: 'quickstart' | 'team' = $state('quickstart') let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined) + // Tutorial index mapping + const TUTORIAL_INDEXES = { + 'workspace-onboarding': 1, + 'flow-live-tutorial': 2, + 'troubleshoot-flow': 3 + } as const + + // Sync tutorial progress on mount + onMount(async () => { + await syncTutorialsTodos() + }) + + // Check if a tutorial is completed + function isTutorialCompleted(tutorialId: keyof typeof TUTORIAL_INDEXES): boolean { + const index = TUTORIAL_INDEXES[tutorialId] + return !$tutorialsToDo.includes(index) + } + function startWorkspaceOnboarding() { workspaceTutorials?.runTutorialById('workspace-onboarding') } @@ -40,24 +61,27 @@ {#if tab === 'quickstart'}
-
+
From 84a90ab4452044dbafd64d6f5338b8f90a7f8c7f Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 12:08:20 +0100 Subject: [PATCH 083/137] Improve status in Tutorial button --- .../lib/components/home/TutorialButton.svelte | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/components/home/TutorialButton.svelte b/frontend/src/lib/components/home/TutorialButton.svelte index bfb1d8c493f2b..912a003bcf8bb 100644 --- a/frontend/src/lib/components/home/TutorialButton.svelte +++ b/frontend/src/lib/components/home/TutorialButton.svelte @@ -1,9 +1,9 @@ diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte index 249cf90e3118c..a655cf22032f5 100644 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte @@ -24,6 +24,7 @@ index={1} name="workspace-onboarding" tainted={false} + on:skipAll getSteps={(driver) => { const steps: DriveStep[] = [ { From c23d814ed1daf607c60f10db8f615344a3c5fe24 Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 15:01:50 +0100 Subject: [PATCH 086/137] Add skipped_all to tutorial_progress --- ..._skipped_all_to_tutorial_progress.down.sql | 3 ++ ...dd_skipped_all_to_tutorial_progress.up.sql | 5 ++++ backend/windmill-api/src/users.rs | 29 +++++++++++++------ 3 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/20251203131741_add_skipped_all_to_tutorial_progress.down.sql create mode 100644 backend/migrations/20251203131741_add_skipped_all_to_tutorial_progress.up.sql diff --git a/backend/migrations/20251203131741_add_skipped_all_to_tutorial_progress.down.sql b/backend/migrations/20251203131741_add_skipped_all_to_tutorial_progress.down.sql new file mode 100644 index 0000000000000..fb6b0b2ffea0a --- /dev/null +++ b/backend/migrations/20251203131741_add_skipped_all_to_tutorial_progress.down.sql @@ -0,0 +1,3 @@ +-- Remove skipped_all column from tutorial_progress table +ALTER TABLE tutorial_progress +DROP COLUMN skipped_all; diff --git a/backend/migrations/20251203131741_add_skipped_all_to_tutorial_progress.up.sql b/backend/migrations/20251203131741_add_skipped_all_to_tutorial_progress.up.sql new file mode 100644 index 0000000000000..55a26221b3446 --- /dev/null +++ b/backend/migrations/20251203131741_add_skipped_all_to_tutorial_progress.up.sql @@ -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)'; diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 9021d6fe8e151..d91f99abc670b 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -562,20 +562,30 @@ async fn list_users_as_super_admin( #[derive(Serialize, Deserialize)] struct Progress { progress: u64, + skipped_all: Option, } async fn get_tutorial_progress( authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult { - 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: Some(row.skipped_all), + })) + } else { + Ok(Json(Progress { + progress: 0, + skipped_all: Some(false), + })) + } } async fn update_tutorial_progress( @@ -583,10 +593,11 @@ async fn update_tutorial_progress( Extension(db): Extension, Json(progress): Json, ) -> Result { - 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.unwrap_or(false) ) .execute(&db) .await?; From 3b3df19fb8af87edde8bf16ef8aa6a531e3e068d Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 15:14:11 +0100 Subject: [PATCH 087/137] Connect backend and frontend for tutorial progress --- backend/windmill-api/openapi.yaml | 4 ++++ backend/windmill-api/src/users.rs | 8 ++++---- frontend/src/lib/tutorialUtils.ts | 6 +++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index afcd9c896d2f1..73064657cb3b5 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1326,6 +1326,8 @@ paths: properties: progress: type: integer + skipped_all: + type: boolean post: summary: update tutorial progress operationId: updateTutorialProgress @@ -1341,6 +1343,8 @@ paths: properties: progress: type: integer + skipped_all: + type: boolean responses: "200": description: tutorial progress diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index d91f99abc670b..7ed3fe13f925e 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -562,7 +562,7 @@ async fn list_users_as_super_admin( #[derive(Serialize, Deserialize)] struct Progress { progress: u64, - skipped_all: Option, + skipped_all: bool, } async fn get_tutorial_progress( authed: ApiAuthed, @@ -578,12 +578,12 @@ async fn get_tutorial_progress( if let Some(row) = row { Ok(Json(Progress { progress: row.progress.unwrap_or_default() as u64, - skipped_all: Some(row.skipped_all), + skipped_all: row.skipped_all, })) } else { Ok(Json(Progress { progress: 0, - skipped_all: Some(false), + skipped_all: false, })) } } @@ -597,7 +597,7 @@ async fn update_tutorial_progress( "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, - progress.skipped_all.unwrap_or(false) + progress.skipped_all ) .execute(&db) .await?; diff --git a/frontend/src/lib/tutorialUtils.ts b/frontend/src/lib/tutorialUtils.ts index 80ea5bfc7d653..5ca0386e75f7c 100644 --- a/frontend/src/lib/tutorialUtils.ts +++ b/frontend/src/lib/tutorialUtils.ts @@ -15,7 +15,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 +25,7 @@ export async function skipAllTodos() { bits = bits | mask } tutorialsToDo.set([]) - await UserService.updateTutorialProgress({ requestBody: { progress: bits } }) + await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } }) } export async function resetAllTodos() { @@ -35,7 +35,7 @@ export async function resetAllTodos() { } tutorialsToDo.set(todos) - await UserService.updateTutorialProgress({ requestBody: { progress: 0 } }) + await UserService.updateTutorialProgress({ requestBody: { progress: 0, skipped_all: false } }) } export async function syncTutorialsTodos() { From 998e3d40414617ce118b2097fd96257c56af766c Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 15:27:13 +0100 Subject: [PATCH 088/137] Add store and helper to display or not Tutorials from left menu --- .../components/sidebar/SidebarContent.svelte | 23 ++++++++++++++++--- frontend/src/lib/stores.ts | 1 + frontend/src/lib/tutorialUtils.ts | 21 +++++++++++++++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index 8b91d468ecbf1..c2e5f436bb3af 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -7,8 +7,11 @@ workspaceStore, isCriticalAlertsUIOpen, enterpriseLicense, - devopsRole + devopsRole, + tutorialsToDo, + skippedAll } from '$lib/stores' + import { syncTutorialsTodos, shouldHideTutorialsFromMainMenu } from '$lib/tutorialUtils' import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts' import { BookOpen, @@ -84,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) @@ -93,6 +96,8 @@ } else { recentChangelogs = changelogs.slice(0, 3) } + // Sync tutorial progress on mount + await syncTutorialsTodos() }) function openChangelogs() { @@ -215,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([ { diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 5632bff47ab16..4d49133895173 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -55,6 +55,7 @@ export function clearWorkspaceFromStorage() { } export const tutorialsToDo = writable([]) +export const skippedAll = writable(false) export const globalEmailInvite = writable('') export const awarenessStore = writable>(undefined) export const enterpriseLicense = writable(undefined) diff --git a/frontend/src/lib/tutorialUtils.ts b/frontend/src/lib/tutorialUtils.ts index 5ca0386e75f7c..e0afe6b2108ec 100644 --- a/frontend/src/lib/tutorialUtils.ts +++ b/frontend/src/lib/tutorialUtils.ts @@ -1,5 +1,5 @@ import { get } from 'svelte/store' -import { tutorialsToDo } from './stores' +import { tutorialsToDo, skippedAll } from './stores' import { UserService } from './gen' const MAX_TUTORIAL_ID = 7 @@ -8,6 +8,7 @@ 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 @@ -25,6 +26,7 @@ export async function skipAllTodos() { bits = bits | mask } tutorialsToDo.set([]) + skippedAll.set(true) await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } }) } @@ -34,12 +36,15 @@ export async function resetAllTodos() { todos.push(i) } tutorialsToDo.set(todos) + skippedAll.set(false) await UserService.updateTutorialProgress({ requestBody: { progress: 0, skipped_all: false } }) } 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 +53,7 @@ export async function syncTutorialsTodos() { } } tutorialsToDo.set(todos) + skippedAll.set(skipped) } export function tutorialInProgress() { @@ -55,3 +61,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 +} From 699ddfb13095a2f508f39ba1e0b14b653afb63cc Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 15:34:52 +0100 Subject: [PATCH 089/137] Add reminder at the end of each tutorial --- .../src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte | 2 +- .../lib/components/tutorials/TroubleshootFlowTutorial.svelte | 2 +- .../tutorials/workspace/WorkspaceOnboardingTutorial.svelte | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte b/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte index ce6ce9b420c92..5ce50f971fd70 100644 --- a/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte +++ b/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte @@ -718,7 +718,7 @@ element: '#flow-editor-test-flow', popover: { title: 'Ready to test!', - description: 'Run the complete flow and see your temperature converter in action.', + description: 'Run the complete flow and see your temperature converter in action.

💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

', onNextClick: () => { updateProgress(2) driver.destroy() diff --git a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte b/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte index 8446ca9a1682f..e40abce6e9958 100644 --- a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte +++ b/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte @@ -435,7 +435,7 @@ popover: { title: 'Your turn now!', description: - 'Fix the issue in the code, and run the flow again to confirm everything works.', + 'Fix the issue in the code, and run the flow again to confirm everything works.

💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

', side: 'top', onNextClick: () => { if (!checkStepComplete(7)) return diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte index a655cf22032f5..2b82ab9e2d50a 100644 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte @@ -87,7 +87,7 @@ popover: { title: 'Create your first app', description: - 'App

Build low-code applications with Windmill. That\'s it for the tour!

', + 'App

Build low-code applications with Windmill. That\'s it for the tour!

💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

', onNextClick: async () => { // Mark tutorial as complete updateProgress(1) From 16a398c380fd228169a264370fce7ec1398b2d98 Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 16:22:09 +0100 Subject: [PATCH 090/137] Add tutorial banner --- .../lib/components/home/TutorialBanner.svelte | 65 +++++++++++++++++++ .../src/routes/(root)/(logged)/+page.svelte | 5 ++ 2 files changed, 70 insertions(+) create mode 100644 frontend/src/lib/components/home/TutorialBanner.svelte diff --git a/frontend/src/lib/components/home/TutorialBanner.svelte b/frontend/src/lib/components/home/TutorialBanner.svelte new file mode 100644 index 0000000000000..168ff563bdc81 --- /dev/null +++ b/frontend/src/lib/components/home/TutorialBanner.svelte @@ -0,0 +1,65 @@ + + +{#if !isDismissed} + +
+
+
+ +
+
+

Learn with interactive tutorials

+

+ Get started quickly with step-by-step guides on building flows, scripts, and more. +

+
+
+
+ + +
+
+{/if} + diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index fb83ec6bd3072..ed0353e16eaf9 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -39,6 +39,7 @@ 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' @@ -273,6 +274,10 @@ {/if} + {#if !$userStore?.operator} + + {/if} + {#if !$userStore?.operator}
From cb14b5beb64bffd3c92fb837c05c74a03a1e7d42 Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 16:55:57 +0100 Subject: [PATCH 091/137] Remove tutorials from elpty workspace --- .../lib/components/home/NoItemFound.svelte | 74 ++----------------- 1 file changed, 5 insertions(+), 69 deletions(-) diff --git a/frontend/src/lib/components/home/NoItemFound.svelte b/frontend/src/lib/components/home/NoItemFound.svelte index f289d601e8ec2..75778a46370df 100644 --- a/frontend/src/lib/components/home/NoItemFound.svelte +++ b/frontend/src/lib/components/home/NoItemFound.svelte @@ -1,51 +1,9 @@ {#if hasFilters} @@ -56,33 +14,11 @@
{:else} -
-
-
Welcome to Windmill!
-
Get started with these tutorials
-
-
- - - +
+
+
Welcome to Windmill
+
Get started by creating your first script, flow, or app
{/if} + From bd4f543403157deabd8d547a1b67a58baa353877 Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 17:01:12 +0100 Subject: [PATCH 092/137] Improve Tutorials page --- .../lib/components/home/TutorialButton.svelte | 20 ++++++++----------- .../(root)/(logged)/tutorials/+page.svelte | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/home/TutorialButton.svelte b/frontend/src/lib/components/home/TutorialButton.svelte index 14f314ebdb5fc..f9ee4410dae7e 100644 --- a/frontend/src/lib/components/home/TutorialButton.svelte +++ b/frontend/src/lib/components/home/TutorialButton.svelte @@ -15,23 +15,19 @@ +
From 66a75ac237828959b49e453e0d8e45932d0f8f88 Mon Sep 17 00:00:00 2001 From: tristantr Date: Wed, 3 Dec 2025 18:13:41 +0100 Subject: [PATCH 095/137] Refactor --- .../tutorials/TutorialProgressBar.svelte | 29 +++++++++++ frontend/src/lib/tutorialUtils.ts | 20 +++++++ .../(root)/(logged)/tutorials/+page.svelte | 52 +++++++++++++++++-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/components/tutorials/TutorialProgressBar.svelte diff --git a/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte b/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte new file mode 100644 index 0000000000000..2a7d204ab9302 --- /dev/null +++ b/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte @@ -0,0 +1,29 @@ + + +
+
+
+ Progress: {completed} of {total} {label} completed +
+
{progressPercentage}%
+
+
+
+
+
+ diff --git a/frontend/src/lib/tutorialUtils.ts b/frontend/src/lib/tutorialUtils.ts index e0afe6b2108ec..a6cafa60ae10d 100644 --- a/frontend/src/lib/tutorialUtils.ts +++ b/frontend/src/lib/tutorialUtils.ts @@ -4,6 +4,26 @@ import { UserService } from './gen' const MAX_TUTORIAL_ID = 7 +/** + * 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): 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, + 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) diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index e714773fd44cb..ffcda74412828 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -7,9 +7,16 @@ import { base } from '$lib/base' import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.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 { syncTutorialsTodos, resetAllTodos } from '$lib/tutorialUtils' + import { afterNavigate } from '$app/navigation' + import { + syncTutorialsTodos, + resetAllTodos, + getTutorialProgressTotal, + getTutorialProgressCompleted + } from '$lib/tutorialUtils' import { Button } from '$lib/components/common' import { RefreshCw } from 'lucide-svelte' @@ -24,9 +31,40 @@ 'troubleshoot-flow': 3 } as const - // Sync tutorial progress on mount - onMount(async () => { - await syncTutorialsTodos() + // Calculate progress for quickstart tutorials + const totalQuickstartTutorials = getTutorialProgressTotal(TUTORIAL_INDEXES) + const completedQuickstartTutorials = $derived( + getTutorialProgressCompleted(TUTORIAL_INDEXES, $tutorialsToDo) + ) + + // 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 @@ -75,6 +113,12 @@ {#if tab === 'quickstart'}
+ +
Date: Thu, 4 Dec 2025 10:13:35 +0100 Subject: [PATCH 096/137] Refactor to make it easy to add new tutorials and tabs --- frontend/src/lib/tutorials/config.ts | 61 ++++++++++++ .../(root)/(logged)/tutorials/+page.svelte | 94 +++++++++---------- 2 files changed, 106 insertions(+), 49 deletions(-) create mode 100644 frontend/src/lib/tutorials/config.ts diff --git a/frontend/src/lib/tutorials/config.ts b/frontend/src/lib/tutorials/config.ts new file mode 100644 index 0000000000000..38cce7b275e67 --- /dev/null +++ b/frontend/src/lib/tutorials/config.ts @@ -0,0 +1,61 @@ +import type { ComponentType } from 'svelte' +import { BookOpen, Users, Workflow, GraduationCap, Wrench } from 'lucide-svelte' +import { base } from '$lib/base' + +export interface TutorialConfig { + id: string + icon: ComponentType + title: string + description: string + onClick: () => void +} + +export interface TabConfig { + label: string + icon: ComponentType + tutorials: TutorialConfig[] +} + +export type TabId = 'quickstart' | 'team' + +export const TUTORIALS_CONFIG: Record = { + quickstart: { + label: 'Quickstart', + icon: BookOpen, + 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` + } + }, + { + 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` + } + }, + { + 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` + } + } + ] + }, + team: { + label: 'Team Collaboration', + icon: Users, + tutorials: [] + } +} as const + diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index ffcda74412828..0a32eb450a06a 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -3,8 +3,6 @@ import PageHeader from '$lib/components/PageHeader.svelte' import { Tab } from '$lib/components/common' import Tabs from '$lib/components/common/tabs/Tabs.svelte' - import { BookOpen, Users, Workflow, GraduationCap, Wrench } from 'lucide-svelte' - import { base } from '$lib/base' import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte' import TutorialButton from '$lib/components/home/TutorialButton.svelte' import TutorialProgressBar from '$lib/components/tutorials/TutorialProgressBar.svelte' @@ -19,8 +17,9 @@ } from '$lib/tutorialUtils' import { Button } from '$lib/components/common' import { RefreshCw } from 'lucide-svelte' + import { TUTORIALS_CONFIG, type TabId } from '$lib/tutorials/config' - let tab: 'quickstart' | 'team' = $state('quickstart') + let tab: TabId = $state('quickstart') let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined) @@ -31,12 +30,27 @@ 'troubleshoot-flow': 3 } as const - // Calculate progress for quickstart tutorials - const totalQuickstartTutorials = getTutorialProgressTotal(TUTORIAL_INDEXES) - const completedQuickstartTutorials = $derived( - getTutorialProgressCompleted(TUTORIAL_INDEXES, $tutorialsToDo) + // Get current tab configuration + const currentTabConfig = $derived(TUTORIALS_CONFIG[tab]) + + // Create tutorial index mapping for current tab + const currentTabTutorialIndexes = $derived( + Object.fromEntries( + currentTabConfig.tutorials + .filter((tutorial) => tutorial.id in TUTORIAL_INDEXES) + .map((tutorial) => [tutorial.id, TUTORIAL_INDEXES[tutorial.id as keyof typeof TUTORIAL_INDEXES]]) + ) + ) + + // Calculate progress for current tab + const totalTutorials = $derived(getTutorialProgressTotal(currentTabTutorialIndexes)) + const completedTutorials = $derived( + getTutorialProgressCompleted(currentTabTutorialIndexes, $tutorialsToDo) ) + // Tutorials are ready to use directly from config + const tutorials = $derived(currentTabConfig.tutorials) + // Sync tutorial progress on mount and when navigating to this page onMount(() => { // Initial sync @@ -68,22 +82,11 @@ }) // Check if a tutorial is completed - function isTutorialCompleted(tutorialId: keyof typeof TUTORIAL_INDEXES): boolean { - const index = TUTORIAL_INDEXES[tutorialId] + function isTutorialCompleted(tutorialId: string): boolean { + const index = TUTORIAL_INDEXES[tutorialId as keyof typeof TUTORIAL_INDEXES] + if (index === undefined) return false return !$tutorialsToDo.includes(index) } - - function startWorkspaceOnboarding() { - workspaceTutorials?.runTutorialById('workspace-onboarding') - } - - function startFlowTutorial() { - window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial&nodraft=true` - } - - function startTroubleshootFlowTutorial() { - window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow&nodraft=true` - } @@ -106,45 +109,38 @@
- - + {#each Object.entries(TUTORIALS_CONFIG) as [tabId, config]} + + {/each}
- {#if tab === 'quickstart'} + {#if currentTabConfig && currentTabConfig.tutorials.length > 0}
- - - + {#each tutorials as tutorial} + + {/each} +
+
+ {:else if currentTabConfig} +
+
+ No tutorials available for this section yet.
- {:else if tab === 'team'} - {/if}
From c726489f87cc27b1da7ec2f2bc46780e18e137a6 Mon Sep 17 00:00:00 2001 From: tristantr Date: Thu, 4 Dec 2025 11:02:04 +0100 Subject: [PATCH 097/137] Improve tutorial config to make it easy to add new tutorials --- .../src/lib/components/FlowTutorials.svelte | 7 ++- .../lib/components/home/TutorialButton.svelte | 26 ++++++++-- frontend/src/lib/tutorials/config.ts | 23 +++++++-- .../(root)/(logged)/tutorials/+page.svelte | 47 +++++++++++-------- 4 files changed, 75 insertions(+), 28 deletions(-) diff --git a/frontend/src/lib/components/FlowTutorials.svelte b/frontend/src/lib/components/FlowTutorials.svelte index 7575a05169606..209e757161561 100644 --- a/frontend/src/lib/components/FlowTutorials.svelte +++ b/frontend/src/lib/components/FlowTutorials.svelte @@ -26,4 +26,9 @@ on:skipAll={skipAll} on:reload /> - + \ No newline at end of file diff --git a/frontend/src/lib/components/home/TutorialButton.svelte b/frontend/src/lib/components/home/TutorialButton.svelte index f9ee4410dae7e..93446996633bd 100644 --- a/frontend/src/lib/components/home/TutorialButton.svelte +++ b/frontend/src/lib/components/home/TutorialButton.svelte @@ -8,22 +8,40 @@ description: string onclick: () => void isCompleted?: boolean + disabled?: boolean + comingSoon?: boolean } - let { icon: Icon, title, description, onclick, isCompleted = false }: Props = $props() + let { + icon: Icon, + title, + description, + onclick, + isCompleted = false, + disabled = false, + comingSoon = false + }: Props = $props() +
+ + +
From 97c59437f303bd5876ae5f94c83b31d7c49b8e23 Mon Sep 17 00:00:00 2001 From: tristantr Date: Thu, 4 Dec 2025 15:03:32 +0100 Subject: [PATCH 102/137] Add skip tutorial button in banner toast --- .../lib/components/home/TutorialBanner.svelte | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/home/TutorialBanner.svelte b/frontend/src/lib/components/home/TutorialBanner.svelte index 57c752fbfa813..44fe0e7cb7baa 100644 --- a/frontend/src/lib/components/home/TutorialBanner.svelte +++ b/frontend/src/lib/components/home/TutorialBanner.svelte @@ -3,8 +3,9 @@ import { GraduationCap, X } from 'lucide-svelte' import { base } from '$lib/base' import { goto } from '$app/navigation' - import { sendUserToast } from '$lib/toast' + 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' @@ -15,15 +16,31 @@ 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, - 5000 + 8000 ) } From 2a9cf69e688d9ec7c2598a708fd44895f219106d Mon Sep 17 00:00:00 2001 From: tristantr Date: Thu, 4 Dec 2025 15:22:46 +0100 Subject: [PATCH 103/137] Replace if else in tutorials router by map to make it easier to maintain and scale --- .../src/lib/components/AppTutorials.svelte | 34 ++++++++++++++----- .../src/lib/components/FlowTutorials.svelte | 19 ++++++++--- .../lib/components/WorkspaceTutorials.svelte | 16 +++++++-- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/AppTutorials.svelte b/frontend/src/lib/components/AppTutorials.svelte index f32c613bb33db..16fd869d9c84b 100644 --- a/frontend/src/lib/components/AppTutorials.svelte +++ b/frontend/src/lib/components/AppTutorials.svelte @@ -4,17 +4,33 @@ 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 backgroundRunnablesTutorial: BackgroundRunnablesTutorial | undefined = $state(undefined) + let connectionTutorial: ConnectionTutorial | undefined = $state(undefined) + let appTutorial: AppTutorial | undefined = $state(undefined) + + // Map tutorial IDs to their component instances + const tutorialInstances = new Map< + string, + { runTutorial: (options?: number) => void } | undefined + >() + + // Update map when instances change + $effect(() => { + tutorialInstances.set('backgroundrunnables', backgroundRunnablesTutorial) + tutorialInstances.set('connection', connectionTutorial) + tutorialInstances.set('simpleapptutorial', appTutorial) + }) 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() + const instance = tutorialInstances.get(id) + if (instance) { + if (options?.skipStepsCount !== undefined) { + instance.runTutorial(options.skipStepsCount) + } else { + instance.runTutorial() + } + } else { + console.warn(`Tutorial instance not found for id: ${id}`) } } diff --git a/frontend/src/lib/components/FlowTutorials.svelte b/frontend/src/lib/components/FlowTutorials.svelte index 3748388a97e94..475ccee492bd1 100644 --- a/frontend/src/lib/components/FlowTutorials.svelte +++ b/frontend/src/lib/components/FlowTutorials.svelte @@ -7,18 +7,27 @@ let flowBuilderLiveTutorial: FlowBuilderLiveTutorial | undefined = $state(undefined) let troubleshootFlowTutorial: TroubleshootFlowTutorial | undefined = $state(undefined) + // Map tutorial IDs to their component instances + const tutorialInstances = new Map void } | undefined>() + + // Update map when instances change + $effect(() => { + tutorialInstances.set('flow-live-tutorial', flowBuilderLiveTutorial) + tutorialInstances.set('troubleshoot-flow', troubleshootFlowTutorial) + }) + export function runTutorialById(id: string) { - if (id === 'flow-live-tutorial') { - flowBuilderLiveTutorial?.runTutorial() - } else if (id === 'troubleshoot-flow') { - troubleshootFlowTutorial?.runTutorial() + const instance = tutorialInstances.get(id) + if (instance) { + instance.runTutorial() + } else { + console.warn(`Tutorial instance not found for id: ${id}`) } } function skipAll() { skipAllTodos() } - void } | undefined>() + + // Update map when instance changes + $effect(() => { + tutorialInstances.set('workspace-onboarding', workspaceOnboardingTutorial) + }) + export function runTutorialById(id: string) { - if (id === 'workspace-onboarding') { - workspaceOnboardingTutorial?.runTutorial() + const instance = tutorialInstances.get(id) + if (instance) { + instance.runTutorial() + } else { + console.warn(`Tutorial instance not found for id: ${id}`) } } function skipAll() { skipAllTodos() } - Date: Thu, 4 Dec 2025 15:30:03 +0100 Subject: [PATCH 104/137] Delete broken simple app tutorial --- .../tutorials/app/AppTutorial.svelte | 267 ------------------ 1 file changed, 267 deletions(-) delete mode 100644 frontend/src/lib/components/tutorials/app/AppTutorial.svelte diff --git a/frontend/src/lib/components/tutorials/app/AppTutorial.svelte b/frontend/src/lib/components/tutorials/app/AppTutorial.svelte deleted file mode 100644 index 1b52905525dd0..0000000000000 --- a/frontend/src/lib/components/tutorials/app/AppTutorial.svelte +++ /dev/null @@ -1,267 +0,0 @@ - - - { - 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 - }} -/> From fbc1a08de9ea1c15c6701aa2a85972ccc3411cdd Mon Sep 17 00:00:00 2001 From: tristantr Date: Thu, 4 Dec 2025 15:40:07 +0100 Subject: [PATCH 105/137] Add Guide flow guide buttons inside the Create Flow page --- .../flows/FlowEditorTutorial.svelte | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 frontend/src/lib/components/flows/FlowEditorTutorial.svelte diff --git a/frontend/src/lib/components/flows/FlowEditorTutorial.svelte b/frontend/src/lib/components/flows/FlowEditorTutorial.svelte new file mode 100644 index 0000000000000..ca1972914e527 --- /dev/null +++ b/frontend/src/lib/components/flows/FlowEditorTutorial.svelte @@ -0,0 +1,62 @@ + + +{#key $tutorialsToDo} + + {#snippet buttonReplacement()} +
+ {#if customUi?.topBar?.diff != false}
+ + +
+ +
+
{#each Object.entries(TUTORIALS_CONFIG) as [tabId, config]} From 2a62cd99074e640cad66f2d633cb31549d5ac40b Mon Sep 17 00:00:00 2001 From: tristantr Date: Thu, 4 Dec 2025 17:05:26 +0100 Subject: [PATCH 110/137] Add Reset & Skip at tutorial category level --- .../tutorials/TutorialProgressBar.svelte | 8 +- frontend/src/lib/tutorialUtils.ts | 55 ++++++++++++ .../(root)/(logged)/tutorials/+page.svelte | 88 ++++++++++--------- 3 files changed, 106 insertions(+), 45 deletions(-) diff --git a/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte b/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte index 2a7d204ab9302..5084299a7e06d 100644 --- a/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte +++ b/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte @@ -12,12 +12,12 @@ ) -
-
-
+
+
+
Progress: {completed} of {total} {label} completed
-
{progressPercentage}%
+
{progressPercentage}%
!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 + } + }) +} + export async function syncTutorialsTodos() { const response = await UserService.getTutorialProgress() const bits: number = response.progress! diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 1ddaeb3f692d5..26567f069ae16 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -13,7 +13,9 @@ resetAllTodos, getTutorialProgressTotal, getTutorialProgressCompleted, - skipAllTodos + skipAllTodos, + skipTutorialsByIndexes, + resetTutorialsByIndexes } from '$lib/tutorialUtils' import { Button } from '$lib/components/common' import { RefreshCw, CheckCheck } from 'lucide-svelte' @@ -40,32 +42,6 @@ getTutorialProgressCompleted(currentTabTutorialIndexes, $tutorialsToDo) ) - // Calculate overall progress across all tabs - const allTutorialIndexes = $derived.by(() => { - const indexes: Record = {} - const user = $userStore - - for (const tabConfig of Object.values(TUTORIALS_CONFIG)) { - for (const tutorial of tabConfig.tutorials) { - if (tutorial.disabled || tutorial.index === undefined) continue - if (tutorial.requiredRole && user) { - const { requiredRole } = tutorial - if (requiredRole === 'admin' && !user.is_admin && !user.is_super_admin) continue - if (requiredRole === 'operator' && !user.operator && !user.is_admin && !user.is_super_admin) continue - if (requiredRole === 'developer' && user.operator && !user.is_admin && !user.is_super_admin) continue - } - indexes[tutorial.id] = tutorial.index - } - } - return indexes - }) - - // Calculate overall progress - const overallTotal = $derived(getTutorialProgressTotal(allTutorialIndexes)) - const overallCompleted = $derived( - getTutorialProgressCompleted(allTutorialIndexes, $tutorialsToDo) - ) - // Filter and sort tutorials based on props const tutorials = $derived( currentTabConfig.tutorials @@ -121,6 +97,25 @@ 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 + await skipTutorialsByIndexes(currentTabIndexes) + await syncTutorialsTodos() + } + + // Reset all tutorials in current tab + async function resetCurrentTabTutorials() { + if (currentTabIndexes.length === 0) return + await resetTutorialsByIndexes(currentTabIndexes) + await syncTutorialsTodos() + } @@ -154,15 +149,6 @@
- - -
- -
@@ -174,11 +160,31 @@ {#if currentTabConfig && currentTabConfig.tutorials.length > 0}
- +
+ +
+ + +
+
{#each tutorials as tutorial} From e7bcde14f3e4d70da95b7dbea6baa150ec2828c6 Mon Sep 17 00:00:00 2001 From: tristantr Date: Thu, 4 Dec 2025 17:42:47 +0100 Subject: [PATCH 111/137] Add progress to tab title --- frontend/src/lib/tutorials/config.ts | 7 +-- .../(root)/(logged)/tutorials/+page.svelte | 59 ++++++++++++++++++- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/tutorials/config.ts b/frontend/src/lib/tutorials/config.ts index d1cc68aefe25e..90898364d4313 100644 --- a/frontend/src/lib/tutorials/config.ts +++ b/frontend/src/lib/tutorials/config.ts @@ -1,5 +1,5 @@ import type { ComponentType } from 'svelte' -import { BookOpen, Workflow, GraduationCap, Wrench, PlayCircle, Link2, PenTool } from 'lucide-svelte' +import { Workflow, GraduationCap, Wrench, PlayCircle, Link2 } from 'lucide-svelte' import { base } from '$lib/base' export interface TutorialConfig { @@ -17,11 +17,10 @@ export interface TutorialConfig { export interface TabConfig { label: string - icon: ComponentType tutorials: TutorialConfig[] } -export type TabId = 'quickstart' | 'app_editor' +export type TabId = 'quickstart' | 'app_editor' /** * Get tutorial index from config by tutorial ID. @@ -38,7 +37,6 @@ export function getTutorialIndex(id: string): number { export const TUTORIALS_CONFIG: Record = { quickstart: { label: 'Quickstart', - icon: BookOpen, tutorials: [ { id: 'workspace-onboarding', @@ -83,7 +81,6 @@ export const TUTORIALS_CONFIG: Record = { }, app_editor: { label: 'App Editor', - icon: PenTool, tutorials: [ { id: 'backgroundrunnables', diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 26567f069ae16..6613d3ce68245 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -18,7 +18,7 @@ resetTutorialsByIndexes } from '$lib/tutorialUtils' import { Button } from '$lib/components/common' - import { RefreshCw, CheckCheck } from 'lucide-svelte' + import { RefreshCw, CheckCheck, CheckCircle2, Circle } from 'lucide-svelte' import { TUTORIALS_CONFIG, type TabId } from '$lib/tutorials/config' import { userStore } from '$lib/stores' @@ -116,6 +116,48 @@ await resetTutorialsByIndexes(currentTabIndexes) await syncTutorialsTodos() } + + // Calculate progress for each tab + function getTabProgress(tabId: TabId) { + const tabConfig = TUTORIALS_CONFIG[tabId] + const user = $userStore + + // Get all tutorial indexes for this tab (filtered by role) + const indexes: number[] = [] + for (const tutorial of tabConfig.tutorials) { + if (tutorial.disabled || tutorial.index === undefined) continue + if (tutorial.requiredRole && user) { + const { requiredRole } = tutorial + if (requiredRole === 'admin' && !user.is_admin && !user.is_super_admin) continue + if (requiredRole === 'operator' && !user.operator && !user.is_admin && !user.is_super_admin) continue + if (requiredRole === 'developer' && user.operator && !user.is_admin && !user.is_super_admin) 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})` } + } + @@ -153,7 +195,20 @@
{#each Object.entries(TUTORIALS_CONFIG) as [tabId, config]} - + {@const badge = getTabBadge(tabId as TabId)} + {#if badge.type === 'progress'} + + {#snippet extra()} + {badge.text} + {/snippet} + + {:else if badge.type === 'check'} + + {:else if badge.type === 'dot'} + + {:else} + + {/if} {/each}
From dc9ec0b2d9786d87fbed0f32006e3a851a3090f7 Mon Sep 17 00:00:00 2001 From: tristantr Date: Thu, 4 Dec 2025 18:14:11 +0100 Subject: [PATCH 112/137] Nits on design --- .../src/lib/components/home/TutorialButton.svelte | 12 +++++++----- .../routes/(root)/(logged)/tutorials/+page.svelte | 12 ++++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/home/TutorialButton.svelte b/frontend/src/lib/components/home/TutorialButton.svelte index 93446996633bd..c90941d6cc912 100644 --- a/frontend/src/lib/components/home/TutorialButton.svelte +++ b/frontend/src/lib/components/home/TutorialButton.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/lib/tutorialUtils.ts b/frontend/src/lib/tutorialUtils.ts index dbc0ebbbb3295..813c346b9e9f4 100644 --- a/frontend/src/lib/tutorialUtils.ts +++ b/frontend/src/lib/tutorialUtils.ts @@ -116,6 +116,35 @@ export async function resetTutorialsByIndexes(tutorialIndexes: number[]) { }) } +/** + * Reset (mark as incomplete) a single tutorial by index + */ +export async function resetTutorialByIndex(tutorialIndex: number) { + const currentTodos = get(tutorialsToDo) + + // Add the tutorial index back to todos if not already present + if (!currentTodos.includes(tutorialIndex)) { + const aft = [...currentTodos, tutorialIndex] + tutorialsToDo.set(aft) + skippedAll.set(false) + + // Get current progress bits + const currentResponse = await UserService.getTutorialProgress() + let bits: number = currentResponse.progress ?? 0 + + // Clear bit for this tutorial index + const mask = 1 << tutorialIndex + bits = bits & ~mask + + await UserService.updateTutorialProgress({ + requestBody: { + progress: bits, + skipped_all: false + } + }) + } +} + export async function syncTutorialsTodos() { const response = await UserService.getTutorialProgress() const bits: number = response.progress! diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index dfed45a040f4f..43710606963e7 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -15,7 +15,8 @@ getTutorialProgressCompleted, skipAllTodos, skipTutorialsByIndexes, - resetTutorialsByIndexes + resetTutorialsByIndexes, + resetTutorialByIndex } from '$lib/tutorialUtils' import { Button } from '$lib/components/common' import { RefreshCw, CheckCheck, CheckCircle2, Circle } from 'lucide-svelte' @@ -155,6 +156,15 @@ await syncTutorialsTodos() } + // Reset a single tutorial + async function resetSingleTutorial(tutorialId: string) { + const tutorial = currentTabConfig.tutorials.find((t) => t.id === tutorialId) + if (!tutorial || tutorial.index === undefined) return + + await resetTutorialByIndex(tutorial.index) + await syncTutorialsTodos() + } + // Calculate progress for each tab function getTabProgress(tabId: TabId) { const tabConfig = TUTORIALS_CONFIG[tabId] @@ -297,6 +307,7 @@ isCompleted={isTutorialCompleted(tutorial.id)} disabled={tutorial.active === false} comingSoon={tutorial.comingSoon} + onReset={() => resetSingleTutorial(tutorial.id)} /> {/each}
From 7e0b6364f16d464090bbd9df6be1c5baa6712d73 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 12:12:03 +0100 Subject: [PATCH 121/137] Allow a user to mark as completed a single tutorial --- .../lib/components/home/TutorialButton.svelte | 53 +++++++++++---- frontend/src/lib/tutorialUtils.ts | 66 ++++++++++++------- .../(root)/(logged)/tutorials/+page.svelte | 33 ++++++++-- 3 files changed, 112 insertions(+), 40 deletions(-) diff --git a/frontend/src/lib/components/home/TutorialButton.svelte b/frontend/src/lib/components/home/TutorialButton.svelte index 05459a472e27e..b783821fbbff4 100644 --- a/frontend/src/lib/components/home/TutorialButton.svelte +++ b/frontend/src/lib/components/home/TutorialButton.svelte @@ -1,5 +1,5 @@
-
+
(isHovered = true)} + onmouseleave={() => (isHovered = false)} + > {#if actionButton()} {@const button = actionButton()!} {@const ActionIcon = button.icon} From 2c08bad3cd30e42c1b2c481f0f02dd0d93cf0451 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 14:34:05 +0100 Subject: [PATCH 123/137] Allow admins to see which tutorials are available per role --- .../(root)/(logged)/tutorials/+page.svelte | 167 ++++++++++++++---- 1 file changed, 134 insertions(+), 33 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 3219cb43bcdd4..9d2695a1e5ead 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -1,7 +1,7 @@ - - {#if activeTabs.length > 0} -
- - + + Learn how to use Windmill with our interactive tutorials + + + {#if activeTabs.length > 0} +
+ + +
+ {/if} +
+ {#if $userStore?.is_admin} +
+
+ View as an + { + roleOverride = (v || 'admin') as typeof roleOverride + }} + noWFull + > + {#snippet children({ item })} + + + + {/snippet} + +
+ + This allows you to see which tutorials your team members can access +
{/if} -
+
{#if activeTabs.length > 0}
From 9442300513af835b5569324ce9d5162e8bd92994 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 14:57:06 +0100 Subject: [PATCH 124/137] Create utils that allow admins to see which tutorials can access other roles of their organization --- .../src/lib/components/FlowTutorials.svelte | 6 +- frontend/src/lib/tutorials/config.ts | 16 ++--- frontend/src/lib/tutorials/roleUtils.ts | 64 +++++++++++++++++ .../(root)/(logged)/tutorials/+page.svelte | 71 ++++--------------- 4 files changed, 89 insertions(+), 68 deletions(-) create mode 100644 frontend/src/lib/tutorials/roleUtils.ts diff --git a/frontend/src/lib/components/FlowTutorials.svelte b/frontend/src/lib/components/FlowTutorials.svelte index 475ccee492bd1..6b2f043236f2f 100644 --- a/frontend/src/lib/components/FlowTutorials.svelte +++ b/frontend/src/lib/components/FlowTutorials.svelte @@ -40,7 +40,7 @@ \ No newline at end of file diff --git a/frontend/src/lib/tutorials/config.ts b/frontend/src/lib/tutorials/config.ts index f35a5fcf2cde1..daaad34230646 100644 --- a/frontend/src/lib/tutorials/config.ts +++ b/frontend/src/lib/tutorials/config.ts @@ -1,6 +1,7 @@ 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 @@ -11,14 +12,14 @@ export interface TutorialConfig { 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?: ('admin' | 'developer' | 'operator')[] // Roles that can access this tutorial (if not specified, available to everyone) + roles?: Role[] // Roles that can access this tutorial (if not specified, available to everyone) order?: number } export interface TabConfig { label: string tutorials: TutorialConfig[] - roles?: ('admin' | 'developer' | 'operator')[] // Roles that can access this tab category (if not specified, available to everyone) + 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) } @@ -37,15 +38,12 @@ export function getTutorialIndex(id: string): number { throw new Error(`Tutorial index not found for id: ${id}. Make sure the tutorial has an index defined in config.`) } -// Available roles - // 'developer': Developer role (can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f). - // 'operator': Operator role (can execute and view scripts/flows/apps from your workspace, and only those that he has visibility on). - // 'admin': Admin role (has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace). +// Available roles : developer, admin, operator export const TUTORIALS_CONFIG: Record = { quickstart: { label: 'Quickstart', - roles: ['developer', 'admin'], + roles: ['admin', 'developer', 'operator'], progressBar: true, active: true, tutorials: [ @@ -60,7 +58,7 @@ export const TUTORIALS_CONFIG: Record = { index: 1, active: true, comingSoon: false, - roles: ['developer', 'admin'], + roles: ['operator','developer', 'admin'], order: 1 }, { @@ -88,7 +86,7 @@ export const TUTORIALS_CONFIG: Record = { index: 3, active: true, comingSoon: false, - roles: ['developer', 'admin'], + roles: ['admin','developer'], order: 3 } ] diff --git a/frontend/src/lib/tutorials/roleUtils.ts b/frontend/src/lib/tutorials/roleUtils.ts new file mode 100644 index 0000000000000..03728837849ee --- /dev/null +++ b/frontend/src/lib/tutorials/roleUtils.ts @@ -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) +} + diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 9d2695a1e5ead..82ee681359828 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -23,33 +23,13 @@ import { RefreshCw, CheckCheck, CheckCircle2, Circle, Shield, Code, UserCog } from 'lucide-svelte' import { TUTORIALS_CONFIG, type TabId } from '$lib/tutorials/config' import { userStore } from '$lib/stores' - import type { UserExt } 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, type Role } from '$lib/tutorials/roleUtils' // Role override for admins to preview what other roles see // Only used when user is admin - defaults to 'admin' (their actual role) - let roleOverride: 'admin' | 'developer' | 'operator' = $state('admin') - - /** - * Check if a user has access based on a roles array. - */ - function hasRoleAccess( - user: UserExt | null | undefined, - roles?: ('admin' | 'developer' | 'operator')[] - ): boolean { - // No roles specified = available to everyone - if (!roles || roles.length === 0) return true - if (!user) return false - - // Check if user has any of the required roles - return roles.some((role) => { - if (role === 'admin') return user.is_admin - if (role === 'operator') return user.operator || user.is_admin - if (role === 'developer') return !user.operator || user.is_admin - return false - }) - } + let roleOverride: Role = $state('admin') // Debug: Log user role for troubleshooting $effect(() => { @@ -65,36 +45,25 @@ }) /** - * Check if a preview role has access based on a roles array. - * Used by admins to preview what other roles can see. + * Check if the current user (or preview role) has access to a roles array. + * Handles both normal access and admin preview mode. */ - function hasRoleAccessForPreview( - previewRole: 'admin' | 'developer' | 'operator', - roles?: ('admin' | 'developer' | 'operator')[] - ): boolean { - // No roles specified = available to everyone - if (!roles || roles.length === 0) return true - - // Check if preview role has any of the required roles - return roles.some((role) => { - if (role === 'admin') return previewRole === 'admin' - if (role === 'operator') return previewRole === 'operator' || previewRole === 'admin' - if (role === 'developer') return previewRole === 'developer' || previewRole === 'admin' - return false - }) + function checkAccess(roles?: Role[]): boolean { + const user = $userStore + // Use preview function if admin has selected a role override + if (user?.is_admin && roleOverride !== 'admin') { + return hasRoleAccessForPreview(roleOverride, roles) + } + return hasRoleAccess(user, roles) } // Get active tabs only (filtered by active and roles) const activeTabs = $derived.by(() => { - const user = $userStore return Object.entries(TUTORIALS_CONFIG).filter(([, config]) => { // Filter by active if (config.active === false) return false - // Filter by roles - use preview function if admin has selected a role override - if (user?.is_admin && roleOverride !== 'admin') { - return hasRoleAccessForPreview(roleOverride, config.roles) - } - return hasRoleAccess(user, config.roles) + // Filter by roles + return checkAccess(config.roles) }) as [TabId, typeof TUTORIALS_CONFIG[TabId]][] }) @@ -119,11 +88,7 @@ const visibleTutorials = $derived( currentTabConfig.tutorials.filter((tutorial) => { if (tutorial.active === false) return false - // Use preview function if admin has selected a role override - if ($userStore?.is_admin && roleOverride !== 'admin') { - return hasRoleAccessForPreview(roleOverride, tutorial.roles) - } - return hasRoleAccess($userStore, tutorial.roles) + return checkAccess(tutorial.roles) }) ) @@ -238,18 +203,12 @@ // Calculate progress for each tab function getTabProgress(tabId: TabId) { const tabConfig = TUTORIALS_CONFIG[tabId] - const user = $userStore // 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 preview function if admin has selected a role override - if (user?.is_admin && roleOverride !== 'admin') { - if (!hasRoleAccessForPreview(roleOverride, tutorial.roles)) continue - } else { - if (!hasRoleAccess(user, tutorial.roles)) continue - } + if (!checkAccess(tutorial.roles)) continue indexes.push(tutorial.index) } From 71ac2f7a5e7d656049236c92b9aced9c9788d1b0 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 15:01:28 +0100 Subject: [PATCH 125/137] Refactor resetSingleTutorial and completeSingleTutorial into one function --- .../(root)/(logged)/tutorials/+page.svelte | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 82ee681359828..0b8b5a3a36563 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -168,8 +168,8 @@ await syncTutorialsTodos() } - // Reset a single tutorial - async function resetSingleTutorial(tutorialId: string) { + // 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}`) @@ -177,26 +177,14 @@ } try { - await resetTutorialByIndex(tutorial.index) - await syncTutorialsTodos() - } catch (error) { - console.error('Error resetting tutorial:', error) - } - } - - // Mark a single tutorial as completed - async function completeSingleTutorial(tutorialId: string) { - 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 { - await completeTutorialByIndex(tutorial.index) + if (completed) { + await completeTutorialByIndex(tutorial.index) + } else { + await resetTutorialByIndex(tutorial.index) + } await syncTutorialsTodos() } catch (error) { - console.error('Error completing tutorial:', error) + console.error(`Error ${completed ? 'completing' : 'resetting'} tutorial:`, error) } } @@ -391,8 +379,8 @@ isCompleted={isTutorialCompleted(tutorial.id)} disabled={tutorial.active === false} comingSoon={tutorial.comingSoon} - onReset={() => resetSingleTutorial(tutorial.id)} - onComplete={() => completeSingleTutorial(tutorial.id)} + onReset={() => updateSingleTutorial(tutorial.id, false)} + onComplete={() => updateSingleTutorial(tutorial.id, true)} /> {/each}
From b2fdf62c0dd2ee788573116985f018d5daf0881b Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 15:06:23 +0100 Subject: [PATCH 126/137] Improve role system --- .../(root)/(logged)/tutorials/+page.svelte | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 0b8b5a3a36563..739f0c20e5e87 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -25,11 +25,29 @@ 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, type Role } from '$lib/tutorials/roleUtils' + import { hasRoleAccess, hasRoleAccessForPreview, getUserEffectiveRole, type Role } from '$lib/tutorials/roleUtils' - // Role override for admins to preview what other roles see - // Only used when user is admin - defaults to 'admin' (their actual role) - let roleOverride: Role = $state('admin') + // 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' + } + }) // Debug: Log user role for troubleshooting $effect(() => { @@ -38,8 +56,8 @@ console.log('Tutorials page - User role:', { is_admin: user.is_admin, operator: user.operator, - effectiveRole: user.is_admin ? 'admin' : user.operator ? 'operator' : 'developer', - roleOverride: user.is_admin ? roleOverride : 'N/A (not admin)' + effectiveRole: userEffectiveRole, + selectedPreviewRole: selectedPreviewRole }) } }) @@ -50,9 +68,9 @@ */ function checkAccess(roles?: Role[]): boolean { const user = $userStore - // Use preview function if admin has selected a role override - if (user?.is_admin && roleOverride !== 'admin') { - return hasRoleAccessForPreview(roleOverride, roles) + // Use preview function if admin has selected a different role to preview + if (user?.is_admin && selectedPreviewRole !== userEffectiveRole) { + return hasRoleAccessForPreview(selectedPreviewRole, roles) } return hasRoleAccess(user, roles) } @@ -268,15 +286,15 @@
View as an { - roleOverride = (v || 'admin') as typeof roleOverride + selectedPreviewRole = (v || userEffectiveRole) as Role }} noWFull > {#snippet children({ item })} Date: Fri, 5 Dec 2025 15:10:04 +0100 Subject: [PATCH 127/137] Remove hardcoded MAX_TUTORIAL_ID --- frontend/src/lib/tutorialUtils.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/tutorialUtils.ts b/frontend/src/lib/tutorialUtils.ts index da49c07a21eb8..030aeaf4b5ddd 100644 --- a/frontend/src/lib/tutorialUtils.ts +++ b/frontend/src/lib/tutorialUtils.ts @@ -1,8 +1,25 @@ import { get } from 'svelte/store' 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. From 543e0c9acc9ab64fd3b9f1f1e37efa910550a33f Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 15:14:18 +0100 Subject: [PATCH 128/137] Fix type assertion --- frontend/src/routes/(root)/(logged)/tutorials/+page.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 739f0c20e5e87..1f467689cd84c 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -21,7 +21,7 @@ } 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 } from '$lib/tutorials/config' + 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' @@ -77,12 +77,12 @@ // Get active tabs only (filtered by active and roles) const activeTabs = $derived.by(() => { - return Object.entries(TUTORIALS_CONFIG).filter(([, config]) => { + return (Object.entries(TUTORIALS_CONFIG) as [TabId, TabConfig][]).filter(([, config]) => { // Filter by active if (config.active === false) return false // Filter by roles return checkAccess(config.roles) - }) as [TabId, typeof TUTORIALS_CONFIG[TabId]][] + }) }) // Initialize tab to first active tab (already filtered by role and active status) From 967e88eb270d1f5c0c491b331e9748743a0fd536 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 15:15:59 +0100 Subject: [PATCH 129/137] Remove console log --- .../routes/(root)/(logged)/tutorials/+page.svelte | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 1f467689cd84c..641f1e4670e2e 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -49,18 +49,6 @@ } }) - // Debug: Log user role for troubleshooting - $effect(() => { - const user = $userStore - if (user) { - console.log('Tutorials page - User role:', { - is_admin: user.is_admin, - operator: user.operator, - effectiveRole: userEffectiveRole, - selectedPreviewRole: selectedPreviewRole - }) - } - }) /** * Check if the current user (or preview role) has access to a roles array. From 215a4c810233815c47ba32df591359615af08ebe Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 15:22:04 +0100 Subject: [PATCH 130/137] Reduce recalculations when unrelated state changes --- .../(root)/(logged)/tutorials/+page.svelte | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 641f1e4670e2e..c9ca064326f5a 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -50,26 +50,40 @@ }) + // 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 } + }) + /** * Check if the current user (or preview role) has access to a roles array. * Handles both normal access and admin preview mode. */ function checkAccess(roles?: Role[]): boolean { - const user = $userStore + const context = accessCheckContext // Use preview function if admin has selected a different role to preview - if (user?.is_admin && selectedPreviewRole !== userEffectiveRole) { - return hasRoleAccessForPreview(selectedPreviewRole, roles) + if (context.usePreview) { + return hasRoleAccessForPreview(context.previewRole, roles) } - return hasRoleAccess(user, roles) + return hasRoleAccess(context.user, roles) } // 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 - return checkAccess(config.roles) + // Filter by roles (context is captured in closure) + if (context.usePreview) { + return hasRoleAccessForPreview(context.previewRole, config.roles) + } + return hasRoleAccess(context.user, config.roles) }) }) @@ -91,21 +105,29 @@ const currentTabConfig = $derived(TUTORIALS_CONFIG[tab]) // Filter tutorials by role and active status (same logic as displayed tutorials) - const visibleTutorials = $derived( - currentTabConfig.tutorials.filter((tutorial) => { + // 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 - return checkAccess(tutorial.roles) + // 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) - const currentTabTutorialIndexes = $derived( - Object.fromEntries( + // 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)) From a20cbfc3090b74ed14ee06ec2a0b383e9d4ebff5 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 17:02:02 +0100 Subject: [PATCH 131/137] Add console.error --- .../(root)/(logged)/tutorials/+page.svelte | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index c9ca064326f5a..84187d46f9349 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -185,15 +185,23 @@ // Skip all tutorials in current tab async function skipCurrentTabTutorials() { if (currentTabIndexes.length === 0) return - await skipTutorialsByIndexes(currentTabIndexes) - await syncTutorialsTodos() + 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 - await resetTutorialsByIndexes(currentTabIndexes) - await syncTutorialsTodos() + try { + await resetTutorialsByIndexes(currentTabIndexes) + await syncTutorialsTodos() + } catch (error) { + console.error('Error resetting tutorials:', error) + } } // Update a single tutorial's completion status From eeb0cbabf6c200527b5715dd912b3e8979e66778 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 17:05:18 +0100 Subject: [PATCH 132/137] Remove unused function --- .../(root)/(logged)/tutorials/+page.svelte | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte index 84187d46f9349..95ccbc9cc17cc 100644 --- a/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/tutorials/+page.svelte @@ -58,19 +58,6 @@ return { user, usePreview, previewRole: selectedPreviewRole } }) - /** - * Check if the current user (or preview role) has access to a roles array. - * Handles both normal access and admin preview mode. - */ - function checkAccess(roles?: Role[]): boolean { - const context = accessCheckContext - // Use preview function if admin has selected a different role to preview - if (context.usePreview) { - return hasRoleAccessForPreview(context.previewRole, roles) - } - return hasRoleAccess(context.user, roles) - } - // Get active tabs only (filtered by active and roles) // Optimized: $derived.by() automatically memoizes - only recalculates when dependencies change const activeTabs = $derived.by(() => { @@ -227,12 +214,18 @@ // 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 - if (!checkAccess(tutorial.roles)) 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) } From 3e1df0db19f1727c2d06e555e85c7688e6060571 Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 17:28:36 +0100 Subject: [PATCH 133/137] Add tutorial wrapper and better router --- .../src/lib/components/AppTutorials.svelte | 65 ++++++------------- .../src/lib/components/FlowTutorials.svelte | 51 +++++---------- .../lib/components/WorkspaceTutorials.svelte | 38 ++++------- .../tutorials/TutorialRouter.svelte | 64 ++++++++++++++++++ .../tutorials/TutorialWrapper.svelte | 35 ++++++++++ 5 files changed, 144 insertions(+), 109 deletions(-) create mode 100644 frontend/src/lib/components/tutorials/TutorialRouter.svelte create mode 100644 frontend/src/lib/components/tutorials/TutorialWrapper.svelte diff --git a/frontend/src/lib/components/AppTutorials.svelte b/frontend/src/lib/components/AppTutorials.svelte index 435b8efa1553f..25d06e016715f 100644 --- a/frontend/src/lib/components/AppTutorials.svelte +++ b/frontend/src/lib/components/AppTutorials.svelte @@ -1,56 +1,29 @@ - - - diff --git a/frontend/src/lib/components/FlowTutorials.svelte b/frontend/src/lib/components/FlowTutorials.svelte index 6b2f043236f2f..8bb4fe7220d82 100644 --- a/frontend/src/lib/components/FlowTutorials.svelte +++ b/frontend/src/lib/components/FlowTutorials.svelte @@ -1,46 +1,25 @@ - - \ No newline at end of file diff --git a/frontend/src/lib/components/WorkspaceTutorials.svelte b/frontend/src/lib/components/WorkspaceTutorials.svelte index aee41b050b2f7..1eeacbe72f83f 100644 --- a/frontend/src/lib/components/WorkspaceTutorials.svelte +++ b/frontend/src/lib/components/WorkspaceTutorials.svelte @@ -1,36 +1,20 @@ - diff --git a/frontend/src/lib/components/tutorials/TutorialRouter.svelte b/frontend/src/lib/components/tutorials/TutorialRouter.svelte new file mode 100644 index 0000000000000..80c8938effe08 --- /dev/null +++ b/frontend/src/lib/components/tutorials/TutorialRouter.svelte @@ -0,0 +1,64 @@ + + +{#each tutorials as tutorial} + +{/each} + diff --git a/frontend/src/lib/components/tutorials/TutorialWrapper.svelte b/frontend/src/lib/components/tutorials/TutorialWrapper.svelte new file mode 100644 index 0000000000000..34eaad50a0bfa --- /dev/null +++ b/frontend/src/lib/components/tutorials/TutorialWrapper.svelte @@ -0,0 +1,35 @@ + + +{#if Component} + {@const Comp = Component} + +{/if} + From 404faa5f6cdb1c7a6e3815c287fa5073f221e01d Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 18:29:48 +0100 Subject: [PATCH 134/137] Nits to pass npm checks --- .../lib/components/RunPageTutorials.svelte | 9 ++++++- .../apps/editor/AppEditorTutorial.svelte | 4 +-- .../flows/map/FlowModuleSchemaMap.svelte | 4 +-- .../components/sidebar/SidebarContent.svelte | 2 +- frontend/src/lib/tutorials/config.ts | 4 +-- .../src/routes/(root)/(logged)/+page.svelte | 26 +++++++++---------- 6 files changed, 27 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/components/RunPageTutorials.svelte b/frontend/src/lib/components/RunPageTutorials.svelte index 49c74be3846f4..e46b1c901991d 100644 --- a/frontend/src/lib/components/RunPageTutorials.svelte +++ b/frontend/src/lib/components/RunPageTutorials.svelte @@ -1,6 +1,7 @@ - + diff --git a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte b/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte index cee74b94ced9a..6981974c02704 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte @@ -65,8 +65,8 @@ { - targetTutorial = detail.detail + on:error={(event: CustomEvent<{ detail: string }>) => { + targetTutorial = event.detail.detail }} /> diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index 7ff0a8965f2f5..c32ca18b1839c 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -102,8 +102,6 @@ flowHasChanged }: Props = $props() - let flowTutorials: FlowTutorials | undefined = $state(undefined) - const { customUi, selectionManager, moving, history, flowStateStore, flowStore, pathStore } = getContext('FlowEditorContext') const { triggersCount, triggersState } = getContext('TriggerContext') @@ -659,5 +657,5 @@
{#if !disableTutorials} - + {/if} diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index c2e5f436bb3af..e41bc2f89a5b1 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -11,7 +11,7 @@ tutorialsToDo, skippedAll } from '$lib/stores' - import { syncTutorialsTodos, shouldHideTutorialsFromMainMenu } from '$lib/tutorialUtils' + import { syncTutorialsTodos } from '$lib/tutorialUtils' import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts' import { BookOpen, diff --git a/frontend/src/lib/tutorials/config.ts b/frontend/src/lib/tutorials/config.ts index daaad34230646..3aa062e346c93 100644 --- a/frontend/src/lib/tutorials/config.ts +++ b/frontend/src/lib/tutorials/config.ts @@ -43,7 +43,7 @@ export function getTutorialIndex(id: string): number { export const TUTORIALS_CONFIG: Record = { quickstart: { label: 'Quickstart', - roles: ['admin', 'developer', 'operator'], + roles: ['admin', 'developer'], progressBar: true, active: true, tutorials: [ @@ -58,7 +58,7 @@ export const TUTORIALS_CONFIG: Record = { index: 1, active: true, comingSoon: false, - roles: ['operator','developer', 'admin'], + roles: ['developer', 'admin'], order: 1 }, { diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index ed0353e16eaf9..81154d5405b68 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -20,8 +20,7 @@ Globe2, Loader2, Code, - LayoutDashboard, - GraduationCap + LayoutDashboard } from 'lucide-svelte' import { hubBaseUrlStore } from '$lib/stores' import { base } from '$lib/base' @@ -43,25 +42,26 @@ 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 = $state() + let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined = $state(undefined) - let appViewer: Drawer - let appViewerApp: { app?: any & { id?: number } } | undefined + let appViewer: Drawer = $state() + 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 = $state() + let codeViewerContent: string = $state('') + let codeViewerLanguage: Script['language'] = $state('deno') + let codeViewerObj: HubItem | undefined = $state(undefined) const breakpoint = writable('lg') From 1a49f504d7822a1bb207a8a2723138187ddbf1bb Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 19:06:15 +0100 Subject: [PATCH 135/137] Fix typescripts and lint errors --- frontend/src/routes/(root)/(logged)/+page.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index 81154d5405b68..db9b09d53fea5 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -52,13 +52,13 @@ let filter: string = $state('') - let flowViewer: Drawer = $state() + let flowViewer: Drawer | undefined = $state(undefined) let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined = $state(undefined) - let appViewer: Drawer = $state() + let appViewer: Drawer | undefined = $state(undefined) let appViewerApp: { app?: any & { id?: number } } | undefined = $state(undefined) - let codeViewer: Drawer = $state() + let codeViewer: Drawer | undefined = $state(undefined) let codeViewerContent: string = $state('') let codeViewerLanguage: Script['language'] = $state('deno') let codeViewerObj: HubItem | undefined = $state(undefined) @@ -74,7 +74,7 @@ codeViewerObj = obj }) - codeViewer.openDrawer?.() + codeViewer?.openDrawer?.() } async function viewFlow(obj: { flow_id: number }): Promise { @@ -83,7 +83,7 @@ delete hub['comments'] flowViewerFlow = hub }) - flowViewer.openDrawer?.() + flowViewer?.openDrawer?.() } async function viewApp(obj: { app_id: number }): Promise { @@ -92,7 +92,7 @@ delete hub['comments'] appViewerApp = hub }) - appViewer.openDrawer?.() + appViewer?.openDrawer?.() } let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined) From 8ec414346c03b732ff607f24e44ae4a832702bbe Mon Sep 17 00:00:00 2001 From: tristantr Date: Fri, 5 Dec 2025 20:12:30 +0100 Subject: [PATCH 136/137] Add SQLx query cache for tutorial_progress queries --- ...69939da8944711037957664cc2989b239c9d1.json | 16 +++++++++++ ...0f57c9c9c48192bc2293986c8abb54ae94446.json | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 backend/.sqlx/query-04362a55081f7a98bca8fe4db0669939da8944711037957664cc2989b239c9d1.json create mode 100644 backend/.sqlx/query-f3b7ea04830022f918f9302b4d40f57c9c9c48192bc2293986c8abb54ae94446.json diff --git a/backend/.sqlx/query-04362a55081f7a98bca8fe4db0669939da8944711037957664cc2989b239c9d1.json b/backend/.sqlx/query-04362a55081f7a98bca8fe4db0669939da8944711037957664cc2989b239c9d1.json new file mode 100644 index 0000000000000..0a2f244c1127c --- /dev/null +++ b/backend/.sqlx/query-04362a55081f7a98bca8fe4db0669939da8944711037957664cc2989b239c9d1.json @@ -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" +} diff --git a/backend/.sqlx/query-f3b7ea04830022f918f9302b4d40f57c9c9c48192bc2293986c8abb54ae94446.json b/backend/.sqlx/query-f3b7ea04830022f918f9302b4d40f57c9c9c48192bc2293986c8abb54ae94446.json new file mode 100644 index 0000000000000..d50fb8f6f2330 --- /dev/null +++ b/backend/.sqlx/query-f3b7ea04830022f918f9302b4d40f57c9c9c48192bc2293986c8abb54ae94446.json @@ -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" +} From 053fae129de335f9f60ccd6f3eeadf7e75060db2 Mon Sep 17 00:00:00 2001 From: tristantr Date: Mon, 8 Dec 2025 10:59:19 +0100 Subject: [PATCH 137/137] Improve wording for workspace tutorial --- .../workspace/WorkspaceOnboardingTutorial.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte index b463e36fc6720..c5997769658be 100644 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte @@ -37,7 +37,7 @@ 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", + "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(() => { @@ -55,7 +55,7 @@ popover: { title: 'Create your first script', description: - 'Programming Languages

Click to create your first script!

', + 'Programming Languages

Scripts turn code into tools. Write in Python, TypeScript, Go, Bash, SQL and more. Run them manually, on schedule, or via webhooks.

', onNextClick: async () => { // Move to the next step (Create Flow button) setTimeout(() => { @@ -74,7 +74,7 @@ popover: { title: 'Create your first flow', description: - 'Flow

Discover together how flows work in Windmill

', + 'Flow

Flows orchestrate multiple scripts. Chain them together with branching, loops, and error handling to build complex workflows.

', onNextClick: async () => { // Move to the next step (Create App button) setTimeout(() => { @@ -93,7 +93,7 @@ popover: { title: 'Create your first app', description: - 'App

Build low-code applications with Windmill. That\'s it for the tour!

💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

', + 'App

Apps are custom UIs built with drag-and-drop. Combine tables, forms, charts, and buttons that trigger your scripts and flows.. That\'s it for the tour!

💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

', onNextClick: async () => { // Mark tutorial as complete updateProgress(index)