mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
feat(frontend): keep the operator onboarding tour
Operators cannot create anything, so the home page is the whole product to them and its three tabs are worth naming. The tour that did that is the one piece of the removed tutorial system that still has an audience. Restored trimmed: driver.js, the driver wrapper and its controls, the `.driver-popover` styling, and a module for the progress bit. The catalogue machinery it used to sit in — the config, the role gating, the router, the banner and the tutorials page — stays deleted, so the five steps are reached directly instead of through a registry of one. It runs on an operator's first home page visit and is recorded as seen however it ends, including navigating away; afterwards it is in the sidebar menu under Take the tour, which is where the last step points. Progress uses the surviving `tutorial_progress` route, slot 6, read-modify-written so the slots of the removed tutorials keep their state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG
This commit is contained in:
co-authored by
Claude Opus 5
parent
d30bb8a82c
commit
d2d58b7313
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
<Settings size={14} />
|
||||
Account settings
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
href="{base}/?{TOUR_PARAM}={TOUR_PARAM_VALUE}"
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2',
|
||||
sidebarClasses.text,
|
||||
'transition-colors',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
lightMode
|
||||
{item}
|
||||
>
|
||||
<GraduationCap size={14} />
|
||||
Take the tour
|
||||
</MenuItem>
|
||||
</div>
|
||||
|
||||
<div role="none">
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import type { DriveStep } from 'driver.js'
|
||||
import { wait } from '$lib/utils'
|
||||
import Tutorial from './Tutorial.svelte'
|
||||
import { markOperatorTourSeen, MENU_OPEN_DELAY_MS } from './operatorTour'
|
||||
|
||||
let tutorial: Tutorial | undefined = $state(undefined)
|
||||
let running = false
|
||||
|
||||
export function runTutorial() {
|
||||
// A second driver mounted over a live one leaves an overlay that nothing closes.
|
||||
if (running) return
|
||||
running = true
|
||||
tutorial?.runTutorial()
|
||||
}
|
||||
|
||||
// Recorded however the tour ends, not only on the last step: someone who closes it has
|
||||
// answered the question, and the sidebar entry is how they get it back.
|
||||
function onDestroyed() {
|
||||
running = false
|
||||
void markOperatorTourSeen()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
{onDestroyed}
|
||||
getSteps={(driver) => {
|
||||
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:
|
||||
'<img src="/script-tutorial-operator.png" alt="Script Example" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Scripts</strong> are ready-to-use tasks that do things automatically for you.</p><p style="margin-top: 8px;">You can <strong>run scripts</strong> whenever you need them - like generating a report, sending notifications, or processing data.</p>'
|
||||
},
|
||||
element: '[data-value="script"]'
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Flows - Run step-by-step processes',
|
||||
description:
|
||||
'<img src="/flow.png" alt="Flow" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Flows</strong> are processes that run multiple tasks in order, one after another.</p><p style="margin-top: 8px;">You can <strong>start a flow</strong> and watch it complete each step automatically - perfect for tasks that have multiple stages.</p>'
|
||||
},
|
||||
element: '[data-value="flow"]'
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Apps - Use custom tools',
|
||||
description:
|
||||
'<img src="/app.png" alt="App" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Apps</strong> are easy-to-use tools with buttons, forms, and displays built just for your team.</p><p style="margin-top: 8px;">You can <strong>open an app</strong> to work with your data, fill out forms, or trigger tasks - no technical knowledge needed!</p>'
|
||||
},
|
||||
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.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to see this again?</strong> Pick <strong>Take the tour</strong> from that same menu.</p>',
|
||||
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
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { driver, type Driver, type DriveStep } from 'driver.js'
|
||||
import { mount, onDestroy } from 'svelte'
|
||||
import TutorialControls from './TutorialControls.svelte'
|
||||
import TutorialInner from './TutorialInner.svelte'
|
||||
|
||||
interface Props {
|
||||
/** Called once the tour ends, however it ended: last step, close button, or Escape. */
|
||||
onDestroyed?: () => void
|
||||
getSteps: (driver: Driver) => DriveStep[]
|
||||
}
|
||||
|
||||
let { onDestroyed = undefined, getSteps }: Props = $props()
|
||||
|
||||
let totalSteps = 0
|
||||
let tutorial: Driver | undefined = $state(undefined)
|
||||
|
||||
// driver.js renders its popover as plain DOM, so the controls are mounted into it rather
|
||||
// than declared in markup — which is also why they are re-mounted on every step.
|
||||
function renderControls(activeIndex: number) {
|
||||
const popoverContent = document.querySelector('#driver-popover-content')
|
||||
popoverContent?.addEventListener('pointerdown', (event) => {
|
||||
event.stopPropagation()
|
||||
})
|
||||
|
||||
const popoverDescription = document.querySelector('#driver-popover-description')
|
||||
if (!tutorial || !popoverDescription) {
|
||||
return
|
||||
}
|
||||
|
||||
const controls = document.createElement('div')
|
||||
mount(TutorialControls, {
|
||||
target: controls,
|
||||
props: {
|
||||
activeIndex,
|
||||
totalSteps,
|
||||
// A step that defines `onNextClick` owns its own advance — that is how a step
|
||||
// that has to open something first waits for it before moving on.
|
||||
onNext: () => {
|
||||
const step = tutorial?.getActiveStep()
|
||||
if (!step) return
|
||||
const onNextClick = step.popover?.onNextClick
|
||||
if (onNextClick) {
|
||||
onNextClick(tutorial?.getActiveElement(), step, {
|
||||
config: tutorial!.getConfig(),
|
||||
state: tutorial!.getState(),
|
||||
driver: tutorial!,
|
||||
index: activeIndex
|
||||
})
|
||||
} else {
|
||||
tutorial?.moveNext()
|
||||
}
|
||||
},
|
||||
onPrevious: () => {
|
||||
const step = tutorial?.getActiveStep()
|
||||
if (!step) return
|
||||
const onPrevClick = step.popover?.onPrevClick
|
||||
if (onPrevClick) {
|
||||
onPrevClick(tutorial?.getActiveElement(), step, {
|
||||
config: tutorial!.getConfig(),
|
||||
state: tutorial!.getState(),
|
||||
driver: tutorial!,
|
||||
index: activeIndex
|
||||
})
|
||||
} else {
|
||||
tutorial?.movePrevious()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
popoverDescription.appendChild(controls)
|
||||
}
|
||||
|
||||
export function runTutorial() {
|
||||
tutorial = driver({
|
||||
allowClose: true,
|
||||
disableActiveInteraction: true,
|
||||
showButtons: ['close'],
|
||||
showProgress: false,
|
||||
overlayColor: 'rgba(0, 0, 0, 0.8)',
|
||||
onPopoverRender: (_popover, { state }) => {
|
||||
renderControls(state.activeIndex ?? 0)
|
||||
},
|
||||
onDestroyed: () => {
|
||||
onDestroyed?.()
|
||||
}
|
||||
})
|
||||
|
||||
const steps = getSteps(tutorial)
|
||||
totalSteps = steps.length
|
||||
tutorial.setSteps(steps)
|
||||
tutorial.drive()
|
||||
}
|
||||
|
||||
// driver.js appends its overlay to the body, so leaving the page mid-tour would strand it
|
||||
// over whatever renders next. Destroying also runs `onDestroyed`, which is where the tour
|
||||
// is recorded as seen — so navigating away counts as having been shown it.
|
||||
onDestroy(() => tutorial?.destroy())
|
||||
</script>
|
||||
|
||||
{#if tutorial}
|
||||
<TutorialInner />
|
||||
{/if}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
|
||||
interface Props {
|
||||
activeIndex: number
|
||||
totalSteps: number
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
let { activeIndex, totalSteps, onPrevious, onNext }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full pt-4">
|
||||
{#if activeIndex === 0}
|
||||
<Alert size="xs" title="Help">
|
||||
<li>UI is not interactive during the tour, press next at every step</li>
|
||||
<li>You can use the arrow keys to navigate</li>
|
||||
</Alert>
|
||||
{/if}
|
||||
<div class="flex flex-row gap-2 justify-between w-full items-center">
|
||||
<div class="text-xs">
|
||||
Step {activeIndex + 1} of {totalSteps}
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
onclick={onPrevious}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button unifiedSize="xs" variant="accent" endIcon={{ icon: ArrowRight }} onclick={onNext}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script lang="ts">
|
||||
import 'driver.js/dist/driver.css'
|
||||
</script>
|
||||
@@ -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<boolean> {
|
||||
// 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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={codeViewer} size="900px">
|
||||
@@ -324,6 +367,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $userStore?.operator}
|
||||
<OperatorTour bind:this={operatorTour} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* The page's content arriving, rather than being there. The layout has already painted the
|
||||
sidebar and the surface behind it, so only what is new to this route fades. It plays on
|
||||
|
||||
Reference in New Issue
Block a user