diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4761b01b04..1febb20b83 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,6 +43,7 @@ "date-fns": "^2.30.0", "diff": "^7.0.0", "dompurify": "^3.3.1", + "driver.js": "^1.3.0", "esm-env": "^1.0.0", "fast-equals": "^5.0.1", "graphql": "^16.7.1", @@ -5563,6 +5564,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/driver.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz", + "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==", + "license": "MIT" + }, "node_modules/dts-bundle-generator": { "version": "9.5.1", "resolved": "https://registry.npmjs.org/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f8d4261f93..d96e512c00 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -119,6 +119,7 @@ "date-fns": "^2.30.0", "diff": "^7.0.0", "dompurify": "^3.3.1", + "driver.js": "^1.3.0", "esm-env": "^1.0.0", "fast-equals": "^5.0.1", "graphql": "^16.7.1", diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 3e9aa42728..f6dc648234 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -302,6 +302,18 @@ } } +.driver-popover-title { + @apply leading-6 !text-primary !text-base; +} + +.driver-popover-description { + @apply !text-secondary !text-sm; +} + +.driver-popover { + @apply p-6 !bg-surface !max-w-2xl; +} + .panel-item { @apply border dark:border-gray-600 border-gray-200 flex gap-1 truncate font-normal justify-between w-full items-center py-1 px-2 rounded-sm duration-200; } diff --git a/frontend/src/lib/components/sidebar/OperatorMenu.svelte b/frontend/src/lib/components/sidebar/OperatorMenu.svelte index 8a4ee10dfe..fa59d730df 100644 --- a/frontend/src/lib/components/sidebar/OperatorMenu.svelte +++ b/frontend/src/lib/components/sidebar/OperatorMenu.svelte @@ -12,9 +12,11 @@ Building, Calendar, ServerCog, - Table2 + Table2, + GraduationCap } from 'lucide-svelte' import { base } from '$lib/base' + import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' import MultiplayerMenu from './MultiplayerMenu.svelte' import { Plus } from 'lucide-svelte' @@ -228,6 +230,21 @@ Account settings + + + + Take the tour +
diff --git a/frontend/src/lib/components/tutorials/OperatorTour.svelte b/frontend/src/lib/components/tutorials/OperatorTour.svelte new file mode 100644 index 0000000000..cc7f4ab4a5 --- /dev/null +++ b/frontend/src/lib/components/tutorials/OperatorTour.svelte @@ -0,0 +1,81 @@ + + + { + const steps: DriveStep[] = [ + { + popover: { + title: 'Welcome to Windmill! 🎉', + description: + "Let's take a quick tour! We'll show you the three main tools you can use: Scripts, Flows, and Apps." + } + }, + { + popover: { + title: 'Scripts - Run automated tasks', + description: + 'Script Example

Scripts are ready-to-use tasks that do things automatically for you.

You can run scripts whenever you need them - like generating a report, sending notifications, or processing data.

' + }, + element: '[data-value="script"]' + }, + { + popover: { + title: 'Flows - Run step-by-step processes', + description: + 'Flow

Flows are processes that run multiple tasks in order, one after another.

You can start a flow and watch it complete each step automatically - perfect for tasks that have multiple stages.

' + }, + element: '[data-value="flow"]' + }, + { + popover: { + title: 'Apps - Use custom tools', + description: + 'App

Apps are easy-to-use tools with buttons, forms, and displays built just for your team.

You can open an app to work with your data, fill out forms, or trigger tasks - no technical knowledge needed!

' + }, + element: '[data-value="app"]' + }, + { + popover: { + title: 'Finally, the Menu section', + description: + 'Explore available tabs where you can access your history of runs, your scheduled scripts, and your workspaces.

💡 Want to see this again? Pick Take the tour from that same menu.

', + onNextClick: async () => { + // The step points into the menu, so it has to be open before the popover + // lands on it — and open is also where the entry to re-run the tour is. + const menuButton = document.querySelector('[role="menuitem"]') as HTMLElement | null + menuButton?.click() + await wait(MENU_OPEN_DELAY_MS) + driver.destroy() + } + }, + element: '[role="menuitem"]' + } + ] + + return steps + }} +/> diff --git a/frontend/src/lib/components/tutorials/Tutorial.svelte b/frontend/src/lib/components/tutorials/Tutorial.svelte new file mode 100644 index 0000000000..d4c99482a2 --- /dev/null +++ b/frontend/src/lib/components/tutorials/Tutorial.svelte @@ -0,0 +1,103 @@ + + +{#if tutorial} + +{/if} diff --git a/frontend/src/lib/components/tutorials/TutorialControls.svelte b/frontend/src/lib/components/tutorials/TutorialControls.svelte new file mode 100644 index 0000000000..dd15cf4151 --- /dev/null +++ b/frontend/src/lib/components/tutorials/TutorialControls.svelte @@ -0,0 +1,41 @@ + + +
+ {#if activeIndex === 0} + +
  • UI is not interactive during the tour, press next at every step
  • +
  • You can use the arrow keys to navigate
  • +
    + {/if} +
    +
    + Step {activeIndex + 1} of {totalSteps} +
    +
    + + +
    +
    +
    diff --git a/frontend/src/lib/components/tutorials/TutorialInner.svelte b/frontend/src/lib/components/tutorials/TutorialInner.svelte new file mode 100644 index 0000000000..ce0784ba8e --- /dev/null +++ b/frontend/src/lib/components/tutorials/TutorialInner.svelte @@ -0,0 +1,3 @@ + diff --git a/frontend/src/lib/components/tutorials/operatorTour.ts b/frontend/src/lib/components/tutorials/operatorTour.ts new file mode 100644 index 0000000000..5c4269e84d --- /dev/null +++ b/frontend/src/lib/components/tutorials/operatorTour.ts @@ -0,0 +1,47 @@ +import { UserService } from '$lib/gen' + +/** + * The tour's slot in the `tutorial_progress` bitmask. Slot 6 is reserved for it across + * versions: an operator who has already been through the tour must not meet it again, and + * a slot that another tutorial writes would read as finished on day one. + */ +const OPERATOR_TOUR_BIT = 6 + +/** URL parameter the sidebar entry uses to ask the home page for a run. */ +export const TOUR_PARAM = 'tour' +export const TOUR_PARAM_VALUE = 'operator' + +/** Long enough for the home page's tabs to exist before the first step points at one. */ +export const TOUR_START_DELAY_MS = 500 +/** Time for the sidebar to open before the last step points into it. */ +export const MENU_OPEN_DELAY_MS = 300 + +export async function hasSeenOperatorTour(): Promise { + // A failure answers "seen": the tour interrupts the page, and interrupting someone who + // has already been through it is worse than never offering it, which the sidebar entry + // covers anyway. + try { + const progress = (await UserService.getTutorialProgress()).progress ?? 0 + return (progress & (1 << OPERATOR_TOUR_BIT)) !== 0 + } catch (error) { + console.error('Could not read tutorial progress:', error) + return true + } +} + +export async function markOperatorTourSeen(): Promise { + try { + // Read-modify-write, because the row is shared: it carries every slot's state, and a + // write of this bit alone would clear the rest. `skipped_all` rides along for the same + // reason — and the handler rejects a body without it, whatever the generated type says. + const current = await UserService.getTutorialProgress() + await UserService.updateTutorialProgress({ + requestBody: { + progress: (current.progress ?? 0) | (1 << OPERATOR_TOUR_BIT), + skipped_all: current.skipped_all ?? false + } + }) + } catch (error) { + console.error('Could not record tutorial progress:', error) + } +} diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index b2035e2ed4..c1f4f4818d 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -28,6 +28,14 @@ import { z } from 'zod' import HomeAIChat from '$lib/components/home/HomeAIChat.svelte' import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' + import { onMount, untrack } from 'svelte' + import OperatorTour from '$lib/components/tutorials/OperatorTour.svelte' + import { + hasSeenOperatorTour, + TOUR_PARAM, + TOUR_PARAM_VALUE, + TOUR_START_DELAY_MS + } from '$lib/components/tutorials/operatorTour' type Tab = 'hub' | 'workspace' @@ -83,6 +91,41 @@ } let showCreateButtons = $state(false) + + let operatorTour: OperatorTour | undefined = $state(undefined) + + // Delayed so the tabs the first steps point at exist. `runTutorial` refuses while a tour is + // already running, which is the guard that matters — the tour ends by telling the operator + // to start it again from the menu, so a start has to be possible for the life of the page. + function startTour() { + setTimeout(() => operatorTour?.runTutorial(), TOUR_START_DELAY_MS) + } + + // The sidebar entry asks by URL parameter so it works from any page an operator can be on. + // Read reactively rather than on mount: arriving from the menu while already on the home + // page is a parameter change, not a new page. + $effect(() => { + if (page.url.searchParams.get(TOUR_PARAM) !== TOUR_PARAM_VALUE) return + const user = $userStore + if (!user) return + untrack(() => { + const url = new URL(page.url) + url.searchParams.delete(TOUR_PARAM) + replaceState(url, page.state) + // Gated here too: the parameter is part of a URL anyone can type, and the tour + // describes a home page that only operators see. + if (user.operator) startTour() + }) + }) + + onMount(async () => { + // Operators get the tour once, and only when they have not been through it: they cannot + // create anything, so the home page is the whole product to them and it is worth naming + // its three tabs. Anyone who can build gets nothing — they have the create button. + if (!$userStore?.operator || page.url.searchParams.has(TOUR_PARAM)) return + if (await hasSeenOperatorTour()) return + startTour() + }) @@ -324,6 +367,10 @@ {/if}
    +{#if $userStore?.operator} + +{/if} +