Files
windmill/frontend/src/lib/components/tutorials/utils.ts
T
Tristan TR e96da54001 fix: New tutorials (#7427)
* Put back banner a new tutorial is available for a user that completed all his tutorials and never skipped all

* Create onboarding tutorial for operators in tutorial config file

* Add router and steps for onboarding tutorial for operators

* Improve onboarding tutorial for operators

* Improve the tutorial UX

* Refactor

* Remove cursor from last step of operator onboarding tutorial

* Improve filtering per role

* Add Runs page tutorial

* Improve Runs page tutorial

* Add failed run

* Simplify Runs tutorial with job clicks into one unique step

* Finish overall structure of Runs tutorial

* Improve wordings

* Prevent breaking animations by clicking on Next or Previous

* Add success and failure logo to step title

* Improve wording

* Create util function for moving cursor

* Nits

* Improve wordings

* Differentiate successfull and failed jobs steps

* Remove delete flows if operator to prevent permission errors

* Add comment
2025-12-23 18:19:30 +00:00

329 lines
8.9 KiB
TypeScript

import type { FlowModule, OpenFlow } from '$lib/gen'
import { deepEqual } from 'fast-equals'
import { emptyApp } from '../apps/editor/appUtils'
import type { App } from '../apps/types'
import { findGridItem } from '../apps/editor/appUtilsCore'
import { isRunnableByName } from '../apps/inputType'
import { wait } from '$lib/utils'
// Tutorial animation delay constants
export const DELAY_SHORT = 100
export const DELAY_MEDIUM = 300
export const DELAY_LONG = 500
export const DELAY_ANIMATION = 1500
export const DELAY_ANIMATION_LONG = 2500
export const DELAY_TYPING = 50
export const DELAY_CODE_CHAR = 2
export const DELAY_CODE_NEWLINE = 5
export function setInputBySelector(selector: string, value: string) {
const input = document.querySelector(selector) as HTMLInputElement
if (input) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
}
export function clickButtonBySelector(selector: string) {
const button = document.querySelector(selector) as HTMLButtonElement
if (button) {
button.click()
}
}
export function clickFirstButtonBySelector(selector: string) {
const buttons = document.querySelector(selector)
const button = buttons?.childNodes[0] as HTMLButtonElement
if (button) {
button.click()
}
}
export function triggerPointerDown(selector: string) {
const elem = document.querySelector(selector) as HTMLElement
if (elem) {
elem.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
}
}
export function selectOptionsBySelector(selector: string, value: string) {
const select = document.querySelector(selector) as HTMLSelectElement
if (select) {
select.value = value
select.dispatchEvent(new Event('change', { bubbles: true }))
}
}
export function isFlowTainted(flow: OpenFlow) {
return (
flow.value.modules.length > 0 || Object.keys((flow?.schema?.properties as any) ?? {}).length > 0
)
}
export function isAppTainted(app: App) {
if (app.hideLegacyTopBar === true) {
// An empty app should have only have a topbar and no hidden inline scripts
if (Array.isArray(app.hiddenInlineScripts) && app.hiddenInlineScripts?.length > 0) {
return true
}
// New apps have only a single component which is the topbar
if (Array.isArray(app.grid) && app.grid.length > 1) {
return true
}
// Check if the current app is different from an empty app
return !deepEqual(app, emptyApp())
} else {
// For older apps,
return !(app.grid?.length === 0 && app.hiddenInlineScripts?.length === 0)
}
}
export function updateFlowModuleById(
flow: OpenFlow,
id: string,
callback: (module: FlowModule) => void
) {
const dfs = (modules: FlowModule[]) => {
for (const module of modules) {
if (module.id === id) {
callback(module)
return
}
if (module.value.type === 'forloopflow') {
dfs(module.value.modules)
} else if (module.value.type === 'branchone') {
module.value.branches.forEach((branch) => dfs(branch.modules))
} else if (module.value.type === 'branchall') {
module.value.branches.forEach((branch) => dfs(branch.modules))
}
// AI agent tools are leaf nodes - no traversal needed
}
}
dfs(flow.value.modules)
}
export function updateBackgroundRunnableCode(app: App, index: number, newCode: string) {
const script = app.hiddenInlineScripts[index]
if (isRunnableByName(script) && script.inlineScript) {
script.inlineScript.content = newCode
}
}
export function updateInlineRunnableCode(app: App, componentId: string, newCode: string) {
const gridItem = findGridItem(app, componentId)
if (gridItem?.data.componentInput?.type === 'runnable') {
if (
isRunnableByName(gridItem.data.componentInput.runnable) &&
gridItem.data.componentInput.runnable.inlineScript
) {
gridItem.data.componentInput.runnable.inlineScript.content = newCode
}
}
}
export function connectComponentSourceToOutput(app: App, componentId: string, targetId: string) {
const gridItem = findGridItem(app, componentId)
if (gridItem) {
gridItem.data.componentInput = {
type: 'evalv2',
fieldType: 'object',
expr: `${targetId}.result`,
connections: [
{
componentId: targetId,
id: 'result'
}
]
}
}
}
export function connectInlineRunnableInputToComponentOutput(
app: App,
sourceComponentId: string,
sourceField: string,
targetComponentId: string,
targetField: string,
fieldType: string = 'text'
) {
const gridItem = findGridItem(app, sourceComponentId)
if (gridItem?.data.componentInput?.type === 'runnable') {
// @ts-ignore
gridItem.data.componentInput.fields = {
[sourceField]: {
type: 'evalv2',
expr: `${targetComponentId}.${targetField}`,
fieldType: fieldType,
connections: [
{
componentId: targetComponentId,
id: targetField
}
]
}
}
}
}
function elementExists(selector: string): boolean {
return !!document.querySelector(selector)
}
export function waitForElementLoading(
selector: string,
callback: () => void,
interval: number = 50,
maxAttempts: number = 30
): void {
let attempts = 0
const checkExistence = setInterval(() => {
if (elementExists(selector)) {
clearInterval(checkExistence)
callback()
} else if (attempts >= maxAttempts) {
clearInterval(checkExistence)
console.error('Element not found after multiple attempts.')
}
attempts++
}, interval)
}
// Helper function to move cursor to element (for continuous cursor movement in tutorials)
export 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 a fake cursor element for tutorial animations
export function createFakeCursor(): 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 1.5s ease-in-out;
`
document.body.appendChild(fakeCursor)
return fakeCursor
}
// Constants for cursor animation
const CURSOR_START_OFFSET = -100
const CURSOR_CLICK_SCALE = 0.8
// Helper function to create and animate a fake cursor with start position
export async function createFakeCursorWithStart(
startElement: HTMLElement | null,
endElement: HTMLElement,
transitionDuration: number = 1.5
): Promise<HTMLElement> {
const fakeCursor = createFakeCursor()
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 animate a fake cursor click
export async function animateFakeCursorClick(
element: HTMLElement,
transitionDuration: number = 1.5,
options?: { usePointerEvents?: boolean; startElement?: HTMLElement | null }
): Promise<void> {
const fakeCursor = await createFakeCursorWithStart(
options?.startElement ?? 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 animate cursor to element and click (for reusing a cursor across multiple clicks)
export async function animateCursorToElementAndClick(
cursor: HTMLElement,
element: HTMLElement,
startOffset: number = CURSOR_START_OFFSET
): Promise<void> {
const rect = element.getBoundingClientRect()
// Set initial position (off-screen to the left)
cursor.style.left = `${rect.left + startOffset}px`
cursor.style.top = `${rect.top + rect.height / 2}px`
await wait(DELAY_SHORT)
// Animate to target position
cursor.style.left = `${rect.left + rect.width / 2}px`
cursor.style.top = `${rect.top + rect.height / 2}px`
await wait(DELAY_ANIMATION)
await wait(DELAY_MEDIUM)
// Click on the element
element.click()
await wait(DELAY_SHORT)
}