mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 00:01:34 +00:00
feat: new live onboarding for flows (#7194)
* Start workspace onboarding * Add pictures to tutorial steps * Remove unecessary step * Continue tutorial by creating a flow together * Add image into the Create Flow tutorial pop up * Generate flow from frontend * Set pause between each node * Add automatic scripts overview * Simplify tutorial, and add step to show the code * Add input step * Autoremove last step after 5 seconds * Add flow typing when opening code editor * Remove lock field from json file * Add Guides tab on left menu * Add /guides page * Add tutorial card in Guides tab * Add step to show data connector * Add second text input to show 2 types of inputs and fill them dynamically * Improve tutorial chronology * Add flow input connexion with first sctript * Improve overlay * Improve wording * Add new tutorial step to show node b * Add test step * Add cursor to pick typescript * Improve end of tutorial * Refactor * Highlight bottom right corner for 5 and 6 * Fix last step overlay * change home tutorial button * guidelines nits * Automate onNext() trigger on step 3 * Improve fakr cursor for Test this step button * Improve overlay transitions * Merge data connectors and test step steps * Improve live code writing in step 3 * Add a step to complete the flow * Improve the step where we generate remaining scripts * Refactor * Add blocking behavior on step 3 * nit about delay * Prevent clicking on Next while code not generated * Sharpen wordings * Remove Svelte 4 and migrate to Svelte 5 * Remove unecesary helper function * Add toast if the user clicks on Next button before code finished generating * Add toasts to each step * Improve tutorial trigger timing * Improve delays * Add cursor movement to Test Flow button * Block previous on certain steps to prevent bug * Fix for github npm check * Fix for github npm check * Unlike workspace onboarding and flow tutorial * Rename flow tutorial with better name * Remove the automatic trigger for flow previous and broken tutorial * Push tutorials to Help sectionof the sidebar * Fix redirection t /tutorials page * Add tutorials page and update workspace onboarding flow - Rename guides to tutorials page (/tutorials) - Add workspace onboarding tutorial to tutorials page - Remove Tutorial button from homepage - Add welcome cards for empty workspace with 3 tutorial options - Update workspace onboarding to redirect to homepage before starting - Clean up URL parameter after tutorial completion - Move Tutorials to Help menu in sidebar - Remove automatic "action" tutorial trigger for new flows - Add flow-live-tutorial (renamed from workspace-onboarding-continue) - Add Previous button blocking with toast notifications in flow tutorial 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add tutorials to workspace homepage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Start tutorials for Run/logs section * Fix data connector * Add flow execution graph from Run drawer * Add tabs highlighting in drawer * Improve tutorial on run drawer * Add mouse cursor moving from graph tab * Add cursor click on script in Drawer Graph tabs * Add troubleshooting flow in tutorial * Add step to show logs of failed step * add step 7 to invite the user to fix by himself and se the new results * Improve wording * Nit improvements * Nits * Refactor * Refactor * Rename the tutorial * Remove deleted file * Improve wording * Improve first step of troubleshooting flow tutorial * Add tutorials to /tutorials page and create component * Remove previous Flow tutorials * Fixes, and improve tutorial button design * Improve status in Tutorial button * Align tutorial button to brand guidelines * Add skip all to onboarding workspace tutorial * Add skipped_all to tutorial_progress * Connect backend and frontend for tutorial progress * Add store and helper to display or not Tutorials from left menu * Add reminder at the end of each tutorial * Add tutorial banner * Remove tutorials from elpty workspace * Improve Tutorials page * Align banner to guidelines * Add reset tutorials buttons * Refactor * Refactor to make it easy to add new tutorials and tabs * Improve tutorial config to make it easy to add new tutorials * Refactor and remove hardcoded indexes * Add getTutorialIndex in tutorial config file * Nit * Add Mark all as complete button in tutorial page * Add skip tutorial button in banner toast * Replace if else in tutorials router by map to make it easier to maintain and scale * Delete broken simple app tutorial * Add Guide flow guide buttons inside the Create Flow page * Add flow editor tutorials into flow builder page * Update existing app tutorials with new tutorial system * Create a dedicated tutorial category for app editor * Add global progress bar * Add Reset & Skip at tutorial category level * Add progress to tab title * Nits on design * Make progress bar a props and design nits * Add active props for Tutorial Category * Display tutorials according to the user role * Adapt progress bar to the user role * Add roles array for each tutorial * Add Tutorials tab in Operator menu * Edge case if no Category and no Tutorial available for my role * Allow the user to reset a single tutorial * Allow a user to mark as completed a single tutorial * Nit on hoovering tutorial status * Allow admins to see which tutorials are available per role * Create utils that allow admins to see which tutorials can access other roles of their organization * Refactor resetSingleTutorial and completeSingleTutorial into one function * Improve role system * Remove hardcoded MAX_TUTORIAL_ID * Fix type assertion * Remove console log * Reduce recalculations when unrelated state changes * Add console.error * Remove unused function * Add tutorial wrapper and better router * Nits to pass npm checks * Fix typescripts and lint errors * Add SQLx query cache for tutorial_progress queries * Improve wording for workspace tutorial --------- Co-authored-by: Diego Imbert <diego@windmill.dev> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
+16
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Remove skipped_all column from tutorial_progress table
|
||||
ALTER TABLE tutorial_progress
|
||||
DROP COLUMN skipped_all;
|
||||
@@ -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)';
|
||||
@@ -1349,6 +1349,8 @@ paths:
|
||||
properties:
|
||||
progress:
|
||||
type: integer
|
||||
skipped_all:
|
||||
type: boolean
|
||||
post:
|
||||
summary: update tutorial progress
|
||||
operationId: updateTutorialProgress
|
||||
@@ -1364,6 +1366,8 @@ paths:
|
||||
properties:
|
||||
progress:
|
||||
type: integer
|
||||
skipped_all:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: tutorial progress
|
||||
|
||||
@@ -562,20 +562,30 @@ async fn list_users_as_super_admin(
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Progress {
|
||||
progress: u64,
|
||||
skipped_all: bool,
|
||||
}
|
||||
async fn get_tutorial_progress(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<Progress> {
|
||||
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: row.skipped_all,
|
||||
}))
|
||||
} else {
|
||||
Ok(Json(Progress {
|
||||
progress: 0,
|
||||
skipped_all: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_tutorial_progress(
|
||||
@@ -583,10 +593,11 @@ async fn update_tutorial_progress(
|
||||
Extension(db): Extension<DB>,
|
||||
Json(progress): Json<Progress>,
|
||||
) -> Result<String> {
|
||||
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
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
@@ -1,51 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { skipAllTodos } from '$lib/tutorialUtils'
|
||||
import AppTutorial from './tutorials/app/AppTutorial.svelte'
|
||||
import TutorialRouter from './tutorials/TutorialRouter.svelte'
|
||||
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 tutorialRouter: TutorialRouter | undefined = $state(undefined)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
function skipAll() {
|
||||
skipAllTodos()
|
||||
tutorialRouter?.runTutorialById(id, options)
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppTutorial
|
||||
bind:this={appTutorial}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
index={7}
|
||||
name="simpleapptutorial"
|
||||
/>
|
||||
|
||||
<BackgroundRunnablesTutorial
|
||||
bind:this={backgroundRunnablesTutorial}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
index={5}
|
||||
name="backgroundrunnables"
|
||||
/>
|
||||
|
||||
<ConnectionTutorial
|
||||
bind:this={connectionTutorial}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
index={6}
|
||||
name="connection"
|
||||
<TutorialRouter
|
||||
bind:this={tutorialRouter}
|
||||
tutorials={[
|
||||
{
|
||||
id: 'backgroundrunnables',
|
||||
component: BackgroundRunnablesTutorial,
|
||||
name: 'backgroundrunnables',
|
||||
supportsSkipSteps: true
|
||||
},
|
||||
{
|
||||
id: 'connection',
|
||||
component: ConnectionTutorial,
|
||||
name: 'connection',
|
||||
supportsSkipSteps: true
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import { initHistory, redo, undo } from '$lib/history.svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
tutorialsToDo,
|
||||
userStore,
|
||||
workspaceStore,
|
||||
usedTriggerKinds
|
||||
@@ -61,11 +60,10 @@
|
||||
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 FlowEditorTutorial from './flows/FlowEditorTutorial.svelte'
|
||||
import Summary from './Summary.svelte'
|
||||
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
|
||||
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
|
||||
@@ -805,8 +803,6 @@
|
||||
|
||||
if (tutorial) {
|
||||
flowTutorials?.runTutorialById(tutorial)
|
||||
} else if ($tutorialsToDo.includes(0) && !$ignoredTutorials.includes(0)) {
|
||||
flowTutorials?.runTutorialById('action')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,13 +1106,7 @@
|
||||
<Dropdown items={moreItems} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if customUi?.topBar?.tutorials != false}
|
||||
<FlowBuilderTutorials
|
||||
on:reload={() => {
|
||||
renderCount += 1
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<FlowEditorTutorial />
|
||||
{#if customUi?.topBar?.diff != false}
|
||||
<Button
|
||||
variant="default"
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { BookOpen, CheckCircle, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import FlowTutorials from './FlowTutorials.svelte'
|
||||
import { tutorialsToDo } from '$lib/stores'
|
||||
|
||||
let targetTutorial: string | undefined = $state(undefined)
|
||||
let flowTutorials: FlowTutorials | undefined = $state(undefined)
|
||||
|
||||
async function getTutorialItems() {
|
||||
const tutorials = [
|
||||
{ displayName: 'Simple flow tutorials', id: 'action' },
|
||||
{ displayName: 'For loops tutorial', id: 'forloop' },
|
||||
{ displayName: 'Branch one tutorial', id: 'branchone' },
|
||||
{ displayName: 'Branch all tutorial', id: 'branchall' },
|
||||
{ displayName: 'Error handler', id: 'error-handler' }
|
||||
]
|
||||
|
||||
return [
|
||||
...tutorials.map((tutorial, index) => ({
|
||||
displayName: tutorial.displayName,
|
||||
action: () => flowTutorials?.runTutorialById(tutorial.id),
|
||||
icon: $tutorialsToDo.includes(index) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(index) ? undefined : 'green'
|
||||
})),
|
||||
{
|
||||
displayName: 'Skip tutorials',
|
||||
action: () => skipAllTodos(),
|
||||
icon: CheckCheck
|
||||
},
|
||||
{
|
||||
displayName: 'Reset tutorials',
|
||||
action: () => resetAllTodos(),
|
||||
icon: RefreshCw
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key $tutorialsToDo}
|
||||
<Dropdown items={getTutorialItems} class="w-fit">
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
id="tutorials-button"
|
||||
startIcon={{ icon: BookOpen }}
|
||||
/>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
{/key}
|
||||
|
||||
<FlowTutorials
|
||||
bind:this={flowTutorials}
|
||||
on:error={({ detail }) => {
|
||||
targetTutorial = detail.detail
|
||||
}}
|
||||
on:skipAll={() => {
|
||||
skipAllTodos()
|
||||
}}
|
||||
on:reload
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
open={targetTutorial !== undefined}
|
||||
title="Tutorial error"
|
||||
confirmationText="Open new tab"
|
||||
on:canceled={() => {
|
||||
targetTutorial = undefined
|
||||
}}
|
||||
on:confirmed={async () => {
|
||||
window.open(`/flows/add?tutorial=${targetTutorial}&nodraft=true`, '_blank')
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span> You need to create a new flow before starting the tutorial.</span>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
@@ -1,64 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { skipAllTodos } from '$lib/tutorialUtils'
|
||||
import FlowBuilderTutorialBranchAll from './tutorials/FlowBuilderTutorialBranchAll.svelte'
|
||||
import FlowBuilderTutorialBranchOne from './tutorials/FlowBuilderTutorialBranchOne.svelte'
|
||||
import FlowBuilderTutorialSimpleFlow from './tutorials/FlowBuilderTutorialSimpleFlow.svelte'
|
||||
import FlowBuilderTutorialForLoop from './tutorials/FlowBuilderTutorialForLoop.svelte'
|
||||
import FlowBuilderTutorialErrorHandler from './tutorials/FlowBuilderTutorialErrorHandler.svelte'
|
||||
import TutorialRouter from './tutorials/TutorialRouter.svelte'
|
||||
import FlowBuilderLiveTutorial from './tutorials/FlowBuilderLiveTutorial.svelte'
|
||||
import TroubleshootFlowTutorial from './tutorials/TroubleshootFlowTutorial.svelte'
|
||||
|
||||
let flowBuilderTutorialSimpleFlow: FlowBuilderTutorialSimpleFlow | undefined = $state(undefined)
|
||||
let flowBuilderTutorialForLoop: FlowBuilderTutorialForLoop | undefined = $state(undefined)
|
||||
let flowBuilderTutorialBranchOne: FlowBuilderTutorialBranchOne | undefined = $state(undefined)
|
||||
let flowBuilderTutorialBranchAll: FlowBuilderTutorialBranchAll | undefined = $state(undefined)
|
||||
let flowBuilderTutorialErrorHandler: FlowBuilderTutorialErrorHandler | undefined =
|
||||
$state(undefined)
|
||||
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
|
||||
|
||||
export function runTutorialById(id: string, indexToInsertAt?: number | undefined) {
|
||||
if (id === 'forloop') {
|
||||
flowBuilderTutorialForLoop?.runTutorial(indexToInsertAt)
|
||||
} else if (id === 'branchone') {
|
||||
flowBuilderTutorialBranchOne?.runTutorial()
|
||||
} else if (id === 'branchall') {
|
||||
flowBuilderTutorialBranchAll?.runTutorial()
|
||||
} else if (id === 'action') {
|
||||
flowBuilderTutorialSimpleFlow?.runTutorial()
|
||||
} else if (id === 'error-handler') {
|
||||
flowBuilderTutorialErrorHandler?.runTutorial()
|
||||
}
|
||||
}
|
||||
|
||||
function skipAll() {
|
||||
skipAllTodos()
|
||||
export function runTutorialById(id: string) {
|
||||
tutorialRouter?.runTutorialById(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<FlowBuilderTutorialSimpleFlow
|
||||
bind:this={flowBuilderTutorialSimpleFlow}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
/>
|
||||
<FlowBuilderTutorialForLoop
|
||||
bind:this={flowBuilderTutorialForLoop}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
/>
|
||||
<FlowBuilderTutorialBranchOne
|
||||
bind:this={flowBuilderTutorialBranchOne}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
/>
|
||||
<FlowBuilderTutorialBranchAll
|
||||
bind:this={flowBuilderTutorialBranchAll}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
/>
|
||||
<FlowBuilderTutorialErrorHandler
|
||||
bind:this={flowBuilderTutorialErrorHandler}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
/>
|
||||
<TutorialRouter
|
||||
bind:this={tutorialRouter}
|
||||
tutorials={[
|
||||
{
|
||||
id: 'flow-live-tutorial',
|
||||
component: FlowBuilderLiveTutorial
|
||||
},
|
||||
{
|
||||
id: 'troubleshoot-flow',
|
||||
component: TroubleshootFlowTutorial
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -5,6 +5,7 @@
|
||||
export let tooltip: string = ''
|
||||
export let documentationLink: string | undefined = undefined
|
||||
export let primary: boolean = true
|
||||
export let childrenWrapperDivClasses: string = ''
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row flex-wrap justify-between pb-2 my-4 mr-2">
|
||||
@@ -31,7 +32,7 @@
|
||||
{/if}
|
||||
|
||||
{#if $$slots.default}
|
||||
<div class="my-2">
|
||||
<div class="my-2 {childrenWrapperDivClasses}">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { skipAllTodos } from '$lib/tutorialUtils'
|
||||
import TroubleshootFlowTutorial from './tutorials/TroubleshootFlowTutorial.svelte'
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
|
||||
let troubleshootFlowTutorial: TroubleshootFlowTutorial | undefined = $state(undefined)
|
||||
|
||||
export function runTutorialById(id: string) {
|
||||
if (id === 'troubleshoot-flow') {
|
||||
troubleshootFlowTutorial?.runTutorial()
|
||||
}
|
||||
}
|
||||
|
||||
function skipAll() {
|
||||
skipAllTodos()
|
||||
}
|
||||
</script>
|
||||
|
||||
<TroubleshootFlowTutorial
|
||||
bind:this={troubleshootFlowTutorial}
|
||||
index={getTutorialIndex('troubleshoot-flow')}
|
||||
on:error
|
||||
on:skipAll={skipAll}
|
||||
on:reload
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import TutorialRouter from './tutorials/TutorialRouter.svelte'
|
||||
import WorkspaceOnboardingTutorial from './tutorials/workspace/WorkspaceOnboardingTutorial.svelte'
|
||||
|
||||
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
|
||||
|
||||
export function runTutorialById(id: string) {
|
||||
tutorialRouter?.runTutorialById(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<TutorialRouter
|
||||
bind:this={tutorialRouter}
|
||||
tutorials={[
|
||||
{
|
||||
id: 'workspace-onboarding',
|
||||
component: WorkspaceOnboardingTutorial
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -428,7 +428,12 @@
|
||||
let appEditorHeader: AppEditorHeader | undefined = $state(undefined)
|
||||
|
||||
export function triggerTutorial() {
|
||||
appEditorHeader?.toggleTutorial()
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tutorial = urlParams.get('tutorial')
|
||||
|
||||
if (tutorial) {
|
||||
appEditorHeader?.runTutorialById(tutorial)
|
||||
}
|
||||
}
|
||||
|
||||
let box: HTMLElement | undefined = $state(undefined)
|
||||
|
||||
@@ -656,8 +656,8 @@
|
||||
|
||||
let appEditorTutorial: AppEditorTutorial | undefined = $state(undefined)
|
||||
|
||||
export function toggleTutorial() {
|
||||
appEditorTutorial?.toggleTutorial()
|
||||
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
|
||||
appEditorTutorial?.runTutorialById(id, options)
|
||||
}
|
||||
|
||||
let appReportingDrawerOpen = $state(false)
|
||||
|
||||
@@ -4,66 +4,35 @@
|
||||
import { BookOpen, CheckCircle, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
|
||||
import { getContext, onMount } from 'svelte'
|
||||
import type { AppViewerContext } from '../types'
|
||||
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
|
||||
import { tutorialsToDo } from '$lib/stores'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { isAppTainted } from '$lib/components/tutorials/utils'
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
|
||||
let appTutorials: AppTutorials | undefined = $state(undefined)
|
||||
let targetTutorial: string | undefined = $state(undefined)
|
||||
|
||||
const { app } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
onMount(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
|
||||
const forkedFromTheHub = urlParams.get('hub')
|
||||
const forkedFromTemplate = urlParams.get('template')
|
||||
|
||||
if (
|
||||
!isAppTainted($app) &&
|
||||
!$ignoredTutorials.includes(7) &&
|
||||
$tutorialsToDo.includes(7) &&
|
||||
!forkedFromTheHub &&
|
||||
!forkedFromTemplate
|
||||
) {
|
||||
appTutorials?.runTutorialById('simpleapptutorial')
|
||||
}
|
||||
})
|
||||
|
||||
export function toggleTutorial() {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tutorial = urlParams.get('tutorial')
|
||||
|
||||
if (tutorial === 'simpleapptutorial') {
|
||||
appTutorials?.runTutorialById('simpleapptutorial')
|
||||
}
|
||||
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
|
||||
appTutorials?.runTutorialById(id, options)
|
||||
}
|
||||
|
||||
async function getTutorialItems() {
|
||||
const backgroundRunnablesIndex = getTutorialIndex('backgroundrunnables')
|
||||
const connectionIndex = getTutorialIndex('connection')
|
||||
|
||||
return [
|
||||
{
|
||||
displayName: 'App tutorial',
|
||||
action: () => appTutorials?.runTutorialById('simpleapptutorial'),
|
||||
index: 7,
|
||||
icon: $tutorialsToDo.includes(7) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(7) ? undefined : 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Background runnables',
|
||||
action: () => appTutorials?.runTutorialById('backgroundrunnables'),
|
||||
index: 5,
|
||||
icon: $tutorialsToDo.includes(5) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(5) ? undefined : 'green'
|
||||
index: backgroundRunnablesIndex,
|
||||
icon: $tutorialsToDo.includes(backgroundRunnablesIndex) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(backgroundRunnablesIndex) ? undefined : 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Connection',
|
||||
action: () => appTutorials?.runTutorialById('connection'),
|
||||
index: 6,
|
||||
icon: $tutorialsToDo.includes(6) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(6) ? undefined : 'green'
|
||||
index: connectionIndex,
|
||||
icon: $tutorialsToDo.includes(connectionIndex) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(connectionIndex) ? undefined : 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Reset tutorials',
|
||||
@@ -96,8 +65,8 @@
|
||||
<AppTutorials
|
||||
bind:this={appTutorials}
|
||||
on:reload
|
||||
on:error={({ detail }) => {
|
||||
targetTutorial = detail.detail
|
||||
on:error={(event: CustomEvent<{ detail: string }>) => {
|
||||
targetTutorial = event.detail.detail
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
id="create-app-button"
|
||||
aiId="apps-create-actions-app"
|
||||
aiDescription="Create a new low-code app"
|
||||
unifiedSize="lg"
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
id="create-flow-button"
|
||||
aiId="flows-create-actions-flow"
|
||||
aiDescription="Create a new flow"
|
||||
unifiedSize="lg"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import FlowTutorials from '../FlowTutorials.svelte'
|
||||
import { BookOpen, CheckCircle, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
|
||||
import { tutorialsToDo } from '$lib/stores'
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
|
||||
let flowTutorials: FlowTutorials | undefined = $state(undefined)
|
||||
|
||||
async function getTutorialItems() {
|
||||
return [
|
||||
{
|
||||
displayName: 'Build a flow',
|
||||
action: () => flowTutorials?.runTutorialById('flow-live-tutorial'),
|
||||
index: getTutorialIndex('flow-live-tutorial'),
|
||||
icon: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) ? undefined : 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Fix a broken flow',
|
||||
action: () => flowTutorials?.runTutorialById('troubleshoot-flow'),
|
||||
index: getTutorialIndex('troubleshoot-flow'),
|
||||
icon: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow')) ? Circle : CheckCircle,
|
||||
iconColor: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow')) ? undefined : 'green'
|
||||
},
|
||||
{
|
||||
displayName: 'Reset tutorials',
|
||||
action: () => resetAllTodos(),
|
||||
icon: RefreshCw
|
||||
},
|
||||
{
|
||||
displayName: 'Skip tutorials',
|
||||
action: () => skipAllTodos(),
|
||||
icon: CheckCheck
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key $tutorialsToDo}
|
||||
<Dropdown items={getTutorialItems}>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: BookOpen }}
|
||||
/>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
{/key}
|
||||
|
||||
<FlowTutorials
|
||||
bind:this={flowTutorials}
|
||||
on:reload
|
||||
on:error
|
||||
on:skipAll
|
||||
/>
|
||||
|
||||
@@ -458,50 +458,52 @@
|
||||
<AssetsDropdownButton {assets} />
|
||||
{/if}
|
||||
</div>
|
||||
<Editor
|
||||
loadAsync
|
||||
folding
|
||||
path={$pathStore + '/' + flowModule.id}
|
||||
bind:websocketAlive
|
||||
bind:this={editor}
|
||||
class="h-full relative"
|
||||
code={flowModule.value.content}
|
||||
scriptLang={flowModule?.value?.language}
|
||||
automaticLayout={true}
|
||||
cmdEnterAction={async () => {
|
||||
selected = 'test'
|
||||
if (selectedId == flowModule.id) {
|
||||
if (flowModule.value.type === 'rawscript' && editor) {
|
||||
flowModule.value.content = editor.getCode()
|
||||
<div id="flow-editor-code-section" class="h-full relative">
|
||||
<Editor
|
||||
loadAsync
|
||||
folding
|
||||
path={$pathStore + '/' + flowModule.id}
|
||||
bind:websocketAlive
|
||||
bind:this={editor}
|
||||
class="h-full relative"
|
||||
code={flowModule.value.content}
|
||||
scriptLang={flowModule?.value?.language}
|
||||
automaticLayout={true}
|
||||
cmdEnterAction={async () => {
|
||||
selected = 'test'
|
||||
if (selectedId == flowModule.id) {
|
||||
if (flowModule.value.type === 'rawscript' && editor) {
|
||||
flowModule.value.content = editor.getCode()
|
||||
}
|
||||
await reload(flowModule)
|
||||
modulePreview?.runTestWithStepArgs()
|
||||
}
|
||||
await reload(flowModule)
|
||||
modulePreview?.runTestWithStepArgs()
|
||||
}
|
||||
}}
|
||||
on:change={async (event) => {
|
||||
const content = event.detail
|
||||
if (flowModule.value.type === 'rawscript') {
|
||||
if (flowModule.value.content !== content) {
|
||||
flowModule.value.content = content
|
||||
}}
|
||||
on:change={async (event) => {
|
||||
const content = event.detail
|
||||
if (flowModule.value.type === 'rawscript') {
|
||||
if (flowModule.value.content !== content) {
|
||||
flowModule.value.content = content
|
||||
}
|
||||
await reload(flowModule)
|
||||
}
|
||||
await reload(flowModule)
|
||||
}
|
||||
}}
|
||||
formatAction={() => {
|
||||
reload(flowModule)
|
||||
saveDraft()
|
||||
}}
|
||||
fixedOverflowWidgets={true}
|
||||
args={Object.entries(flowModule.value.input_transforms).reduce(
|
||||
(acc, [key, obj]) => {
|
||||
acc[key] = obj.type === 'static' ? obj.value : undefined
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)}
|
||||
key={`flow-inline-${$workspaceStore}-${$pathStore}-${flowModule.id}`}
|
||||
moduleId={flowModule.id}
|
||||
/>
|
||||
}}
|
||||
formatAction={() => {
|
||||
reload(flowModule)
|
||||
saveDraft()
|
||||
}}
|
||||
fixedOverflowWidgets={true}
|
||||
args={Object.entries(flowModule.value.input_transforms).reduce(
|
||||
(acc, [key, obj]) => {
|
||||
acc[key] = obj.type === 'static' ? obj.value : undefined
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)}
|
||||
key={`flow-inline-${$workspaceStore}-${$pathStore}-${flowModule.id}`}
|
||||
moduleId={flowModule.id}
|
||||
/>
|
||||
</div>
|
||||
<DiffEditor
|
||||
open={false}
|
||||
bind:this={diffEditor}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
id={id || undefined}
|
||||
class="relative flex gap-1 justify-between items-center w-full overflow-hidden rounded-sm
|
||||
p-2 text-2xs module text-primary"
|
||||
>
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
|
||||
import { getDependentComponents } from '../flowExplorer'
|
||||
import { tutorialsToDo, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import FlowTutorials from '$lib/components/FlowTutorials.svelte'
|
||||
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
|
||||
import { tutorialInProgress } from '$lib/tutorialUtils'
|
||||
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
|
||||
import { replaceId } from '../flowStore.svelte'
|
||||
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
|
||||
@@ -104,8 +102,6 @@
|
||||
flowHasChanged
|
||||
}: Props = $props()
|
||||
|
||||
let flowTutorials: FlowTutorials | undefined = $state(undefined)
|
||||
|
||||
const { customUi, selectionManager, moving, history, flowStateStore, flowStore, pathStore } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
|
||||
@@ -307,14 +303,6 @@
|
||||
noteMode = !noteMode
|
||||
}
|
||||
|
||||
function shouldRunTutorial(tutorialName: string, name: string, index: number) {
|
||||
return (
|
||||
$tutorialsToDo.includes(index) &&
|
||||
name == tutorialName &&
|
||||
!$ignoredTutorials.includes(index) &&
|
||||
!tutorialInProgress()
|
||||
)
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
generateStep: { moduleId: string; instructions: string; lang: ScriptLang }
|
||||
@@ -473,13 +461,7 @@
|
||||
}
|
||||
}}
|
||||
onInsert={async (detail) => {
|
||||
if (shouldRunTutorial('forloop', detail.detail, 1)) {
|
||||
flowTutorials?.runTutorialById('forloop', detail.index)
|
||||
} else if (shouldRunTutorial('branchone', detail.detail, 2)) {
|
||||
flowTutorials?.runTutorialById('branchone')
|
||||
} else if (shouldRunTutorial('branchall', detail.detail, 3)) {
|
||||
flowTutorials?.runTutorialById('branchall')
|
||||
} else {
|
||||
{
|
||||
let originalModules
|
||||
let targetModules
|
||||
if (
|
||||
@@ -675,5 +657,5 @@
|
||||
</div>
|
||||
|
||||
{#if !disableTutorials}
|
||||
<FlowTutorials bind:this={flowTutorials} on:reload />
|
||||
<FlowTutorials on:reload />
|
||||
{/if}
|
||||
|
||||
@@ -527,7 +527,7 @@
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
{:else if filteredItems.length === 0}
|
||||
<NoItemFound />
|
||||
<NoItemFound hasFilters={filter !== '' || archived || filterUserFolders} />
|
||||
{:else if treeView}
|
||||
<TreeViewRoot
|
||||
{items}
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
<div class="flex justify-center items-center h-48">
|
||||
<div class="text-primary text-center">
|
||||
<div class="text-lg font-semibold text-emphasis">No items found</div>
|
||||
<div class="text-xs font-normal text-hint">Try changing your search or filters</div>
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
hasFilters?: boolean
|
||||
}
|
||||
|
||||
let { hasFilters = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if hasFilters}
|
||||
<div class="flex justify-center items-center h-48">
|
||||
<div class="text-primary text-center">
|
||||
<div class="text-lg font-semibold text-emphasis">No items found</div>
|
||||
<div class="text-xs font-normal text-hint">Try changing your search or filters</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-center items-center h-48">
|
||||
<div class="text-primary text-center">
|
||||
<div class="text-lg font-semibold text-emphasis">Welcome to Windmill</div>
|
||||
<div class="text-xs font-normal text-hint">Get started by creating your first script, flow, or app</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { GraduationCap, X } from 'lucide-svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$app/navigation'
|
||||
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'
|
||||
let isDismissed = $state(false)
|
||||
|
||||
onMount(() => {
|
||||
// Check if banner has been dismissed
|
||||
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,
|
||||
8000
|
||||
)
|
||||
}
|
||||
|
||||
function goToTutorials() {
|
||||
goto(`${base}/tutorials`)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isDismissed}
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 px-4 py-3 rounded-lg border border-light bg-surface-tertiary mb-4"
|
||||
>
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<GraduationCap size={20} class="text-accent-primary flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
|
||||
Learn with interactive tutorials
|
||||
</div>
|
||||
<div class="text-hint text-3xs truncate text-left font-normal">
|
||||
Get started quickly with step-by-step guides on building flows, scripts, and more.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<Button size="xs" variant="accent" onclick={goToTutorials} startIcon={{ icon: GraduationCap }}>
|
||||
View tutorials
|
||||
</Button>
|
||||
<button
|
||||
onclick={dismissBanner}
|
||||
class="p-1.5 rounded hover:bg-surface-hover text-secondary hover:text-primary transition-colors"
|
||||
aria-label="Dismiss tutorial banner"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2, Circle, RefreshCw, CheckCheck } from 'lucide-svelte'
|
||||
import type { ComponentType } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
icon: ComponentType
|
||||
title: string
|
||||
description: string
|
||||
onclick: () => void
|
||||
isCompleted?: boolean
|
||||
disabled?: boolean
|
||||
comingSoon?: boolean
|
||||
onReset?: () => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
onclick,
|
||||
isCompleted = false,
|
||||
disabled = false,
|
||||
comingSoon = false,
|
||||
onReset,
|
||||
onComplete
|
||||
}: Props = $props()
|
||||
|
||||
let isHovered = $state(false)
|
||||
|
||||
// Determine which action button to show
|
||||
const actionButton = $derived(() => {
|
||||
if (isCompleted && isHovered && onReset) {
|
||||
return {
|
||||
icon: RefreshCw,
|
||||
label: 'Reset',
|
||||
onClick: onReset
|
||||
}
|
||||
}
|
||||
if (!isCompleted && isHovered && onComplete) {
|
||||
return {
|
||||
icon: CheckCheck,
|
||||
label: 'Mark as completed',
|
||||
onClick: onComplete
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function handleAction(e: MouseEvent | KeyboardEvent) {
|
||||
const button = actionButton()
|
||||
if (!button) return
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
button.onClick()
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={disabled || comingSoon ? undefined : onclick}
|
||||
disabled={disabled || comingSoon}
|
||||
class="group relative flex items-center gap-4 w-full px-4 py-3 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b border-b border-light transition-colors text-left last:border-b-0 {disabled || comingSoon
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-surface-hover'}"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<Icon size={20} class="flex-shrink-0 text-accent-primary transition-colors" />
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-emphasis flex-wrap text-left text-xs font-semibold {!disabled && !comingSoon
|
||||
? 'group-hover:text-accent-primary'
|
||||
: ''} transition-colors">
|
||||
{title}
|
||||
{#if comingSoon}
|
||||
<span class="ml-2 text-3xs text-secondary">(Coming soon)</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-hint text-3xs truncate text-left font-normal">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div
|
||||
role="status"
|
||||
class="flex items-center gap-1.5 flex-shrink-0"
|
||||
onmouseenter={() => (isHovered = true)}
|
||||
onmouseleave={() => (isHovered = false)}
|
||||
>
|
||||
{#if actionButton()}
|
||||
{@const button = actionButton()!}
|
||||
{@const ActionIcon = button.icon}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={handleAction}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleAction(e)
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-1.5 px-2 py-1 text-xs font-normal text-secondary hover:text-primary hover:bg-surface-hover rounded transition-colors cursor-pointer"
|
||||
>
|
||||
<ActionIcon size={14} class="flex-shrink-0" />
|
||||
{button.label}
|
||||
</div>
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs font-normal {isCompleted
|
||||
? 'text-green-500'
|
||||
: 'text-blue-300'}"
|
||||
>
|
||||
{isCompleted ? 'Completed' : 'Not started'}
|
||||
</span>
|
||||
{#if isCompleted}
|
||||
<CheckCircle2 size={14} class="text-green-500 flex-shrink-0" />
|
||||
{:else}
|
||||
<Circle size={14} class="text-blue-300 flex-shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
id="create-script-button"
|
||||
{aiId}
|
||||
{aiDescription}
|
||||
unifiedSize="lg"
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
LayoutDashboard,
|
||||
Building,
|
||||
Calendar,
|
||||
ServerCog
|
||||
ServerCog,
|
||||
GraduationCap
|
||||
} from 'lucide-svelte'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
@@ -21,7 +22,9 @@
|
||||
enterpriseLicense,
|
||||
superadmin,
|
||||
userWorkspaces,
|
||||
workspaceStore
|
||||
workspaceStore,
|
||||
tutorialsToDo,
|
||||
skippedAll
|
||||
} from '$lib/stores'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { USER_SETTINGS_HASH } from './settings'
|
||||
@@ -52,10 +55,22 @@
|
||||
[
|
||||
{ label: 'Home', id: 'home', href: `${base}/`, icon: Home },
|
||||
{ label: 'Runs', id: 'runs', href: `${base}/runs`, icon: Play },
|
||||
{ label: 'Schedules', id: 'schedules', href: `${base}/schedules`, icon: Calendar }
|
||||
{ label: 'Schedules', id: 'schedules', href: `${base}/schedules`, icon: Calendar },
|
||||
// Add Tutorials to main menu only if not all completed and not skipped
|
||||
...($tutorialsToDo.length > 0 && !$skippedAll
|
||||
? [
|
||||
{
|
||||
label: 'Tutorials',
|
||||
id: 'tutorials',
|
||||
href: `${base}/tutorials`,
|
||||
icon: GraduationCap
|
||||
}
|
||||
]
|
||||
: [])
|
||||
].filter(
|
||||
(link) =>
|
||||
link.id === 'home' ||
|
||||
link.id === 'tutorials' ||
|
||||
($userWorkspaces &&
|
||||
$workspaceStore &&
|
||||
$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] ===
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
workspaceStore,
|
||||
isCriticalAlertsUIOpen,
|
||||
enterpriseLicense,
|
||||
devopsRole
|
||||
devopsRole,
|
||||
tutorialsToDo,
|
||||
skippedAll
|
||||
} from '$lib/stores'
|
||||
import { syncTutorialsTodos } from '$lib/tutorialUtils'
|
||||
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
|
||||
import {
|
||||
BookOpen,
|
||||
@@ -21,6 +24,7 @@
|
||||
FolderCog,
|
||||
FolderOpen,
|
||||
Github,
|
||||
GraduationCap,
|
||||
HelpCircle,
|
||||
Home,
|
||||
LogOut,
|
||||
@@ -83,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)
|
||||
@@ -92,6 +96,8 @@
|
||||
} else {
|
||||
recentChangelogs = changelogs.slice(0, 3)
|
||||
}
|
||||
// Sync tutorial progress on mount
|
||||
await syncTutorialsTodos()
|
||||
})
|
||||
|
||||
function openChangelogs() {
|
||||
@@ -105,33 +111,45 @@
|
||||
label: 'Help',
|
||||
icon: HelpCircle,
|
||||
subItems: [
|
||||
{
|
||||
label: 'Tutorials',
|
||||
href: `${base}/tutorials`,
|
||||
icon: GraduationCap,
|
||||
aiId: 'sidebar-menu-link-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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -202,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([
|
||||
{
|
||||
@@ -619,7 +649,7 @@
|
||||
{/snippet}
|
||||
{#snippet children({ item })}
|
||||
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
|
||||
<MenuItem href={subItem.href} class={itemClass} target="_blank" {item}>
|
||||
<MenuItem href={subItem.href} class={itemClass} target={subItem.external !== false ? "_blank" : undefined} {item}>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
{#if subItem.icon}
|
||||
<subItem.icon size={16} />
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../flows/types'
|
||||
import { isFlowTainted, triggerPointerDown, clickButtonBySelector } from './utils'
|
||||
import Tutorial from './Tutorial.svelte'
|
||||
import type { DriveStep } from 'driver.js'
|
||||
import { initFlow } from '../flows/flowStore.svelte'
|
||||
import type { Flow, FlowModule } from '$lib/gen'
|
||||
import { loadFlowModuleState } from '../flows/flowStateUtils.svelte'
|
||||
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>('FlowEditorContext')
|
||||
|
||||
interface Props {
|
||||
index: number
|
||||
}
|
||||
|
||||
let { index }: Props = $props()
|
||||
|
||||
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
|
||||
const DELAY_MEDIUM = 300
|
||||
const DELAY_LONG = 500
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
endElement: HTMLElement,
|
||||
transitionDuration: number = 1.5
|
||||
): Promise<HTMLElement> {
|
||||
const fakeCursor = document.createElement('div')
|
||||
fakeCursor.style.cssText = `
|
||||
position: fixed;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(59, 130, 246, 0.8);
|
||||
border: 2px solid white;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
transition: all ${transitionDuration}s ease-in-out;
|
||||
`
|
||||
document.body.appendChild(fakeCursor)
|
||||
|
||||
const endRect = endElement.getBoundingClientRect()
|
||||
let startX: number, startY: number
|
||||
|
||||
if (startElement) {
|
||||
const startRect = startElement.getBoundingClientRect()
|
||||
startX = startRect.left + startRect.width / 2
|
||||
startY = startRect.top + startRect.height / 2
|
||||
} else {
|
||||
startX = endRect.left - 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 function runTutorial() {
|
||||
try {
|
||||
localStorage.removeItem('flow')
|
||||
} catch (e) {
|
||||
console.error('Error clearing localStorage', e)
|
||||
}
|
||||
tutorial?.runTutorial()
|
||||
}
|
||||
|
||||
const flowJson: Flow = {
|
||||
summary: '',
|
||||
description: '',
|
||||
value: {
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content:
|
||||
'export async function main(celsius: number) {\n // Validate that the temperature is within a reasonable range\n if (celsius < -273.15) {\n throw new Error("Temperature cannot be below absolute zero (-273.15°C)");\n }\n \n if (celsius > 1000) {\n throw new Error("Temperature seems unreasonably high. Please check your input.");\n }\n \n return {\n celsius: celsius,\n isValid: true,\n message: "Temperature is valid"\n };\n}',
|
||||
language: 'bun',
|
||||
input_transforms: {}
|
||||
},
|
||||
summary: 'Validate temperature input'
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content:
|
||||
'export async function main(celsius: number) {\n // Convert Celsius to Fahrenheit using the formula: F = (C × 9/5) + 32\n const fahrenheit = (celsius * 9/5) + 32;\n \n return {\n celsius: celsius,\n fahrenheit: Math.round(fahrenheit * 100) / 100 // Round to 2 decimal places\n };\n}',
|
||||
language: 'bun',
|
||||
input_transforms: {
|
||||
celsius: {
|
||||
expr: 'results.a.celsius',
|
||||
type: 'javascript'
|
||||
}
|
||||
}
|
||||
},
|
||||
summary: 'Convert to Fahrenheit'
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content:
|
||||
'export async function main(celsius: number, fahrenheit: number) {\n // Categorize the temperature based on Celsius value\n let category: string;\n let emoji: string;\n \n if (celsius < 0) {\n category = "Freezing";\n emoji = "❄️";\n } else if (celsius < 10) {\n category = "Cold";\n emoji = "🥶";\n } else if (celsius < 20) {\n category = "Cool";\n emoji = "😊";\n } else if (celsius < 30) {\n category = "Warm";\n emoji = "☀️";\n } else {\n category = "Hot";\n emoji = "🔥";\n }\n \n return {\n celsius: celsius,\n fahrenheit: fahrenheit,\n category: category,\n emoji: emoji\n };\n}',
|
||||
language: 'bun',
|
||||
input_transforms: {
|
||||
celsius: {
|
||||
expr: 'results.b.celsius',
|
||||
type: 'javascript'
|
||||
},
|
||||
fahrenheit: {
|
||||
expr: 'results.b.fahrenheit',
|
||||
type: 'javascript'
|
||||
}
|
||||
}
|
||||
},
|
||||
summary: 'Categorize temperature'
|
||||
}
|
||||
]
|
||||
},
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
properties: {
|
||||
celsius: {
|
||||
type: 'number',
|
||||
description: 'Temperature in Celsius',
|
||||
default: ""
|
||||
}
|
||||
},
|
||||
required: ['celsius'],
|
||||
order: ['celsius']
|
||||
},
|
||||
path: '',
|
||||
edited_at: '',
|
||||
edited_by: '',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
index={index}
|
||||
name="flow-live-tutorial"
|
||||
tainted={isFlowTainted(flowStore.val)}
|
||||
on:error
|
||||
on:skipAll
|
||||
getSteps={(driver) => {
|
||||
const steps: DriveStep[] = [
|
||||
{
|
||||
popover: {
|
||||
title: 'Build your first flow',
|
||||
description:
|
||||
"Let's create a temperature converter that validates input and converts Celsius to Fahrenheit.",
|
||||
onNextClick: async () => {
|
||||
const emptyFlow: Flow = {
|
||||
summary: '',
|
||||
description: '',
|
||||
value: { modules: [] },
|
||||
schema: flowJson.schema,
|
||||
path: '',
|
||||
edited_at: '',
|
||||
edited_by: '',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
}
|
||||
await initFlow(emptyFlow, flowStore as StateStore<Flow>, flowStateStore)
|
||||
|
||||
driver.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-virtual-Input',
|
||||
onHighlighted: async () => {
|
||||
step2Complete = false
|
||||
|
||||
await wait(DELAY_MEDIUM)
|
||||
triggerPointerDown('#flow-editor-virtual-Input')
|
||||
await wait(DELAY_SHORT)
|
||||
selectionManager.selectId('Input')
|
||||
await wait(200)
|
||||
|
||||
const overlay = getDriverOverlay()
|
||||
if (overlay) {
|
||||
overlay.style.width = '50%'
|
||||
overlay.style.right = 'auto'
|
||||
overlay.style.left = '0'
|
||||
}
|
||||
|
||||
const celsiusInput = document.querySelector('input[type="number"][placeholder=""]') as HTMLInputElement
|
||||
if (celsiusInput) {
|
||||
celsiusInput.value = ''
|
||||
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await wait(DELAY_MEDIUM)
|
||||
|
||||
celsiusInput.value = '2'
|
||||
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await wait(400)
|
||||
|
||||
celsiusInput.value = '25'
|
||||
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
step2Complete = true
|
||||
}
|
||||
},
|
||||
popover: {
|
||||
title: 'Set the input',
|
||||
description: 'Every flow starts with input. Here we define a temperature in Celsius.',
|
||||
side: 'bottom',
|
||||
align: 'start',
|
||||
onNextClick: () => {
|
||||
if (!step2Complete) {
|
||||
sendUserToast('Please wait for the input to be filled...', false, [], undefined, 3000)
|
||||
return
|
||||
}
|
||||
driver.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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
|
||||
if (button) {
|
||||
const fakeCursor1 = await createFakeCursor(null, button, 1.5)
|
||||
await wait(DELAY_SHORT)
|
||||
button.click()
|
||||
fakeCursor1.remove()
|
||||
}
|
||||
|
||||
const overlay = getDriverOverlay()
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none'
|
||||
}
|
||||
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
const spans = Array.from(document.querySelectorAll('span'))
|
||||
const bunSpan = spans.find(span => span.textContent?.includes('TypeScript (Bun)')) as HTMLElement
|
||||
|
||||
if (bunSpan) {
|
||||
// Animate cursor from add step button to TypeScript (Bun) span
|
||||
const fakeCursor2 = await createFakeCursor(button, bunSpan, 1.5)
|
||||
await wait(DELAY_MEDIUM)
|
||||
fakeCursor2.remove()
|
||||
|
||||
// Automatically trigger next step after cursor animation
|
||||
await wait(DELAY_SHORT)
|
||||
|
||||
// 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
|
||||
}
|
||||
// Clear content after module creation if it's a rawscript
|
||||
if ('content' in module.value) {
|
||||
module.value = { ...module.value, content: '' } as typeof module.value
|
||||
}
|
||||
|
||||
await addModuleToFlow(module)
|
||||
|
||||
await wait(700)
|
||||
|
||||
// Restore overlay
|
||||
const overlay = getDriverOverlay()
|
||||
if (overlay) {
|
||||
overlay.style.display = ''
|
||||
}
|
||||
|
||||
step3Complete = true
|
||||
driver.moveNext()
|
||||
}
|
||||
},
|
||||
popover: {
|
||||
title: 'Choose TypeScript',
|
||||
description: 'Pick TypeScript (Bun) to write our validation script.',
|
||||
side: 'top',
|
||||
onNextClick: () => {
|
||||
if (!step3Complete) {
|
||||
sendUserToast('Please wait for the script to be created...', false, [], undefined, 3000)
|
||||
return
|
||||
}
|
||||
driver.moveNext()
|
||||
},
|
||||
onPrevClick: () => {
|
||||
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#a',
|
||||
onHighlighted: async () => {
|
||||
// Reset the flag when step starts
|
||||
step4Complete = false
|
||||
|
||||
selectionManager.selectId('a')
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
const overlay = getDriverOverlay()
|
||||
if (overlay) {
|
||||
overlay.style.width = '50%'
|
||||
overlay.style.right = 'auto'
|
||||
overlay.style.left = '0'
|
||||
}
|
||||
|
||||
// First, type the summary
|
||||
await wait(DELAY_MEDIUM)
|
||||
const summaryInput = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement
|
||||
if (summaryInput) {
|
||||
const summaryText = 'Validate temperature input'
|
||||
await typeText(summaryInput, summaryText)
|
||||
updateModuleSummary('a', summaryText)
|
||||
await wait(DELAY_LONG)
|
||||
}
|
||||
|
||||
// Then, type the code
|
||||
let editorState = get(currentEditor)
|
||||
let attempts = 0
|
||||
while (attempts < 20) {
|
||||
if (editorState && editorState.type === 'script' && editorState.stepId === 'a') {
|
||||
break
|
||||
}
|
||||
await wait(100)
|
||||
editorState = get(currentEditor)
|
||||
attempts++
|
||||
}
|
||||
|
||||
if (editorState && editorState.type === 'script') {
|
||||
const editor = editorState.editor
|
||||
const moduleA = flowJson.value.modules.find(m => m.id === 'a')
|
||||
const codeToType = (moduleA?.value && 'content' in moduleA.value) ? moduleA.value.content : ''
|
||||
|
||||
if (codeToType) {
|
||||
editor.setCode('', true)
|
||||
await wait(200)
|
||||
|
||||
let currentText = ''
|
||||
for (let i = 0; i < codeToType.length; i++) {
|
||||
const char = codeToType[i]
|
||||
currentText += char
|
||||
editor.setCode(currentText, true)
|
||||
const delay = char === '\n' ? DELAY_CODE_NEWLINE : DELAY_CODE_CHAR
|
||||
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(DELAY_MEDIUM)
|
||||
const model = editor.getModel()
|
||||
if (model && 'setValue' in model) {
|
||||
model.setValue(currentText + '\n')
|
||||
}
|
||||
|
||||
// Mark step 4 as complete
|
||||
step4Complete = true
|
||||
}
|
||||
}
|
||||
},
|
||||
popover: {
|
||||
title: 'Add validation logic',
|
||||
description:
|
||||
"Watch as we write code to validate the temperature input.",
|
||||
side: 'bottom',
|
||||
onNextClick: () => {
|
||||
// Only proceed if code writing is complete
|
||||
if (!step4Complete) {
|
||||
sendUserToast('Please wait for the code to finish typing...', false, [], undefined, 3000)
|
||||
return
|
||||
}
|
||||
|
||||
const driverOverlay = getDriverOverlay()
|
||||
if (driverOverlay) {
|
||||
driverOverlay.style.display = 'none'
|
||||
}
|
||||
|
||||
const customOverlay = document.createElement('div')
|
||||
customOverlay.className = 'tutorial-custom-overlay'
|
||||
customOverlay.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
clip-path: polygon(
|
||||
0 0, 100% 0, 100% 50%, 50% 50%, 50% 100%, 0 100%
|
||||
);
|
||||
`
|
||||
document.body.appendChild(customOverlay)
|
||||
|
||||
driver.moveNext()
|
||||
},
|
||||
onPrevClick: () => {
|
||||
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
onHighlighted: async () => {
|
||||
step5Complete = false
|
||||
|
||||
// Create a single cursor that will move continuously
|
||||
const fakeCursor = document.createElement('div')
|
||||
fakeCursor.style.cssText = `
|
||||
position: fixed;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(59, 130, 246, 0.8);
|
||||
border: 2px solid white;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
transition: all 1.5s ease-in-out;
|
||||
`
|
||||
document.body.appendChild(fakeCursor)
|
||||
|
||||
// Step 1: Move to and click plug button
|
||||
document.querySelector('#flow-editor-plug')?.parentElement?.classList.remove('opacity-0')
|
||||
await wait(DELAY_SHORT)
|
||||
const plugButton = document.querySelector('#flow-editor-plug') as HTMLElement
|
||||
if (plugButton) {
|
||||
const plugRect = plugButton.getBoundingClientRect()
|
||||
// Start from off-screen left
|
||||
fakeCursor.style.left = `${plugRect.left - 100}px`
|
||||
fakeCursor.style.top = `${plugRect.top + plugRect.height / 2}px`
|
||||
await wait(DELAY_SHORT)
|
||||
// Move to plug button
|
||||
fakeCursor.style.left = `${plugRect.left + plugRect.width / 2}px`
|
||||
fakeCursor.style.top = `${plugRect.top + plugRect.height / 2}px`
|
||||
await wait(DELAY_ANIMATION)
|
||||
await wait(DELAY_MEDIUM)
|
||||
clickButtonBySelector('#flow-editor-plug')
|
||||
}
|
||||
|
||||
await wait(DELAY_MEDIUM)
|
||||
|
||||
// Step 2: Move to and click flow_input.celsius
|
||||
const targetButton = document.querySelector('button[title="flow_input.celsius"]') as HTMLElement
|
||||
if (targetButton) {
|
||||
await moveCursorToElement(fakeCursor, targetButton, DELAY_ANIMATION_LONG)
|
||||
await wait(DELAY_MEDIUM)
|
||||
const clickEvent = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window
|
||||
})
|
||||
targetButton.dispatchEvent(clickEvent)
|
||||
}
|
||||
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Step 3: Move to and click Test this step tab
|
||||
const testTabButton = findButtonByText('Test this step', ['border-b-2', 'cursor-pointer'])
|
||||
|
||||
if (testTabButton) {
|
||||
await moveCursorToElement(fakeCursor, testTabButton, DELAY_ANIMATION)
|
||||
await wait(DELAY_SHORT)
|
||||
testTabButton.click()
|
||||
}
|
||||
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Step 4: Move to and click Run button
|
||||
const testActionButton = findButtonByText('Run', ['bg-surface-accent-primary', 'w-full'])
|
||||
|
||||
if (testActionButton) {
|
||||
await moveCursorToElement(fakeCursor, testActionButton, DELAY_ANIMATION)
|
||||
await wait(DELAY_MEDIUM)
|
||||
testActionButton.click()
|
||||
await wait(DELAY_MEDIUM)
|
||||
}
|
||||
|
||||
// Remove cursor at the end
|
||||
fakeCursor.remove()
|
||||
|
||||
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()
|
||||
},
|
||||
onPrevClick: () => {
|
||||
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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++) {
|
||||
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
|
||||
}
|
||||
|
||||
await addModuleToFlow(module)
|
||||
}
|
||||
|
||||
await wait(700)
|
||||
|
||||
// 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(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(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(DELAY_ANIMATION)
|
||||
await wait(DELAY_MEDIUM)
|
||||
selectionManager.selectId('b')
|
||||
}
|
||||
|
||||
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'
|
||||
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) {
|
||||
await moveCursorToElement(fakeCursor, scriptC, DELAY_ANIMATION)
|
||||
await wait(DELAY_SHORT)
|
||||
selectionManager.selectId('c')
|
||||
}
|
||||
|
||||
await wait(DELAY_LONG)
|
||||
|
||||
// Type summary for script 'c'
|
||||
const summaryInputC = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement
|
||||
if (summaryInputC) {
|
||||
const summaryTextC = 'Categorize temperature'
|
||||
await typeText(summaryInputC, summaryTextC)
|
||||
updateModuleSummary('c', summaryTextC)
|
||||
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()
|
||||
|
||||
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) {
|
||||
driverOverlay.style.display = ''
|
||||
driverOverlay.style.width = ''
|
||||
driverOverlay.style.right = ''
|
||||
driverOverlay.style.left = ''
|
||||
}
|
||||
driver.moveNext()
|
||||
},
|
||||
onPrevClick: () => {
|
||||
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-test-flow',
|
||||
popover: {
|
||||
title: 'Ready to test!',
|
||||
description: 'Run the complete flow and see your temperature converter in action.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu or in the <strong>Help</strong> submenu.</p>',
|
||||
onNextClick: () => {
|
||||
updateProgress(index)
|
||||
driver.destroy()
|
||||
},
|
||||
onPrevClick: () => {
|
||||
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
return steps
|
||||
}}
|
||||
/>
|
||||
@@ -1,101 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../flows/types'
|
||||
import { triggerPointerDown } from './utils'
|
||||
import Tutorial from './Tutorial.svelte'
|
||||
import { updateProgress } from '$lib/tutorialUtils'
|
||||
|
||||
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
let tutorial: Tutorial | undefined = undefined
|
||||
|
||||
export function runTutorial(indexToInsertAt?: number | undefined) {
|
||||
tutorial?.runTutorial({ indexToInsertAt })
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
index={3}
|
||||
name="branchall"
|
||||
on:error
|
||||
on:skipAll
|
||||
getSteps={(driver, options) => {
|
||||
const index = options?.indexToInsertAt ?? flowStore.val.value.modules.length
|
||||
|
||||
const steps = [
|
||||
{
|
||||
popover: {
|
||||
title: 'Branch all tutorial',
|
||||
description:
|
||||
'Learn how to build our first branch to be executed on a condition. You can use arrow keys to navigate'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: `#flow-editor-add-step-${index}`,
|
||||
popover: {
|
||||
title: 'Add a step',
|
||||
description: 'Click here to add a step to your flow',
|
||||
onNextClick: () => {
|
||||
triggerPointerDown(`#flow-editor-add-step-${index}`)
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
popover: {
|
||||
title: 'Steps kind',
|
||||
description: "Choose the kind of step you want to add. Let's start with a simple action"
|
||||
},
|
||||
element: '#flow-editor-insert-module'
|
||||
},
|
||||
|
||||
{
|
||||
popover: {
|
||||
title: 'Insert Branch all',
|
||||
description: "Let's pick branch all",
|
||||
onNextClick: () => {
|
||||
triggerPointerDown('#flow-editor-flow-kind-branch-to-all')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
},
|
||||
element: '#flow-editor-flow-kind-branch-to-all'
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-branch-all-wrapper',
|
||||
popover: {
|
||||
title: 'Branches',
|
||||
description:
|
||||
'Here you can add a summary to a branch, or configure the branches to run in parallel',
|
||||
onNextClick: () => {
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Add steps',
|
||||
description: 'You can now add step to one of the branches',
|
||||
onNextClick: () => {
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
|
||||
updateProgress(3)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return steps
|
||||
}}
|
||||
/>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../flows/types'
|
||||
import { clickButtonBySelector, updateFlowModuleById, triggerPointerDown } from './utils'
|
||||
import Tutorial from './Tutorial.svelte'
|
||||
import { updateProgress } from '$lib/tutorialUtils'
|
||||
import { nextId } from '../flows/flowModuleNextId'
|
||||
|
||||
const { flowStore, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let tutorial: Tutorial | undefined = undefined
|
||||
|
||||
export function runTutorial(indexToInsertAt?: number | undefined) {
|
||||
tutorial?.runTutorial({ indexToInsertAt })
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
index={2}
|
||||
name="branchone"
|
||||
on:error
|
||||
on:skipAll
|
||||
getSteps={(driver, options) => {
|
||||
const id = nextId(flowStateStore.val, flowStore.val)
|
||||
const index = options?.indexToInsertAt ?? flowStore.val.value.modules.length
|
||||
|
||||
const steps = [
|
||||
{
|
||||
popover: {
|
||||
title: 'Branch one tutorial',
|
||||
description:
|
||||
'Learn how to build our first branch to be executed on a condition. You can use arrow keys to navigate'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: `#flow-editor-add-step-${index}`,
|
||||
popover: {
|
||||
title: 'Branch one',
|
||||
description: 'Windmill supports branches, let us add one',
|
||||
onNextClick: () => {
|
||||
triggerPointerDown(`#flow-editor-add-step-${index}`)
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Steps kind',
|
||||
description: 'Choose the kind of step you want to add.'
|
||||
},
|
||||
element: '#flow-editor-insert-module'
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Insert Branch one',
|
||||
description: "Let's pick branch one",
|
||||
onNextClick: () => {
|
||||
triggerPointerDown('#flow-editor-flow-kind-branch-to-one')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
},
|
||||
element: '#flow-editor-flow-kind-branch-to-one'
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-edit-predicate',
|
||||
popover: {
|
||||
title: 'Edit predicate',
|
||||
description: 'Click here to edit the predicate of your branch',
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-edit-predicate')
|
||||
|
||||
updateFlowModuleById(flowStore.val, id, (module) => {
|
||||
if (module.value.type === 'branchone') {
|
||||
module.value.branches[0].expr = "result.a === 'foo'"
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-branch-one-wrapper',
|
||||
popover: {
|
||||
title: 'Predicate saved',
|
||||
description: 'You can now see the predicate of your branch',
|
||||
onNextClick: () => {
|
||||
dispatch('reload')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Add steps',
|
||||
description: 'You can now add a step to one of the branches',
|
||||
onNextClick: () => {
|
||||
setTimeout(() => {
|
||||
updateProgress(2)
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
return steps
|
||||
}}
|
||||
/>
|
||||
@@ -1,136 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../flows/types'
|
||||
import Tutorial from './Tutorial.svelte'
|
||||
import { clickButtonBySelector, triggerPointerDown } from './utils'
|
||||
import { updateProgress } from '$lib/tutorialUtils'
|
||||
|
||||
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
let tutorial: Tutorial | undefined = undefined
|
||||
|
||||
export function runTutorial() {
|
||||
tutorial?.runTutorial()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
index={4}
|
||||
name="error-handler"
|
||||
tainted={false}
|
||||
on:error
|
||||
on:skipAll
|
||||
getSteps={(driver) => [
|
||||
{
|
||||
popover: {
|
||||
title: 'Error handler tutorial',
|
||||
description: 'Learn how to recover from an error. You can use arrow keys to navigate.',
|
||||
onNextClick: () => {
|
||||
flowStore.val.value.modules = [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content:
|
||||
'// import * as wmill from "npm:windmill-client@1"\n\nexport async function main(x: string) {\n throw new Error("Fake error")\n}\n',
|
||||
language: 'deno',
|
||||
input_transforms: {
|
||||
x: {
|
||||
type: 'static',
|
||||
value: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-add-step-error-handler-button',
|
||||
popover: {
|
||||
title: 'Error handler',
|
||||
description:
|
||||
'You can add an error handler to your flow. It will be executed if any of the steps in the flow fails.',
|
||||
|
||||
onNextClick: () => {
|
||||
triggerPointerDown('#flow-editor-add-step-error-handler-button')
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Steps kind',
|
||||
description: "Choose the kind of step you want to add. Let's start with a simple action"
|
||||
},
|
||||
element: '#flow-editor-insert-module'
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-flow-providers',
|
||||
popover: {
|
||||
title: 'Action configuration',
|
||||
description: 'An action can be inlined, imported from your workspace or the Hub.'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-flow-atoms',
|
||||
popover: {
|
||||
title: 'Supported languages',
|
||||
description: 'Windmill support the following languages/runtimes.'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-new-bun',
|
||||
popover: {
|
||||
title: 'Typescript',
|
||||
description: "Let's create a Typescript error handler for your flow",
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-new-bun')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-test-flow',
|
||||
popover: {
|
||||
title: 'Test your flow',
|
||||
description: 'We can now test our flow',
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-test-flow')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
element: '#flow-editor-test-flow-drawer',
|
||||
popover: {
|
||||
title: 'Test your flow',
|
||||
description:
|
||||
'Finally we can test our flow, and view how the error handler is executed when a step fails',
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-test-flow-drawer')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
|
||||
updateProgress(4)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -1,279 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../flows/types'
|
||||
import { emptyFlowModuleState } from '../flows/utils.svelte'
|
||||
import { clickButtonBySelector, updateFlowModuleById, triggerPointerDown } from './utils'
|
||||
import Tutorial from './Tutorial.svelte'
|
||||
import { updateProgress } from '$lib/tutorialUtils'
|
||||
import { nextId } from '../flows/flowModuleNextId'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const { flowStore, selectionManager, flowStateStore } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
let tutorial: Tutorial | undefined = undefined
|
||||
|
||||
export function runTutorial(indexToInsertAt?: number | undefined) {
|
||||
tutorial?.runTutorial({ indexToInsertAt })
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
index={1}
|
||||
name="forloop"
|
||||
tainted={false}
|
||||
on:error
|
||||
on:skipAll
|
||||
getSteps={(driver, options) => {
|
||||
const id = nextId(flowStateStore.val, flowStore.val)
|
||||
const index = options?.indexToInsertAt ?? flowStore.val.value.modules.length
|
||||
|
||||
let tempId = ''
|
||||
|
||||
return [
|
||||
{
|
||||
popover: {
|
||||
title: 'For loops tutorial',
|
||||
description:
|
||||
'Learn how to build our first for loop to iterate on. You can use arrow keys to navigate.'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
element: `#flow-editor-add-step-${index}`,
|
||||
popover: {
|
||||
title: 'Add a step',
|
||||
description: 'Click here to add a step to your flow',
|
||||
onNextClick: () => {
|
||||
triggerPointerDown(`#flow-editor-add-step-${index}`)
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
popover: {
|
||||
title: 'Steps kind',
|
||||
description: "Choose the kind of step you want to add. Let's start with a simple action"
|
||||
},
|
||||
element: '#flow-editor-insert-module'
|
||||
},
|
||||
|
||||
{
|
||||
popover: {
|
||||
title: 'Insert a loop',
|
||||
description: "Let's pick a for loop",
|
||||
onNextClick: () => {
|
||||
triggerPointerDown('#flow-editor-flow-kind-for-loop')
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
},
|
||||
element: '#flow-editor-flow-kind-for-loop'
|
||||
},
|
||||
|
||||
{
|
||||
element: '#flow-editor-iterator-expression',
|
||||
popover: {
|
||||
title: 'Iterator expression',
|
||||
description:
|
||||
'The iterator expression is a JavaScript expression that respresents the array to iterate on. Here we will iterate on the firstname input letter by letter',
|
||||
onNextClick: () => {
|
||||
updateFlowModuleById(flowStore.val, id, (module) => {
|
||||
if (module.value.type === 'forloopflow') {
|
||||
if (module.value.iterator.type === 'javascript') {
|
||||
module.value.iterator.expr = '[1,2,3]'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
dispatch('reload')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
element: '#flow-editor-iterator-expression',
|
||||
popover: {
|
||||
title: 'Iterator expression',
|
||||
description:
|
||||
'We can refer to the result of previous steps using the results object: results.a or use static values like [1,2,3] in this case.',
|
||||
onNextClick: () => {
|
||||
updateFlowModuleById(flowStore.val, id, (module) => {
|
||||
const newId = nextId(flowStateStore.val, flowStore.val)
|
||||
tempId = newId
|
||||
|
||||
if (module.value.type === 'forloopflow') {
|
||||
module.value.modules = [
|
||||
{
|
||||
id: newId,
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content: 'def main(x):\n return x',
|
||||
// @ts-ignore
|
||||
language: 'python3',
|
||||
input_transforms: {
|
||||
x: {
|
||||
type: 'javascript',
|
||||
// @ts-ignore
|
||||
value: '',
|
||||
expr: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
flowStateStore.val[newId] = emptyFlowModuleState()
|
||||
let schema = flowStateStore.val[newId].schema ?? { properties: {} }
|
||||
schema.properties = {
|
||||
x: {
|
||||
type: 'string',
|
||||
description: '',
|
||||
default: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
dispatch('reload')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Step of the loop',
|
||||
description: 'We added an action to the loop. Let’s configure it',
|
||||
onNextClick: () => {
|
||||
selectionManager.selectId(tempId)
|
||||
|
||||
dispatch('reload')
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-editor',
|
||||
popover: {
|
||||
title: 'Python step',
|
||||
description:
|
||||
'We can write python code in the editor. In this example we will capitalize the letter'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
element: '#flow-editor-step-input',
|
||||
onHighlighted: () => {
|
||||
document.querySelector('#flow-editor-plug')?.parentElement?.classList.remove('opacity-0')
|
||||
},
|
||||
popover: {
|
||||
title: 'Flow inputs',
|
||||
description: 'UI is autogenerated from your code.'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-plug',
|
||||
popover: {
|
||||
title: 'Input configuration',
|
||||
description: 'Let’s connect the input to the letter input',
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-plug')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.key',
|
||||
popover: {
|
||||
title: 'Connect',
|
||||
description: 'As we did before, we can connect to the iterator of the loop',
|
||||
onNextClick: () => {
|
||||
updateFlowModuleById(flowStore.val, id, (module) => {
|
||||
if (
|
||||
module.value.type === 'forloopflow' &&
|
||||
module.value.modules[0].value.type === 'rawscript'
|
||||
) {
|
||||
module.value.modules[0].value.input_transforms = {
|
||||
x: {
|
||||
type: 'javascript',
|
||||
expr: 'flow_input.iter.value'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
dispatch('reload')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-step-input',
|
||||
popover: {
|
||||
title: 'Iterator',
|
||||
description:
|
||||
'Loops expose an iterator object that contains the current value of the loop and the index',
|
||||
onNextClick: () => {
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
|
||||
updateProgress(1)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-test-flow',
|
||||
popover: {
|
||||
title: 'Test your flow',
|
||||
description: 'We can now test our flow',
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-test-flow')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
element: '#flow-editor-test-flow-drawer',
|
||||
popover: {
|
||||
title: 'Test your flow',
|
||||
description: 'Finally we can test our flow, and view the results!',
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-test-flow-drawer')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
|
||||
updateProgress(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
@@ -1,291 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext, tick } from 'svelte'
|
||||
import type { FlowEditorContext } from '../flows/types'
|
||||
import { updateProgress } from '$lib/tutorialUtils'
|
||||
import {
|
||||
clickButtonBySelector,
|
||||
isFlowTainted,
|
||||
setInputBySelector,
|
||||
triggerPointerDown,
|
||||
waitForElementLoading
|
||||
} from './utils'
|
||||
import Tutorial from './Tutorial.svelte'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import { wait } from '$lib/utils'
|
||||
|
||||
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let tutorial: Tutorial | undefined = undefined
|
||||
|
||||
export function runTutorial() {
|
||||
tutorial?.runTutorial()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
index={0}
|
||||
name="action"
|
||||
tainted={isFlowTainted(flowStore.val)}
|
||||
on:error
|
||||
on:skipAll
|
||||
getSteps={(driver) => [
|
||||
{
|
||||
popover: {
|
||||
title: 'Flow builder tutorial',
|
||||
description:
|
||||
'Learn how to build powerful flows in a few steps. You can use arrow keys to navigate.'
|
||||
}
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Flows inputs',
|
||||
description: 'Flows have inputs that can be used in the flow',
|
||||
onNextClick: async () => {
|
||||
triggerPointerDown('#flow-editor-virtual-Input')
|
||||
await wait(20)
|
||||
clickButtonBySelector('#add-flow-input-btn')
|
||||
await wait(0)
|
||||
setInputBySelector('input[placeholder="Field name"]', 'firstname')
|
||||
setTimeout(() => driver.moveNext())
|
||||
}
|
||||
},
|
||||
element: '#flow-editor-virtual-Input'
|
||||
},
|
||||
{
|
||||
element: 'input[placeholder="Field name"]',
|
||||
popover: {
|
||||
title: 'Name your property',
|
||||
description: 'Give a name to your property. Here we will call it firstname',
|
||||
onNextClick: () => {
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
},
|
||||
onPrevClick: () => {
|
||||
clickButtonBySelector('#add-flow-input-btn') // Close the input drawer
|
||||
setTimeout(() => driver.movePrevious())
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '*:has(> #flow-editor-add-property)',
|
||||
popover: {
|
||||
title: 'Add your property',
|
||||
description: 'Click here to save your property',
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-add-property')
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-add-step-0',
|
||||
popover: {
|
||||
title: 'Add a step',
|
||||
description: 'Click here to add a step to your flow',
|
||||
onNextClick: () => {
|
||||
triggerPointerDown(`#flow-editor-add-step-0`)
|
||||
setTimeout(() => driver.moveNext())
|
||||
},
|
||||
onPrevClick: async () => {
|
||||
clickButtonBySelector('.delete-schema-field-button')
|
||||
await wait(0)
|
||||
clickButtonBySelector('#add-flow-input-btn')
|
||||
await wait(0)
|
||||
setInputBySelector('input[placeholder="Field name"]', 'firstname')
|
||||
setTimeout(() => driver.movePrevious())
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Steps kind',
|
||||
description: "Choose the kind of step you want to add. Let's start with a simple action",
|
||||
onPrevClick: () => {
|
||||
triggerPointerDown(`#flow-editor-add-step-0`)
|
||||
setTimeout(() => driver.movePrevious())
|
||||
}
|
||||
},
|
||||
element: '#flow-editor-insert-module'
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Pick an action',
|
||||
description: 'Let’s pick an action to add to your flow',
|
||||
onNextClick: () => {
|
||||
triggerPointerDown('#flow-editor-insert-module > div > div > button:nth-child(1)')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
},
|
||||
element: '#flow-editor-insert-module > div > div > button:nth-child(1)'
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-flow-providers',
|
||||
popover: {
|
||||
title: 'Action configuration',
|
||||
description: 'An action can be inlined, imported from your workspace or the Hub.'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-flow-atoms',
|
||||
popover: {
|
||||
title: 'Supported languages',
|
||||
description: 'Windmill support the following languages/runtimes.'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-new-bun',
|
||||
popover: {
|
||||
title: 'Typescript',
|
||||
description: "Let's write a script for your flow",
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-new-bun')
|
||||
tick().then(() =>
|
||||
waitForElementLoading('#flow-editor-editor', () => {
|
||||
driver.moveNext()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-editor',
|
||||
popover: {
|
||||
title: 'Action editor',
|
||||
description: 'Windmill provides a full code editor to write your actions',
|
||||
onPrevClick: () => {
|
||||
triggerPointerDown(`#flow-editor-virtual-Input`)
|
||||
triggerPointerDown(`#flow-editor-add-step-0`)
|
||||
setTimeout(() => {
|
||||
driver.movePrevious()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-step-input',
|
||||
onHighlighted: () => {
|
||||
document.querySelector('#flow-editor-plug')?.parentElement?.classList.remove('opacity-0')
|
||||
},
|
||||
popover: {
|
||||
title: 'Autogenerated schema',
|
||||
description: 'The schema and the UI is autogenerated from your code'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-plug',
|
||||
onHighlighted: () => {
|
||||
document.querySelector('#flow-editor-plug')?.parentElement?.classList.remove('opacity-0')
|
||||
},
|
||||
popover: {
|
||||
title: 'Connect',
|
||||
description:
|
||||
'You can provide static values or connect to other nodes result. Here we will connect to the firstname input',
|
||||
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-plug')
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-step-input .prop-picker-inputs',
|
||||
popover: {
|
||||
title: 'Connection mode',
|
||||
description: 'Once you pressed the connect button, you can choose what to connect to.',
|
||||
onNextClick: () => {
|
||||
if (flowStore.val.value.modules[0].value.type === 'rawscript') {
|
||||
flowStore.val.value.modules[0].value.input_transforms = {
|
||||
x: {
|
||||
type: 'javascript',
|
||||
expr: 'flow_input.firstname'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshStateStore(flowStore)
|
||||
dispatch('reload')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
},
|
||||
onPrevClick: () => {
|
||||
clickButtonBySelector('#flow-editor-plug')
|
||||
setTimeout(() => driver.movePrevious())
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
element: '#flow-editor-step-input',
|
||||
popover: {
|
||||
title: 'Input connected!',
|
||||
description: 'The input is now connected to the firstname input',
|
||||
onPrevClick: () => {
|
||||
clickButtonBySelector('#flow-editor-plug')
|
||||
setTimeout(() => driver.movePrevious())
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
element: '#flow-editor-test-flow',
|
||||
popover: {
|
||||
title: 'Test your flow',
|
||||
description: 'We can now test our flow',
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-test-flow')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
element: 'textarea.w-full',
|
||||
popover: {
|
||||
title: 'Flow input',
|
||||
description: 'Let’s provide an input to our flow',
|
||||
onNextClick: () => {
|
||||
setInputBySelector('textarea.w-full', 'Hello World!')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
})
|
||||
},
|
||||
onPrevClick: () => {
|
||||
clickButtonBySelector('#flow-editor-test-flow') // Close the test drawer
|
||||
setTimeout(() => driver.movePrevious())
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '#flow-editor-test-flow-drawer',
|
||||
popover: {
|
||||
title: 'Test your flow',
|
||||
description: 'Finally we can test our flow, and view the results!',
|
||||
onNextClick: () => {
|
||||
clickButtonBySelector('#flow-editor-test-flow-drawer')
|
||||
|
||||
setTimeout(() => {
|
||||
driver.moveNext()
|
||||
|
||||
updateProgress(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,457 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../flows/types'
|
||||
import Tutorial from './Tutorial.svelte'
|
||||
import type { DriveStep } from 'driver.js'
|
||||
import { initFlow } from '../flows/flowStore.svelte'
|
||||
import type { Flow } from '$lib/gen'
|
||||
import { wait, type StateStore } from '$lib/utils'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { updateProgress } from '$lib/tutorialUtils'
|
||||
|
||||
interface Props {
|
||||
index: number
|
||||
}
|
||||
|
||||
let { index }: Props = $props()
|
||||
|
||||
const { flowStore, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
let tutorial: Tutorial | undefined = undefined
|
||||
|
||||
// Flags to track if steps are complete
|
||||
let stepComplete = $state<Record<number, boolean>>({
|
||||
1: false,
|
||||
2: false,
|
||||
3: false,
|
||||
4: false,
|
||||
5: false,
|
||||
6: false,
|
||||
7: false
|
||||
})
|
||||
|
||||
// Constants for delays
|
||||
const DELAY_SHORT = 100
|
||||
const DELAY_MEDIUM = 300
|
||||
const DELAY_LONG = 500
|
||||
|
||||
// Constants for cursor animation
|
||||
const CURSOR_START_OFFSET = -100
|
||||
const CURSOR_CLICK_SCALE = 0.8
|
||||
|
||||
// DOM Selectors
|
||||
const SELECTORS = {
|
||||
testFlowButton: '#flow-editor-test-flow',
|
||||
testFlowDrawer: '#flow-editor-test-flow-drawer',
|
||||
flowPreviewContent: '#flow-preview-content',
|
||||
stepB: '#b'
|
||||
} as const
|
||||
|
||||
// Text constants
|
||||
const TEXT = {
|
||||
convertToFahrenheit: 'Convert to Fahrenheit'
|
||||
} as const
|
||||
|
||||
// Helper function to check if step is complete
|
||||
function checkStepComplete(step: number): boolean {
|
||||
if (!stepComplete[step]) {
|
||||
sendUserToast('Please wait...', false, [], undefined, 3000)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Helper function to create and animate a fake cursor
|
||||
async function createFakeCursor(
|
||||
startElement: HTMLElement | null,
|
||||
endElement: HTMLElement,
|
||||
transitionDuration: number = 1.5
|
||||
): Promise<HTMLElement> {
|
||||
const fakeCursor = document.createElement('div')
|
||||
fakeCursor.style.cssText = `
|
||||
position: fixed;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(59, 130, 246, 0.8);
|
||||
border: 2px solid white;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
transition: all ${transitionDuration}s ease-in-out;
|
||||
`
|
||||
document.body.appendChild(fakeCursor)
|
||||
|
||||
const endRect = endElement.getBoundingClientRect()
|
||||
let startX: number, startY: number
|
||||
|
||||
if (startElement) {
|
||||
const startRect = startElement.getBoundingClientRect()
|
||||
startX = startRect.left + startRect.width / 2
|
||||
startY = startRect.top + startRect.height / 2
|
||||
} else {
|
||||
startX = endRect.left + CURSOR_START_OFFSET
|
||||
startY = endRect.top + endRect.height / 2
|
||||
}
|
||||
|
||||
fakeCursor.style.left = `${startX}px`
|
||||
fakeCursor.style.top = `${startY}px`
|
||||
|
||||
await wait(DELAY_SHORT)
|
||||
|
||||
fakeCursor.style.left = `${endRect.left + endRect.width / 2}px`
|
||||
fakeCursor.style.top = `${endRect.top + endRect.height / 2}px`
|
||||
|
||||
await wait(transitionDuration * 1000)
|
||||
|
||||
return fakeCursor
|
||||
}
|
||||
|
||||
// Helper function to get element by selector (handles both querySelector and getElementById)
|
||||
function getElementBySelector(selector: string): HTMLElement | null {
|
||||
// If selector starts with #, try getElementById first, then fallback to querySelector
|
||||
if (selector.startsWith('#')) {
|
||||
const id = selector.slice(1)
|
||||
return document.getElementById(id) || document.querySelector(selector)
|
||||
}
|
||||
return document.querySelector(selector) as HTMLElement | null
|
||||
}
|
||||
|
||||
// Helper function to animate a fake cursor click
|
||||
async function animateFakeCursorClick(
|
||||
element: HTMLElement,
|
||||
transitionDuration: number = 1.5,
|
||||
options?: { usePointerEvents?: boolean }
|
||||
): Promise<void> {
|
||||
const fakeCursor = await createFakeCursor(null, element, transitionDuration)
|
||||
await wait(DELAY_MEDIUM)
|
||||
|
||||
// Animate click (shrink cursor briefly)
|
||||
fakeCursor.style.transform = `scale(${CURSOR_CLICK_SCALE})`
|
||||
await wait(DELAY_SHORT)
|
||||
fakeCursor.style.transform = 'scale(1)'
|
||||
await wait(DELAY_SHORT)
|
||||
|
||||
// Trigger pointer events if needed (flow graph uses pointer events instead of click)
|
||||
if (options?.usePointerEvents) {
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
}
|
||||
|
||||
// Click the element
|
||||
element.click()
|
||||
await wait(DELAY_SHORT)
|
||||
|
||||
// Remove fake cursor
|
||||
fakeCursor.remove()
|
||||
}
|
||||
|
||||
// Helper function to find close button in drawer
|
||||
function findCloseButton(drawer: HTMLElement): HTMLElement | null {
|
||||
return Array.from(drawer.querySelectorAll('button')).find(btn => {
|
||||
const svg = btn.querySelector('svg.lucide-x')
|
||||
return svg !== null
|
||||
}) as HTMLElement | null
|
||||
}
|
||||
|
||||
// Helper function to find button by text
|
||||
function findButtonByText(container: HTMLElement, text: string): HTMLElement | null {
|
||||
const buttons = Array.from(container.querySelectorAll('button'))
|
||||
return buttons.find(btn => btn.textContent?.includes(text)) as HTMLElement | null
|
||||
}
|
||||
|
||||
export async function runTutorial() {
|
||||
// Load the pre-built flow immediately when tutorial starts
|
||||
await initFlow(preBuiltFlow, flowStore as StateStore<Flow>, flowStateStore)
|
||||
await wait(DELAY_MEDIUM)
|
||||
|
||||
// Set the celsius input to 25
|
||||
await wait(DELAY_SHORT)
|
||||
const celsiusInput = document.querySelector('input[type="number"]') as HTMLInputElement
|
||||
if (celsiusInput) {
|
||||
celsiusInput.value = '25'
|
||||
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
tutorial?.runTutorial()
|
||||
}
|
||||
|
||||
// Pre-built flow - same as the flow builder tutorial result
|
||||
const preBuiltFlow: Flow = {
|
||||
summary: 'Temperature Converter',
|
||||
description: 'Convert Celsius to Fahrenheit and categorize the temperature',
|
||||
value: {
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content:
|
||||
'export async function main(celsius: number) {\n // Validate that the temperature is within a reasonable range\n if (celsius < -273.15) {\n throw new Error("Temperature cannot be below absolute zero (-273.15°C)");\n }\n \n if (celsius > 1000) {\n throw new Error("Temperature seems unreasonably high. Please check your input.");\n }\n \n return {\n celsius: celsius,\n isValid: true,\n message: "Temperature is valid"\n };\n}',
|
||||
language: 'bun',
|
||||
input_transforms: {
|
||||
celsius: {
|
||||
expr: 'flow_input.celsius',
|
||||
type: 'javascript'
|
||||
}
|
||||
}
|
||||
},
|
||||
summary: 'Validate temperature input'
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content:
|
||||
'export async function main(celsius: number) {\n // Convert Celsius to Fahrenheit using the formula: F = (C × 9/5) + 32\n const fahrenheit = (celsius * 9/5) + 32;\n \n return {\n celsius: celsiu,\n fahrenheit: Math.round(fahrenheit * 100) / 100 // Round to 2 decimal places\n };\n}',
|
||||
language: 'bun',
|
||||
input_transforms: {
|
||||
celsius: {
|
||||
expr: 'results.a.celsius',
|
||||
type: 'javascript'
|
||||
}
|
||||
}
|
||||
},
|
||||
summary: 'Convert to Fahrenheit'
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content:
|
||||
'export async function main(celsius: number, fahrenheit: number) {\n // Categorize the temperature based on Celsius value\n let category: string;\n let emoji: string;\n \n if (celsius < 0) {\n category = "Freezing";\n emoji = "❄️";\n } else if (celsius < 10) {\n category = "Cold";\n emoji = "🥶";\n } else if (celsius < 20) {\n category = "Cool";\n emoji = "😊";\n } else if (celsius < 30) {\n category = "Warm";\n emoji = "☀️";\n } else {\n category = "Hot";\n emoji = "🔥";\n }\n \n return {\n celsius: celsius,\n fahrenheit: fahrenheit,\n category: category,\n emoji: emoji\n };\n}',
|
||||
language: 'bun',
|
||||
input_transforms: {
|
||||
celsius: {
|
||||
expr: 'results.b.celsius',
|
||||
type: 'javascript'
|
||||
},
|
||||
fahrenheit: {
|
||||
expr: 'results.b.fahrenheit',
|
||||
type: 'javascript'
|
||||
}
|
||||
}
|
||||
},
|
||||
summary: 'Categorize temperature'
|
||||
}
|
||||
]
|
||||
},
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
properties: {
|
||||
celsius: {
|
||||
type: 'number',
|
||||
description: 'Temperature in Celsius',
|
||||
default: 25
|
||||
}
|
||||
},
|
||||
required: ['celsius'],
|
||||
order: ['celsius']
|
||||
},
|
||||
path: '',
|
||||
edited_at: '',
|
||||
edited_by: '',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
index={index}
|
||||
name="troubleshoot-flow"
|
||||
tainted={false}
|
||||
on:error
|
||||
on:skipAll
|
||||
getSteps={(driver) => {
|
||||
const steps: DriveStep[] = [
|
||||
{
|
||||
popover: {
|
||||
title: '🛠️ Troubleshoot a broken flow',
|
||||
description:
|
||||
'We created a flow that is a temperature converter that validates input and converts Celsius to Fahrenheit. For this tutorial, our flow is intentionally broken.',
|
||||
onNextClick: () => {
|
||||
driver.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: SELECTORS.testFlowButton,
|
||||
onHighlighted: async () => {
|
||||
stepComplete[1] = false
|
||||
await wait(DELAY_SHORT)
|
||||
stepComplete[1] = true
|
||||
},
|
||||
popover: {
|
||||
title: 'Test our flow',
|
||||
description:
|
||||
'Let\'s run it so you can see what needs to be fixed.',
|
||||
side: 'bottom',
|
||||
onNextClick: async () => {
|
||||
if (!checkStepComplete(1)) return
|
||||
|
||||
// Click the Test Flow button to open the drawer
|
||||
const testFlowButton = document.querySelector(SELECTORS.testFlowButton) as HTMLElement
|
||||
if (testFlowButton) {
|
||||
testFlowButton.click()
|
||||
await wait(DELAY_LONG)
|
||||
}
|
||||
|
||||
driver.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: SELECTORS.testFlowDrawer,
|
||||
onHighlighted: async () => {
|
||||
stepComplete[2] = false
|
||||
await wait(DELAY_SHORT)
|
||||
stepComplete[2] = true
|
||||
},
|
||||
popover: {
|
||||
title: 'Run the flow',
|
||||
description:
|
||||
'Click "Next" to execute the flow. We\'ll use the results to troubleshoot the error.',
|
||||
side: 'left',
|
||||
onNextClick: async () => {
|
||||
if (!checkStepComplete(2)) return
|
||||
|
||||
// Click the Test button to execute the flow
|
||||
const testButton = document.querySelector(SELECTORS.testFlowDrawer) as HTMLElement
|
||||
if (testButton) {
|
||||
testButton.click()
|
||||
}
|
||||
|
||||
await wait(DELAY_LONG)
|
||||
driver.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.border.rounded-md.shadow.p-2',
|
||||
onHighlighted: async () => {
|
||||
stepComplete[3] = false
|
||||
await wait(DELAY_SHORT)
|
||||
stepComplete[3] = true
|
||||
},
|
||||
popover: {
|
||||
title: 'Review the error',
|
||||
description:
|
||||
'Our flow failed. Let\'s review the error and understand what happened.',
|
||||
side: 'left',
|
||||
onNextClick: () => {
|
||||
if (!checkStepComplete(3)) return
|
||||
driver.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.border-b.flex.flex-row.whitespace-nowrap.scrollbar-hidden.mx-auto',
|
||||
onHighlighted: async () => {
|
||||
stepComplete[4] = false
|
||||
await wait(DELAY_SHORT)
|
||||
stepComplete[4] = true
|
||||
},
|
||||
popover: {
|
||||
title: 'Explore the tabs',
|
||||
description:
|
||||
'Use these tabs to navigate between different views: Result, Logs, and Graph. We\'ll focus on the Graph tab to review the error.',
|
||||
side: 'bottom',
|
||||
onNextClick: () => {
|
||||
if (!checkStepComplete(4)) return
|
||||
driver.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.grid.grid-cols-3.border.h-full',
|
||||
onHighlighted: async () => {
|
||||
stepComplete[5] = false
|
||||
await wait(DELAY_SHORT)
|
||||
|
||||
// Find the step 'b' button inside the drawer and click it with fake cursor
|
||||
const flowPreviewContent = getElementBySelector(SELECTORS.flowPreviewContent)
|
||||
if (flowPreviewContent) {
|
||||
const stepButton = findButtonByText(flowPreviewContent, TEXT.convertToFahrenheit)
|
||||
|
||||
if (stepButton) {
|
||||
await animateFakeCursorClick(stepButton, 1.5, { usePointerEvents: true })
|
||||
await wait(DELAY_MEDIUM)
|
||||
}
|
||||
}
|
||||
|
||||
stepComplete[5] = true
|
||||
},
|
||||
popover: {
|
||||
title: 'Inspect the flow graph',
|
||||
description:
|
||||
'B step failed during the run. Let\'s take a closer look at its behavior.',
|
||||
side: 'top',
|
||||
onNextClick: () => {
|
||||
if (!checkStepComplete(5)) return
|
||||
driver.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.rounded-md.grow.bg-surface-tertiary.text-xs.flex.flex-col.max-h-screen.gap-2.overflow-hidden.border',
|
||||
onHighlighted: async () => {
|
||||
stepComplete[6] = false
|
||||
await wait(DELAY_SHORT)
|
||||
stepComplete[6] = true
|
||||
},
|
||||
popover: {
|
||||
title: 'Error spotted!',
|
||||
description:
|
||||
'We made a typo in the code. Let\'s fix it and run the flow again.',
|
||||
side: 'left',
|
||||
onNextClick: async () => {
|
||||
if (!checkStepComplete(6)) return
|
||||
|
||||
// Click the close button inside the drawer
|
||||
const drawer = getElementBySelector(SELECTORS.flowPreviewContent)
|
||||
if (drawer) {
|
||||
const closeButton = findCloseButton(drawer)
|
||||
|
||||
if (closeButton) {
|
||||
await animateFakeCursorClick(closeButton, 1.5)
|
||||
}
|
||||
}
|
||||
|
||||
await wait(DELAY_LONG)
|
||||
driver.moveNext()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: SELECTORS.stepB,
|
||||
onHighlighted: async () => {
|
||||
stepComplete[7] = false
|
||||
await wait(DELAY_SHORT)
|
||||
|
||||
// Click on div id="b" to open the editor
|
||||
const stepBDiv = getElementBySelector(SELECTORS.stepB)
|
||||
if (stepBDiv) {
|
||||
await animateFakeCursorClick(stepBDiv, 1.5)
|
||||
await wait(DELAY_LONG)
|
||||
}
|
||||
|
||||
stepComplete[7] = true
|
||||
},
|
||||
popover: {
|
||||
title: 'Your turn now!',
|
||||
description:
|
||||
'Fix the issue in the code, and run the flow again to confirm everything works.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu or in the <strong>Help</strong> submenu.</p>',
|
||||
side: 'top',
|
||||
onNextClick: () => {
|
||||
if (!checkStepComplete(7)) return
|
||||
updateProgress(index)
|
||||
driver.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return steps
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
completed: number
|
||||
total: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
let { completed, total, label = 'tutorials' }: Props = $props()
|
||||
|
||||
const progressPercentage = $derived(
|
||||
total > 0 ? Math.round((completed / total) * 100) : 0
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-2 gap-2">
|
||||
<div class="text-xs font-semibold text-emphasis whitespace-nowrap">
|
||||
Progress: {completed} of {total} {label} completed
|
||||
</div>
|
||||
<div class="text-xs font-normal text-secondary flex-shrink-0">{progressPercentage}%</div>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-surface-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-surface-accent-primary transition-all duration-300 ease-out rounded-full"
|
||||
style="width: {progressPercentage}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { skipAllTodos } from '$lib/tutorialUtils'
|
||||
import TutorialWrapper from './TutorialWrapper.svelte'
|
||||
|
||||
interface TutorialDefinition {
|
||||
id: string
|
||||
component: any // Svelte component type - using any to avoid complex type issues
|
||||
name?: string // Optional name prop (used by some tutorials like AppTutorials)
|
||||
supportsSkipSteps?: boolean // Whether runTutorial accepts skipStepsCount parameter
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tutorials: TutorialDefinition[]
|
||||
}
|
||||
|
||||
let { tutorials }: Props = $props()
|
||||
|
||||
// Map tutorial IDs to their component instances
|
||||
const tutorialInstances = new Map<
|
||||
string,
|
||||
{ runTutorial: (options?: number) => void } | { runTutorial: () => void } | undefined
|
||||
>()
|
||||
|
||||
function skipAll() {
|
||||
skipAllTodos()
|
||||
}
|
||||
|
||||
// Helper function to register a tutorial instance
|
||||
function registerInstance(id: string, instance: any) {
|
||||
tutorialInstances.set(id, instance)
|
||||
}
|
||||
|
||||
// Export function to run tutorial by ID
|
||||
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
|
||||
const instance = tutorialInstances.get(id)
|
||||
if (!instance) {
|
||||
console.warn(`Tutorial instance not found for id: ${id}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this tutorial supports skipStepsCount
|
||||
const tutorial = tutorials.find((t) => t.id === id)
|
||||
if (tutorial?.supportsSkipSteps && options?.skipStepsCount !== undefined) {
|
||||
// Type assertion needed because TypeScript can't narrow the union type
|
||||
;(instance as { runTutorial: (options?: number) => void }).runTutorial(options.skipStepsCount)
|
||||
} else {
|
||||
// Call runTutorial without parameters
|
||||
if ('runTutorial' in instance && typeof instance.runTutorial === 'function') {
|
||||
instance.runTutorial()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each tutorials as tutorial}
|
||||
<TutorialWrapper
|
||||
id={tutorial.id}
|
||||
component={tutorial.component}
|
||||
name={tutorial.name}
|
||||
onInstanceReady={registerInstance}
|
||||
onSkipAll={skipAll}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { getTutorialIndex } from '$lib/tutorials/config'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
component: any // Svelte component type - using any to avoid complex type issues
|
||||
name?: string
|
||||
onInstanceReady: (id: string, instance: any) => void
|
||||
onSkipAll: () => void
|
||||
}
|
||||
|
||||
let { id, component: Component, name, onInstanceReady, onSkipAll }: Props = $props()
|
||||
|
||||
let instance: any = $state(undefined)
|
||||
const index = getTutorialIndex(id)
|
||||
|
||||
$effect(() => {
|
||||
if (instance) {
|
||||
onInstanceReady(id, instance)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if Component}
|
||||
{@const Comp = Component}
|
||||
<Comp
|
||||
bind:this={instance}
|
||||
{index}
|
||||
{...(name ? { name } : {})}
|
||||
on:error
|
||||
on:skipAll={onSkipAll}
|
||||
on:reload
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { insertNewGridItem, appComponentFromType } from '$lib/components/apps/editor/appUtils'
|
||||
import type { AppComponent, TypedComponent } from '$lib/components/apps/editor/component'
|
||||
import type { AppViewerContext, AppEditorContext } from '$lib/components/apps/types'
|
||||
import { push } from '$lib/history.svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import Tutorial from '../Tutorial.svelte'
|
||||
import {
|
||||
clickButtonBySelector,
|
||||
clickFirstButtonBySelector,
|
||||
connectInlineRunnableInputToComponentOutput,
|
||||
isAppTainted,
|
||||
updateInlineRunnableCode
|
||||
} from '../utils'
|
||||
import { updateProgress } from '$lib/tutorialUtils'
|
||||
import { type DriveStep } from 'driver.js'
|
||||
import { wait } from '$lib/utils'
|
||||
|
||||
export let name: string
|
||||
export let index: number
|
||||
|
||||
let tutorial: Tutorial | undefined = undefined
|
||||
|
||||
const { app, selectedComponent, focusedGrid } = getContext<AppViewerContext>('AppViewerContext')
|
||||
const { history } = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
export function runTutorial() {
|
||||
tutorial?.runTutorial()
|
||||
}
|
||||
|
||||
function addComponent(appComponentType: TypedComponent['type']): void {
|
||||
push(history, $app)
|
||||
|
||||
const id = insertNewGridItem(
|
||||
$app,
|
||||
appComponentFromType(appComponentType) as (id: string) => AppComponent,
|
||||
$focusedGrid
|
||||
)
|
||||
|
||||
$selectedComponent = [id]
|
||||
$app = $app
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
{index}
|
||||
{name}
|
||||
on:error
|
||||
on:skipAll
|
||||
tainted={isAppTainted($app)}
|
||||
getSteps={(driver) => {
|
||||
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
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
index: number
|
||||
}
|
||||
|
||||
let { index }: Props = $props()
|
||||
|
||||
let tutorial: Tutorial | undefined = $state(undefined)
|
||||
|
||||
export function 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()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
index={index}
|
||||
name="workspace-onboarding"
|
||||
tainted={false}
|
||||
on:skipAll
|
||||
getSteps={(driver) => {
|
||||
const steps: DriveStep[] = [
|
||||
{
|
||||
popover: {
|
||||
title: 'Welcome to your Windmill workspace! 🎉',
|
||||
description:
|
||||
"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(() => {
|
||||
const button = document.querySelector('#create-script-button') as HTMLElement | null
|
||||
if (button) {
|
||||
driver.moveNext()
|
||||
} else {
|
||||
alert('Could not find the Create Script button. Please make sure you are on the home page.')
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Create your first script',
|
||||
description:
|
||||
'<img src="/languages.png" alt="Programming Languages" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p>Scripts turn code into tools. Write in Python, TypeScript, Go, Bash, SQL and more. Run them manually, on schedule, or via webhooks.</p>',
|
||||
onNextClick: async () => {
|
||||
// Move to the next step (Create Flow button)
|
||||
setTimeout(() => {
|
||||
const button = document.querySelector('#create-flow-button') as HTMLElement | null
|
||||
if (button) {
|
||||
driver.moveNext()
|
||||
} else {
|
||||
alert('Could not find the Create Flow button. Please make sure you are on the home page.')
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
element: '#create-script-button',
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Create your first flow',
|
||||
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>Flows orchestrate multiple scripts. Chain them together with branching, loops, and error handling to build complex workflows.</p>',
|
||||
onNextClick: async () => {
|
||||
// Move to the next step (Create App button)
|
||||
setTimeout(() => {
|
||||
const button = document.querySelector('#create-app-button') as HTMLElement | null
|
||||
if (button) {
|
||||
driver.moveNext()
|
||||
} else {
|
||||
alert('Could not find the Create App button. Please make sure you are on the home page.')
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
element: '#create-flow-button',
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Create your first app',
|
||||
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>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!</p><p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu or in the <strong>Help</strong> submenu.</p>',
|
||||
onNextClick: async () => {
|
||||
// Mark tutorial as complete
|
||||
updateProgress(index)
|
||||
driver.destroy()
|
||||
|
||||
// Clean up URL parameter if present
|
||||
if ($page.url.searchParams.has('tutorial')) {
|
||||
goto(`${base}/`, { replaceState: true })
|
||||
}
|
||||
}
|
||||
},
|
||||
element: '#create-app-button',
|
||||
}
|
||||
]
|
||||
|
||||
return steps
|
||||
}}
|
||||
/>
|
||||
@@ -55,6 +55,7 @@ export function clearWorkspaceFromStorage() {
|
||||
}
|
||||
|
||||
export const tutorialsToDo = writable<number[]>([])
|
||||
export const skippedAll = writable<boolean>(false)
|
||||
export const globalEmailInvite = writable<string>('')
|
||||
export const awarenessStore = writable<Record<string, string>>(undefined)
|
||||
export const enterpriseLicense = writable<string | undefined>(undefined)
|
||||
|
||||
@@ -1,13 +1,51 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { tutorialsToDo } from './stores'
|
||||
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.
|
||||
* Returns total count. For completed count, use in component with reactive store access.
|
||||
*/
|
||||
export function getTutorialProgressTotal(tutorialIndexes: Record<string, number>): 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<string, number>,
|
||||
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)
|
||||
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
|
||||
@@ -15,7 +53,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 +63,8 @@ export async function skipAllTodos() {
|
||||
bits = bits | mask
|
||||
}
|
||||
tutorialsToDo.set([])
|
||||
await UserService.updateTutorialProgress({ requestBody: { progress: bits } })
|
||||
skippedAll.set(true)
|
||||
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } })
|
||||
}
|
||||
|
||||
export async function resetAllTodos() {
|
||||
@@ -34,12 +73,121 @@ export async function resetAllTodos() {
|
||||
todos.push(i)
|
||||
}
|
||||
tutorialsToDo.set(todos)
|
||||
skippedAll.set(false)
|
||||
|
||||
await UserService.updateTutorialProgress({ requestBody: { progress: 0 } })
|
||||
await UserService.updateTutorialProgress({ requestBody: { progress: 0, skipped_all: false } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip (mark as complete) all tutorials in a specific set of indexes
|
||||
*/
|
||||
export async function skipTutorialsByIndexes(tutorialIndexes: number[]) {
|
||||
const currentTodos = get(tutorialsToDo)
|
||||
const aft = currentTodos.filter((x) => !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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single tutorial's completion status by index
|
||||
*/
|
||||
async function updateTutorialStatusByIndex(tutorialIndex: number, completed: boolean) {
|
||||
const currentTodos = get(tutorialsToDo)
|
||||
const isInTodos = currentTodos.includes(tutorialIndex)
|
||||
|
||||
// Only update if the status needs to change
|
||||
// isInTodos = true means NOT completed, isInTodos = false means completed
|
||||
// So if completed === !isInTodos, we're already in the desired state
|
||||
if (completed === !isInTodos) {
|
||||
return // Already in the desired state
|
||||
}
|
||||
|
||||
// Update todos list
|
||||
const aft = completed
|
||||
? currentTodos.filter((x) => x !== tutorialIndex)
|
||||
: [...currentTodos, tutorialIndex]
|
||||
tutorialsToDo.set(aft)
|
||||
skippedAll.set(false)
|
||||
|
||||
// Get current progress bits
|
||||
const currentResponse = await UserService.getTutorialProgress()
|
||||
let bits: number = currentResponse.progress ?? 0
|
||||
|
||||
// Update bit for this tutorial index
|
||||
const mask = 1 << tutorialIndex
|
||||
bits = completed ? bits | mask : bits & ~mask
|
||||
|
||||
await UserService.updateTutorialProgress({
|
||||
requestBody: {
|
||||
progress: bits,
|
||||
skipped_all: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset (mark as incomplete) a single tutorial by index
|
||||
*/
|
||||
export async function resetTutorialByIndex(tutorialIndex: number) {
|
||||
await updateTutorialStatusByIndex(tutorialIndex, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single tutorial as completed by index
|
||||
*/
|
||||
export async function completeTutorialByIndex(tutorialIndex: number) {
|
||||
await updateTutorialStatusByIndex(tutorialIndex, true)
|
||||
}
|
||||
|
||||
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 +196,7 @@ export async function syncTutorialsTodos() {
|
||||
}
|
||||
}
|
||||
tutorialsToDo.set(todos)
|
||||
skippedAll.set(skipped)
|
||||
}
|
||||
|
||||
export function tutorialInProgress() {
|
||||
@@ -55,3 +204,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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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
|
||||
icon: ComponentType
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
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?: Role[] // Roles that can access this tutorial (if not specified, available to everyone)
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface TabConfig {
|
||||
label: string
|
||||
tutorials: TutorialConfig[]
|
||||
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)
|
||||
}
|
||||
|
||||
export type TabId = 'quickstart' | 'app_editor'
|
||||
|
||||
/**
|
||||
* Get tutorial index from config by tutorial ID.
|
||||
* Throws an error if the tutorial or its index is not found.
|
||||
*/
|
||||
export function getTutorialIndex(id: string): number {
|
||||
for (const tab of Object.values(TUTORIALS_CONFIG)) {
|
||||
const tutorial = tab.tutorials.find((t) => t.id === id)
|
||||
if (tutorial?.index !== undefined) return tutorial.index
|
||||
}
|
||||
throw new Error(`Tutorial index not found for id: ${id}. Make sure the tutorial has an index defined in config.`)
|
||||
}
|
||||
|
||||
// Available roles : developer, admin, operator
|
||||
|
||||
export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
|
||||
quickstart: {
|
||||
label: 'Quickstart',
|
||||
roles: ['admin', 'developer'],
|
||||
progressBar: true,
|
||||
active: true,
|
||||
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`
|
||||
},
|
||||
index: 1,
|
||||
active: true,
|
||||
comingSoon: false,
|
||||
roles: ['developer', 'admin'],
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
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`
|
||||
},
|
||||
index: 2,
|
||||
active: true,
|
||||
comingSoon: false,
|
||||
roles: ['developer', 'admin'],
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
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`
|
||||
},
|
||||
index: 3,
|
||||
active: true,
|
||||
comingSoon: false,
|
||||
roles: ['admin','developer'],
|
||||
order: 3
|
||||
}
|
||||
]
|
||||
},
|
||||
app_editor: {
|
||||
label: 'App Editor',
|
||||
roles: ['developer', 'admin'],
|
||||
progressBar: false,
|
||||
active: true,
|
||||
tutorials: [
|
||||
{
|
||||
id: 'backgroundrunnables',
|
||||
icon: PlayCircle,
|
||||
title: 'Background runnables',
|
||||
description: 'Learn how to create and use background runnables in your apps.',
|
||||
onClick: () => {
|
||||
window.location.href = `${base}/apps/add?tutorial=backgroundrunnables&nodraft=true`
|
||||
},
|
||||
index: 4,
|
||||
active: true,
|
||||
comingSoon: false,
|
||||
roles: ['developer','admin'],
|
||||
order: 4
|
||||
},
|
||||
{
|
||||
id: 'connection',
|
||||
icon: Link2,
|
||||
title: 'Connection',
|
||||
description: 'Learn how to connect component inputs to outputs in your apps.',
|
||||
onClick: () => {
|
||||
window.location.href = `${base}/apps/add?tutorial=connection&nodraft=true`
|
||||
},
|
||||
index: 5,
|
||||
active: true,
|
||||
comingSoon: false,
|
||||
roles: ['developer', 'admin'],
|
||||
order: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -34,28 +34,34 @@
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte'
|
||||
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'
|
||||
|
||||
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 | undefined = $state(undefined)
|
||||
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined = $state(undefined)
|
||||
|
||||
let appViewer: Drawer
|
||||
let appViewerApp: { app?: any & { id?: number } } | undefined
|
||||
let appViewer: Drawer | undefined = $state(undefined)
|
||||
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 | undefined = $state(undefined)
|
||||
let codeViewerContent: string = $state('')
|
||||
let codeViewerLanguage: Script['language'] = $state('deno')
|
||||
let codeViewerObj: HubItem | undefined = $state(undefined)
|
||||
|
||||
const breakpoint = writable<EditorBreakpoint>('lg')
|
||||
|
||||
@@ -68,7 +74,7 @@
|
||||
codeViewerObj = obj
|
||||
})
|
||||
|
||||
codeViewer.openDrawer?.()
|
||||
codeViewer?.openDrawer?.()
|
||||
}
|
||||
|
||||
async function viewFlow(obj: { flow_id: number }): Promise<void> {
|
||||
@@ -77,7 +83,7 @@
|
||||
delete hub['comments']
|
||||
flowViewerFlow = hub
|
||||
})
|
||||
flowViewer.openDrawer?.()
|
||||
flowViewer?.openDrawer?.()
|
||||
}
|
||||
|
||||
async function viewApp(obj: { app_id: number }): Promise<void> {
|
||||
@@ -86,8 +92,31 @@
|
||||
delete hub['comments']
|
||||
appViewerApp = hub
|
||||
})
|
||||
appViewer.openDrawer?.()
|
||||
appViewer?.openDrawer?.()
|
||||
}
|
||||
|
||||
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
|
||||
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')
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={codeViewer} size="900px">
|
||||
@@ -233,17 +262,22 @@
|
||||
Windmill instance, such as keeping resource types up to date.
|
||||
</Alert>
|
||||
{/if}
|
||||
<PageHeader title="Home">
|
||||
<div class="flex flex-row gap-4 flex-wrap justify-end items-center">
|
||||
{#if !$userStore?.operator}
|
||||
<span class="text-xs font-normal text-primary">Create a</span>
|
||||
<CreateActionsScript aiId="create-script-button" aiDescription="Creates a new script" />
|
||||
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
|
||||
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Home"
|
||||
childrenWrapperDivClasses="flex-1 flex flex-row gap-4 flex-wrap justify-end items-center"
|
||||
>
|
||||
{#if !$userStore?.operator}
|
||||
<span class="text-xs font-normal text-primary">Create a</span>
|
||||
<CreateActionsScript aiId="create-script-button" aiDescription="Creates a new script" />
|
||||
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
|
||||
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
|
||||
{/if}
|
||||
</PageHeader>
|
||||
|
||||
{#if !$userStore?.operator}
|
||||
<TutorialBanner />
|
||||
{/if}
|
||||
|
||||
{#if !$userStore?.operator}
|
||||
<div class="w-full overflow-auto scrollbar-hidden pb-2">
|
||||
<Tabs values={['hub', 'workspace']} hashNavigation bind:selected={tab}>
|
||||
@@ -320,3 +354,5 @@
|
||||
{#if tab == 'workspace'}
|
||||
<ItemsList bind:filter bind:subtab />
|
||||
{/if}
|
||||
|
||||
<WorkspaceTutorials bind:this={workspaceTutorials} />
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { DEFAULT_THEME } from '$lib/components/apps/editor/componentsPanel/themeUtils'
|
||||
import { emptyApp } from '$lib/components/apps/editor/appUtils'
|
||||
import { tick } from 'svelte'
|
||||
|
||||
let nodraft = $page.url.searchParams.get('nodraft')
|
||||
let appEditor: AppEditor | undefined = $state(undefined)
|
||||
const hubId = $page.url.searchParams.get('hub')
|
||||
const templatePath = $page.url.searchParams.get('template')
|
||||
const templateId = $page.url.searchParams.get('template_id')
|
||||
@@ -111,6 +113,19 @@
|
||||
} else {
|
||||
value = emptyApp()
|
||||
}
|
||||
|
||||
// Trigger tutorial after everything is initialized
|
||||
const tutorialParam = $page.url.searchParams.get('tutorial')
|
||||
if (tutorialParam) {
|
||||
// Wait for critical elements to be ready before triggering tutorial
|
||||
await tick()
|
||||
let attempts = 0
|
||||
while (attempts < 20 && !document.querySelector('#app-editor-runnable-panel')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
attempts++
|
||||
}
|
||||
appEditor?.triggerTutorial()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -118,6 +133,7 @@
|
||||
<div class="h-screen">
|
||||
{#key value}
|
||||
<AppEditor
|
||||
bind:this={appEditor}
|
||||
onSavedNewAppPath={(path) => {
|
||||
goto(`/apps/edit/${path}`)
|
||||
}}
|
||||
|
||||
@@ -143,15 +143,24 @@
|
||||
flow = flow
|
||||
goto('?', { replaceState: true })
|
||||
selectedId = 'constants'
|
||||
} else {
|
||||
tick().then(() => {
|
||||
flowBuilder?.triggerTutorial()
|
||||
})
|
||||
}
|
||||
}
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
flowBuilder?.loadFlowState()
|
||||
loading = false
|
||||
|
||||
// Trigger tutorial after everything is initialized
|
||||
const tutorialParam = $page.url.searchParams.get('tutorial')
|
||||
if (tutorialParam) {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
loadFlow()
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
<script lang="ts">
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Tab } from '$lib/components/common'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import Tabs from '$lib/components/common/tabs/Tabs.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 { afterNavigate } from '$app/navigation'
|
||||
import {
|
||||
syncTutorialsTodos,
|
||||
resetAllTodos,
|
||||
getTutorialProgressTotal,
|
||||
getTutorialProgressCompleted,
|
||||
skipAllTodos,
|
||||
skipTutorialsByIndexes,
|
||||
resetTutorialsByIndexes,
|
||||
resetTutorialByIndex,
|
||||
completeTutorialByIndex
|
||||
} 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, 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'
|
||||
import { hasRoleAccess, hasRoleAccessForPreview, getUserEffectiveRole, type Role } from '$lib/tutorials/roleUtils'
|
||||
|
||||
// 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'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 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 }
|
||||
})
|
||||
|
||||
// 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 (context is captured in closure)
|
||||
if (context.usePreview) {
|
||||
return hasRoleAccessForPreview(context.previewRole, config.roles)
|
||||
}
|
||||
return hasRoleAccess(context.user, config.roles)
|
||||
})
|
||||
})
|
||||
|
||||
// Initialize tab to first active tab (already filtered by role and active status)
|
||||
let tab: TabId = $state('quickstart')
|
||||
|
||||
// Set initial tab and ensure current tab is active and accessible
|
||||
$effect(() => {
|
||||
const firstActiveTab = activeTabs[0]?.[0]
|
||||
if (firstActiveTab) {
|
||||
// If current tab is not in active tabs, switch to first active tab
|
||||
if (!activeTabs.some(([tabId]) => tabId === tab)) {
|
||||
tab = firstActiveTab
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Get current tab configuration
|
||||
const currentTabConfig = $derived(TUTORIALS_CONFIG[tab])
|
||||
|
||||
// Filter tutorials by role and active status (same logic as displayed tutorials)
|
||||
// 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
|
||||
// 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)
|
||||
// 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))
|
||||
const completedTutorials = $derived(
|
||||
getTutorialProgressCompleted(currentTabTutorialIndexes, $tutorialsToDo)
|
||||
)
|
||||
|
||||
// Sort visible tutorials by order
|
||||
const tutorials = $derived(
|
||||
visibleTutorials.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
|
||||
)
|
||||
|
||||
// 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
|
||||
function isTutorialCompleted(tutorialId: string): boolean {
|
||||
const tutorial = currentTabConfig.tutorials.find((t) => t.id === tutorialId)
|
||||
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
|
||||
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
|
||||
try {
|
||||
await resetTutorialsByIndexes(currentTabIndexes)
|
||||
await syncTutorialsTodos()
|
||||
} catch (error) {
|
||||
console.error('Error resetting tutorials:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (completed) {
|
||||
await completeTutorialByIndex(tutorial.index)
|
||||
} else {
|
||||
await resetTutorialByIndex(tutorial.index)
|
||||
}
|
||||
await syncTutorialsTodos()
|
||||
} catch (error) {
|
||||
console.error(`Error ${completed ? 'completing' : 'resetting'} tutorial:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
|
||||
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})` }
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<CenteredPage>
|
||||
<div class="flex flex-col gap-4 pb-2 my-4 mr-2">
|
||||
<div class="flex flex-row flex-wrap justify-between items-start">
|
||||
<span class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold text-emphasis whitespace-nowrap leading-6 tracking-tight"
|
||||
>Tutorials</h1
|
||||
>
|
||||
<Tooltip documentationLink="https://www.windmill.dev/docs/intro">
|
||||
Learn how to use Windmill with our interactive tutorials
|
||||
</Tooltip>
|
||||
</span>
|
||||
{#if activeTabs.length > 0}
|
||||
<div class="flex items-start gap-2 pt-1">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: CheckCheck }}
|
||||
onclick={async () => {
|
||||
await skipAllTodos()
|
||||
await syncTutorialsTodos()
|
||||
}}
|
||||
>
|
||||
Mark all as completed
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
onclick={async () => {
|
||||
await resetAllTodos()
|
||||
await syncTutorialsTodos()
|
||||
}}
|
||||
>
|
||||
Reset all
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $userStore?.is_admin}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-secondary">View as an</span>
|
||||
<ToggleButtonGroup
|
||||
bind:selected={selectedPreviewRole}
|
||||
onSelected={(v) => {
|
||||
selectedPreviewRole = (v || userEffectiveRole) as Role
|
||||
}}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value={userEffectiveRole}
|
||||
label="Admin (me)"
|
||||
icon={Shield}
|
||||
size="sm"
|
||||
{item}
|
||||
tooltip="View tutorials as yourself (admin)"
|
||||
/>
|
||||
<ToggleButton
|
||||
value="developer"
|
||||
label="Developer"
|
||||
icon={Code}
|
||||
size="sm"
|
||||
{item}
|
||||
tooltip="Preview tutorials visible to developers"
|
||||
/>
|
||||
<ToggleButton
|
||||
value="operator"
|
||||
label="Operator"
|
||||
icon={UserCog}
|
||||
size="sm"
|
||||
{item}
|
||||
tooltip="Preview tutorials visible to operators"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<span class="text-3xs text-secondary">
|
||||
This allows you to see which tutorials your team members can access
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if activeTabs.length > 0}
|
||||
<div class="flex justify-between pt-4">
|
||||
<Tabs class="w-full" bind:selected={tab}>
|
||||
{#each activeTabs as [tabId, config]}
|
||||
{@const badge = getTabBadge(tabId as TabId)}
|
||||
{#if badge.type === 'progress'}
|
||||
<Tab value={tabId} label={config.label}>
|
||||
{#snippet extra()}
|
||||
<span class="text-xs text-secondary ml-1.5 flex-shrink-0">{badge.text}</span>
|
||||
{/snippet}
|
||||
</Tab>
|
||||
{:else if badge.type === 'check'}
|
||||
<Tab value={tabId} label={config.label}>
|
||||
{#snippet extra()}
|
||||
<CheckCircle2 size={14} class="ml-1.5 flex-shrink-0" />
|
||||
{/snippet}
|
||||
</Tab>
|
||||
{:else if badge.type === 'dot'}
|
||||
<Tab value={tabId} label={config.label}>
|
||||
{#snippet extra()}
|
||||
<Circle size={14} class="ml-1.5 flex-shrink-0" />
|
||||
{/snippet}
|
||||
</Tab>
|
||||
{:else}
|
||||
<Tab value={tabId} label={config.label} />
|
||||
{/if}
|
||||
{/each}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{#if tutorials.length > 0}
|
||||
<div class="pt-8">
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
{#if currentTabConfig.progressBar !== false}
|
||||
<TutorialProgressBar
|
||||
completed={completedTutorials}
|
||||
total={totalTutorials}
|
||||
label="tutorials"
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex gap-2 flex-shrink-0 pt-1">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: CheckCheck }}
|
||||
onclick={skipCurrentTabTutorials}
|
||||
>
|
||||
Mark as completed
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
onclick={resetCurrentTabTutorials}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md bg-surface-tertiary">
|
||||
{#each tutorials as tutorial}
|
||||
<TutorialButton
|
||||
icon={tutorial.icon}
|
||||
title={tutorial.title}
|
||||
description={tutorial.description}
|
||||
onclick={tutorial.onClick}
|
||||
isCompleted={isTutorialCompleted(tutorial.id)}
|
||||
disabled={tutorial.active === false}
|
||||
comingSoon={tutorial.comingSoon}
|
||||
onReset={() => updateSingleTutorial(tutorial.id, false)}
|
||||
onComplete={() => updateSingleTutorial(tutorial.id, true)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if currentTabConfig}
|
||||
<div class="pt-8">
|
||||
<div class="text-center text-secondary text-sm py-8">
|
||||
No tutorials available for this section yet.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="pt-8">
|
||||
<div class="text-center text-secondary text-sm py-8">
|
||||
No tutorials available for now. Coming soon.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Reference in New Issue
Block a user