feat: create the cloud workspace in onboarding, and teach the empty home (#10959)

* [ee] feat: create a personal workspace on cloud signup instead of the demo invite

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): land cloud users in their workspace after onboarding

Cloud signup creates exactly one workspace for the new user, so the picker
that followed onboarding was a page with a single choice on it. Switch to
that workspace and go to the home page instead, falling back to the picker
whenever there is a real choice: an invite to accept, several workspaces,
or none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* chore(frontend): remove the tutorial system

Deletes the guided-tour feature: the tutorials directory, the per-editor
wrappers, the home banner and button, the /tutorials route, and the
driver.js dependency they were built on. Also removes what only existed to
serve them — the `tutorialsToDo` / `skippedAll` / `isCurrentlyInTutorial`
stores, the `disableTutorials` prop chain through the flow editor, the
`?tutorial=` deep links, PopupV2's clickOutside exemption for the driver
popover, and the selector-anchor class on the flow editor tabs.

The backend `tutorial_progress` endpoints and table stay: nothing calls
them now, and removing them is a public-API break plus a migration that
would drop existing progress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): suggest Hub projects on an empty workspace home

A workspace with nothing in it showed only "Welcome to Windmill". Replace
that with a grid of ready-made Hub projects to import, and hide the search
box, kind toggles and the sort/filter row while the workspace is empty —
they would act on an empty list. A search that matches nothing still keeps
its controls and shows the no-match message.

The project list is seeded locally for now; the Hub endpoint that ranks
them is not there yet, and Import is still a placeholder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): make a new cloud workspace the thing onboarding produces

Signup already makes a personal workspace; nothing let its owner name it, and a
user who ended up without one landed on a workspace picker whose only action was
a button. Onboarding now ends on the workspace itself, and the empty home that
follows says what a workspace is for rather than "Welcome to Windmill".

Onboarding gains a third step that names the workspace signup created, prefilled
from the login provider's name or the email local part — `ruben@…` gives "Ruben's
workspace". Skipping the survey reaches it too: the questions are ours, the
workspace is theirs. Advanced settings swaps in the real creation form for
someone setting up for a team.

The workspace picker stands down when it has nothing to offer: no workspace to
enter and no invite to accept leaves one action on the page, so the page is that
action — one field, prefilled, "Create workspace". Both hand-overs hold a loading
state for 900ms and the app fades in behind them, so creating a workspace reads
as something that happened.

The empty home draws three static placeholder rows in the shape of real ones,
under a caption offering a template or the New menu. "Start from a template"
opens a popover listing the hub's projects, most-starred first, preloaded when
the empty state renders and paged as you scroll. Picking one opens the import
wizard in a dialog: its two destination steps are already answered by being in a
workspace, so it starts at the import itself and pages to the credentials step
with the animation the paged-modal pattern provides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): gate the empty state and repair the derived workspace id

Review findings from the first round.

The empty state offers a template import and the New menu, and neither checks a
permission — so an operator, or a workspace whose direct-deploy protection
cleared `showEditButtons`, was offered both. It now sits behind the same gate as
the create menu thirty lines above it.

`validateWorkspaceId` answers with the *reason* an id is unusable, so
`if (validateWorkspaceId(next)) break` stopped on the first invalid candidate and
returned it: someone named Global got the reserved `global`, and a 50-char seed
got a taken one. Invalid candidates are skipped instead, and when none works the
caller opens advanced settings rather than posting a name the server refuses.

Also: `rd` may be absolute (the CLI login sends one) and `goto` refuses those,
which would strand the caller on the "Creating …" screen with the workspace
already made; the hub host is parsed defensively, since the instance setting is
whatever an admin typed and `new URL` was throwing in render; `insert_workspace`
says which authorization its callers still own; and the two arrival animations'
comments now describe when they actually play.

Tests for the two pure helpers the review named: `defaultWorkspaceName` and
`hubProjectDescription`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): use unifiedSize on the new onboarding step's buttons

`size` and `color` are deprecated on Button; the new step copied them from the
survey steps above it. AGENTS.md: deprecated props survive at old call sites,
copying one forward is still a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): let Skip wait for the workspace onboarding names

`loadWorkspaceStep()` was fired and dropped, while Skip and the use-case Continue
branch on `ownWorkspace` in their `finally`. Skip awaits one POST that starts
after those two GETs and can finish before them, so a first-frame Skip fell
through to `leaveOnboarding()` and landed in the workspace with the backend's
name — the step this flow exists for, silently gone. Both exits await the load;
`isSubmitting` already covers the wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* refactor: create the workspace in onboarding rather than at signup

Signup no longer makes a personal workspace, so the last onboarding step creates
one instead of renaming it — the same one-field `SimpleCreateWorkspace` the
workspace picker falls back to, so a user who leaves onboarding early meets the
form again rather than something new. The id now comes from the name they type
rather than from their email, and there is one creation path instead of two.

`insert_workspace` goes back to being private: the extraction existed only so the
EE signup path could call it, and nothing outside `create_workspace` does now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): count a pending invite as somewhere to go

Onboarding read `listUserWorkspaces`, which returns membership. An invite is a
`workspace_invite` row until `accept_invite` runs, so an invited teammate reached
the last step owning nothing and was walked into creating a personal workspace,
with the invite nowhere on the page. Invites are fetched alongside the
workspaces, the way the picker already gates the same decision.

A failed load now reads as placed rather than not: the picker can work the
decision out, while the create step's only way forward is creating.

The create form reports when it is handing over, so the Previous button beside
it stands down for the ~900ms rather than offering a way back out of a workspace
that now exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): open the template picker downward and size it from the popover

The popover's default positioning caps its height to the viewport, and the list
inside it carried a fixed one, so a capped box overflowed its own frame — visible
with the AI composer hidden, where the caption sits high and `placement: top`
left almost no room above it. It opens downward now, with flip fallbacks, at a
definite `min(72vh, 520px)`; the list fills what the header leaves, which is
still the definite height it needs to page.

`creating` on the create form becomes `onCreatingChange`: `$bindable(default)` on
an optional prop is banned, and this is something the form reports rather than
state it shares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* refactor(frontend): drop the InfiniteList containerClass prop

Added for the template picker, which turns out not to need it: DataTable's own
container is already `h-full`, so `containerClass="h-full"` merged to nothing and
the height the list pages against comes from the flex chain above it. A prop with
no effect at its only call site is public surface for free. InfiniteList is back
to what it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): stop a dismissed project import from running on

Modal reports dismissal only through its bindable `open` — the X, Escape
and the backdrop dispatch neither `confirmed` nor `canceled`. Bind it, so
clearing `pick` follows the dialog closing: re-picking the same project
opens it again, and a run still in flight is abandoned with a toast
instead of writing to the workspace with no UI in front of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): stop a dismissed project import from running on

Modal reports dismissal only through its bindable `open` — the X, Escape
and the backdrop dispatch neither `confirmed` nor `canceled`. Bind it, so
clearing `pick` follows the dialog closing: re-picking the same project
opens it again, and a run still in flight is abandoned with a toast
instead of writing to the workspace with no UI in front of it.

Also mark the inline-link buttons as sanctioned rather than oversights,
and give Log out `text-accent` instead of `text-blue-500`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): keep the stopped-import toast off the Finish path

`done` survives a retry, so Finish is clickable while the run is going
again, and its own closing reaches the same falling edge the X does.
Abandon the run either way; say it was stopped only when that is what
the click asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): reach the hub importer from the create menu, and count the funnel

The picker only existed inside the empty state, which disappears as soon
as a workspace holds one item — nothing else in the product linked to
`/projects/import`. New → Import now offers a hub project, opening the
catalogue in a dialog: a popover anchored to an item inside an open
dropdown leaves two melt layers arguing over focus. The list and the
import dialog move up to ItemsList, so one dialog serves both doors.

`template_setup` records how the credentials step ended — `filled` only
when nothing was outstanding, `skipped` carrying how many rows were left
— and `template_abandon` records where a dismissed import was given up.
`template_picker_open` gains a key naming the entry point.

Also on the workspace picker: logging out is a text link on the line
that says who you are and an item in the settings menu, rather than the
page's accent action, and onboarding's Previous joins the row it belongs
to instead of hanging under the button that finishes the form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): make the import counters answer what they claim to

`template_abandon` folded a landed import closed with the X into `idle`,
the bucket read as "opened this and bounced" — three outcomes in one
number. It gets its own `done` stage.

`template_setup` counted skipped rows through `value`, which is an
increment: `skipped` accumulated rows while `filled` and `none` counted
imports, two units in one counter with no way to recover one from the
other. The row count becomes a bucket in the key, so every event is one
import and the buckets compare.

The import dialog also asked for the hub URL settings at init, and the
home list now mounts it for everyone on every arrival — two GETs for a
string only the project card renders. Deferred to the first pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix: sanitize the inferred username, and stop counting an unread setup step as clean

`loadUsernamePolicy` derived a username by stripping dots, so `O'Connor`
and `alice+demo` both produced values the `proper_username` constraint
refuses — posted invisibly by the simple form, which then failed with
nothing on screen explaining why. `usernameFromName` keeps only `[\w-]`
and answers undefined when nothing usable is left, which is already the
form's cue to open the full one.

The credentials step offers Finish when the export could not be read,
since it cannot tell what is outstanding — and that landed in
`template_setup` as `filled`, the bucket meaning the step came out
clean. It reports whether it checked anything, and an unread step counts
as `unchecked`.

Also drops an orphaned `.sqlx` entry left by the create-at-signup query
this branch abandoned, and rewrites the stepper's first-frame comment,
which argued from a meaning of `resourceCount` that main has narrowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): cap the inferred username at what the column holds

`usr.username` is VARCHAR(50) while the provider name and email it is
derived from run to 255, and `create_workspace` inserts the value
untruncated — so a long first name failed the same way the invalid
characters did: posted invisibly, refused on insert, with nothing on
screen naming the field. Undefined instead, which the form already
routes to the full one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): show the import note where it applies, and drop onCreated's unused id

The note is about landing on top of what a workspace already holds, so
it belongs wherever the destination is an existing workspace. The route
already read it that way; the dialog, which always imports into the
current workspace, was hiding it. It costs one collapsed row.

`onCreated` was typed as taking the new workspace id, and the advanced
branch passed `''` because `CreateWorkspaceInner` does not report one.
No caller reads it — the form has already switched to the workspace by
then — so the argument goes rather than the lie staying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): keep the empty-state toolbar reachable, and name the view counters

"Empty" here means the default listing found nothing, and a workspace
whose items are all archived looks exactly the same. The searchbar
carries "Only archived", so taking it off the pointer left those items
unreachable without hand-writing a query URL. Dimmed still, never
`inert`.

The disclosure named the counters that fire on a creation or an import
and not the three that fire on merely seeing the empty home or opening
either picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): respect disable_hub in both hub-project entry points

An instance with the hub turned off still got the catalogue preloaded on
every empty home and an "Import a hub project" entry in the create menu
— an outbound request the operator has said not to make, and a door to
somewhere unreachable. Both now observe `disableHubStore`, the store the
script and flow hub pickers already read. With the hub off the caption
reads "Create a new one." rather than continuing a sentence whose first
half is gone.

The telemetry disclosure also scoped the create menu and picker counters
to the empty home, when both fire from the toolbar in a populated one,
and said a creation was recorded when what is recorded is the menu
opening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* refactor(frontend): drop the catalogue preload rather than gate it twice

Warming the hub catalogue when the empty state rendered bought the time
between the caption appearing and someone clicking it, and cost two
defects: the request fired on instances with the hub turned off, and the
gate added for that raced `disable_hub`'s own load, which starts false
and stays false if the settings request fails.

The picker fetches on open instead. Measured: nothing before the click,
one request after it, 411ms to a filled list. `disableHubStore` still
hides the link and the menu entry, which cost no request and correct
themselves if the setting lands late.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* docs: count the home actions, and state the lazy-fetch constraint without its history

The telemetry doc's tally is maintained by hand and main had just moved
it; this PR adds a feature, so it reads 48 across eighteen with `home`
in the list — verified against the pinned EE ref rather than counted by
eye.

The empty state's comment narrated a preload that no longer exists and
the defects it caused. What a future reader needs is the constraint:
`disable_hub` loads asynchronously, so a fetch from here goes out before
the setting forbidding it is known.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): stop the prefill overwriting a typed name, and gate submit on the policy

`load()` assigned the suggested name unconditionally, so a name typed
while its two requests were in flight was replaced a moment later. It
now yields to anything already typed.

Nothing may be submitted before the username policy lands either:
`automateUsername` starts at the common case, and posting that guess to
an instance that derives no usernames sends none where one is required.
`policyLoaded` gates both the button and `create()`, and is set in a
`finally` so a failed load leaves the form usable rather than wedged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): settle the username policy on failure instead of guessing it

`policyLoaded` was set in a `finally`, so a failed policy load unblocked
the form with `automateUsername` still at its default — the exact submit
the flag exists to prevent. The failure now hands over to the full form,
which asks for a username outright rather than inferring one, so the
flag is never true while the answer is still a guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): answer the username policy instead of rejecting it

Three rounds of this bug moved between call sites because the shared
loader rejects when it cannot read `automate_username_creation`, leaving
each caller to guess — and both guessed "automated", which hides the
username field and posts none to an instance that derives none.

`loadUsernamePolicy` now answers "ask for one" in that case, so
`SimpleCreateWorkspace` and `CreateWorkspaceInner` both render a field
someone can type into rather than submitting a guess. An instance that
does automate ignores a username it was sent, so asking is safe either
way.

The prefill and the policy are settled apart now too: a failed
`globalWhoami` costs the suggested name and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): block creation when the username policy is unknown

There is no safe default. `create_workspace` refuses a username on an
instance that automates them and requires one on an instance that does
not (`workspaces.rs:5820`), so a client that cannot read the setting has
two request shapes available and the server rejects both. Last round's
"ask for one" was as wrong as the "automated" guess it replaced.

So the loader reports the failure instead of inventing an answer, and
the form says so: Create stays disabled, with a line explaining why and
a link to try again. Verified in the browser both ways — unreadable
policy disables Create and shows the message, a healthy load prefills
the name and enables it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): close the advanced-settings bypass while the policy is unknown

Create was gated on knowing whether the instance derives usernames, and
the link beside it went to a form with no such gate — so the way around
the block sat next to it. It is disabled until the policy is known, with
a title saying why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): reload the list on dismissal, and never re-offer a workspace that exists

Closing a landed import with the X left the home list stale: only
`finish()` reloaded it, so a workspace that now holds a project kept
showing its placeholder rows. A run that started wrote items whether it
finished, was abandoned or failed partway, so any dismissal after one
reloads.

Creation reported failure for a failed *list refresh* too, and handed
the form back — where a retry picks the next free id and creates a
second workspace. Once `createWorkspace` returns, nothing may report
failure: the refresh is logged if it fails, and the hand-over proceeds,
since the workspace is real either way.

The disabled-link tooltip also claimed the settings could not be read
during the ordinary load, before anything had failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): reload after the abandoned run stops, not when it is asked to

`abandon()` stops the run at the next phase boundary; the request already
sent still lands. Reloading the list at that moment could read it before
that write committed, leaving the caller stale again — the thing the
reload was added to fix. It now waits for `running` to clear, which is
immediate for the common case of dismissing a finished import, with a
cap so a run that never settles still ends in a reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): give the list reload one owner, taken by both exits

Finish reloaded immediately while dismissal waited for the run to stop,
so Finish pressed during a retry — `done` survives one, which is what
makes the button clickable then — read the list mid-write, and its
`finishing` flag stopped the deferred reload from correcting it.

Both exits now go through the same wait. One reload per closing, always
after the writing stops, whichever way the dialog was left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): give ImportExecution a whenIdle(), and await it instead of polling

The reload waited on a 250ms poll of `running` with a 15s cap, because
the modal receives the execution after `run()` was already called and so
holds no promise to await. The cap was its own hole: a write slower than
15s reloaded early, and nothing followed.

`run()` now keeps the in-flight promise and `whenIdle()` hands it out —
resolved when nothing is being written, immediate when no run is in
flight. The modal awaits that: no poll, no cap, no window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): back the settlement reload with a bound, and keep reporting run failures

`installProject` writes serially and takes no signal, so a request left
pending after earlier items committed leaves those invisible until the
next page load — `whenIdle()` alone never resolves for it. A bound now
reloads once in that case, *without* replacing the settlement reload:
replacing it was the flaw in the timeout this grew out of, so a hung run
reloads on the bound and again if it ever finishes.

`whenIdle()`'s rejection handler also swallowed the only report an
unexpected throw had — `#runInternal` has no catch of its own, and a
throw outside its inner ones leaves a stalled run with nothing on
screen. It logs now instead of discarding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): say when a workspace holds only archived items

A workspace whose items are all archived read as empty, because the
placeholder is decided by the default listing. Reaching those items then
depended on the toolbar, which is why it had been left interactive while
dimmed — and that let a kind toggle replace the invitation with "no
items found" on a workspace that really was empty.

The state is named instead. When the default listing comes back empty,
one request asks whether anything archived exists, and the placeholder
says which of the two it is: "Everything in this workspace is archived"
with a link to show them, or the ordinary invitation. Held until that
answer lands rather than drawn and swapped, since the wrong one claims
the workspace is empty when it is not.

The toolbar is dimmed and `inert` again, its original design: the
archived case now carries its own way in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix(frontend): make the archived route independent of write permissions

Three ways the archived-only placeholder failed to deliver what it
promised:

Reading archived items is not a write, but the notice offering them sat
behind the create-permission gate — so an operator, or a workspace whose
direct-deploy protection cleared `showEditButtons`, got "no items found"
over items it could see and a toolbar now inert. The gate governs the
create actions alone; the notice is shown to whoever the probe found
something for.

The probe answered once per workspace and was never invalidated, so
archiving the last item left a cached "nothing archived" claiming the
workspace was empty until a page load. `reloadItemsAndCounts` clears it.

And it omitted `includeWithoutMain`, which the backend reads as
excluding library scripts — a workspace holding only archived ones
answered "empty". Always true here: hiding library scripts puts a filter
in `activeFilters`, which `workspaceEmpty` requires to be empty.

`whenIdle()` gains the two tests its contract deserves, since the reload
correctness three rounds argued over rests on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix: harden the hub proxy, the workspace picker's gating and the rd hand-off

Findings from four local review passes over the branch:

- `list_projects` refuses when `disable_hub` is set, and `is_public_hub` now
  compares the parsed host, so no spelling of the public hub (mixed-case scheme
  or host, port, trailing dot, userinfo) forwards a member's bearer token there.
  Covered by a unit test table.
- The workspace picker waits on `usersWorkspaceStore` as well as `workspaces`,
  which derives to `[]` while the store is unloaded; with the create-form latch,
  one such frame swapped a member's picker for the create form until reload.
- `refreshSuperadmin` takes `force`, and the picker uses it: a `false` left over
  from a logged-out load decides whether the page is a picker or a create form.
  A cancelled call no longer publishes `false` over the live request's answer,
  and only its own request's handle is cleared.
- `rd` is sanitized once where it is derived rather than at each of the four
  hand-offs, so an absolute target keeps the OAuth callback's allowance and
  `https://evil.example/` is dropped.
- The archived-items probe answers "unknown" on failure, which keeps the
  ordinary caption and leaves the toolbar reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* fix: answer the round-33 nits on the hub route and the empty state

- `empty_state_view` is no longer logged for an archived-only workspace, which
  is not the state the counter measures.
- `detail`'s fetcher keeps its last answer in a local instead of reading
  `detail.current`, a self-reference that typed the resource `any`.
- `list_projects`' comment, including its authorization contract, is back on the
  handler rather than on the predicate inserted above it.
- `listHubProjects` documents the 400 an instance with the hub disabled returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9

* feat(frontend): keep the operator onboarding tour

Operators cannot create anything, so the home page is the whole product to
them and its three tabs are worth naming. The tour that did that is the one
piece of the removed tutorial system that still has an audience.

Restored trimmed: driver.js, the driver wrapper and its controls, the
`.driver-popover` styling, and a module for the progress bit. The catalogue
machinery it used to sit in — the config, the role gating, the router, the
banner and the tutorials page — stays deleted, so the five steps are reached
directly instead of through a registry of one.

It runs on an operator's first home page visit and is recorded as seen
however it ends, including navigating away; afterwards it is in the sidebar
menu under Take the tour, which is where the last step points. Progress uses
the surviving `tutorial_progress` route, slot 6, read-modify-written so the
slots of the removed tutorials keep their state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): dim the home toolbar only where the placeholder replaces it

Standing the toolbar down depends on something else offering a way onwards.
An operator in a workspace that is simply empty gets no placeholder — they
cannot create, and there is nothing archived to reach — so the search and the
kind toggles were the only controls on the page, dimmed to 40% and `inert`.

They now follow the placeholder rather than emptiness, which also stops the
operator tour spending three of its five steps highlighting controls this
page had greyed out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* docs(frontend): put the inline-link rationale on the first link, not the third

The caption's three links share one reason for being bare `<button>`s, and it
was written on the last of them. A reader — or a reviewer — meets the archived
one first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): keep private hub project names out of telemetry

An instance pointed at its own hub imports its own projects, and the slug
naming one is the customer's content — `template_import` was recording it
verbatim, which the disclosure ("the name of any public hub project") does
not cover and `hub_script` already avoids by collapsing a private script to
`private`.

`hubProjectUsageKey` gives projects the same treatment, deciding by the
configured hub's host so a port, a scheme's case or a trailing slash cannot
turn a private hub into a public one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): treat an unread hub setting as private, not as the public hub

`hubBaseUrlStore` is seeded with the public hub and written in one place, by a
loader with no catch and no retry. A settings read that threw therefore left
the store naming hub.windmill.dev for the rest of the session, and the import
counter read that as permission to report a private instance's project slug —
the leak the previous commit closed, narrowed to "after one failed read".

The fact has three states and the store held two, so `hubBaseUrlKnown` carries
the third: the loader sets it only once the value is the instance's own, and
the telemetry key requires it. Links keep rendering the default meanwhile,
which is what they always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): tag each hub detail with the slug that asked for it

`resource()` assigns whatever its fetcher returns, with no guard for a run that
has been superseded, so handing back the previously fetched project on a stale
response published that project. With two slow requests in flight — pick A,
leave B loading, pick C — B's answer put A's name, author and counts on the
card while the plan underneath still said C, and Import wrote C.

Each answer now carries its own slug and is read only while that slug is the
chosen one, which also drops the local the previous shape needed to keep the
resource's type from going circular.

The hub-telemetry tests reset their shared fixture per case; the private-hub
one had been passing on what the case above it left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): stop the template picker spinning on a hub with no projects

Opening the picker against a reachable hub that has published nothing pinned
the renderer at full CPU and froze the tab. The effect arming the list called
`setLoader` and `loadData`, which read `InfiniteList`'s reactive state as well
as writing it, so the effect depended on what its own load changed and re-ran
itself; a list that stays empty never settles that cycle. It now arms the
loader once per workspace, untracked, and leaves the load to `setLoader`.

The same empty list also claimed the hub was unreachable, since one `empty`
snippet serves both. The loader records which happened, so a hub with nothing
on it says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* fix(frontend): keep a superseded hub answer from erasing the chosen project

`resource()` publishes whatever its fetcher returns, superseded or not, and
`fetchHubProject` takes no abort signal — so an answer for a project the user
had moved on from replaced the published value, the slug guard rejected it,
and the chosen project's item counts went off the card for good with nothing
left to ask for them again.

The fetch now records its own answer, tagged with its slug and only while that
slug is still the chosen one, and the card reads that. Nothing reads the
resource, so it is a `watch` — the same machinery without the value that was
the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG

* chore: update ee-repo-ref to 81edd1382d951265ab3e9b67fc7ca7967676fd56

This commit updates the EE repository reference after PR #775 was merged in windmill-ee-private.

Previous ee-repo-ref: 21ace847ec1c1406bafc50153004e1874642bf6c

New ee-repo-ref: 81edd1382d951265ab3e9b67fc7ca7967676fd56

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Guilhem
2026-09-09 10:22:06 +02:00
committed by GitHub
co-authored by Claude Opus 5 windmill-internal-app[bot]
parent 90c4e1020a
commit fd35b47658
86 changed files with 3049 additions and 5775 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
- **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead.
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy and `xhigh` reasoning, on `gpt-6-astra` rather than the action's `gpt-5.6-sol`; requires the `codex` CLI >= 0.153.4.
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
- **Domain guides**: `.claude/skills/native-trigger/`
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
+1 -1
View File
@@ -1 +1 @@
e092518ee60e33160fee9ae91a4d109566f7b0ee
81edd1382d951265ab3e9b67fc7ca7967676fd56
+24
View File
@@ -25793,6 +25793,30 @@ paths:
schema:
type: string
/w/{workspace}/hub/projects:
get:
summary: list the hub's published projects
description: |
Forwards to the configured Hub's public project catalogue and returns its
status code and raw response body. Readable by any workspace member: the
listing is not workspace-scoped, and it is proxied only because the Hub's
listing endpoint sends no CORS header. Refused with 400 when the instance
has the Hub disabled, in which case no outbound request is made.
operationId: listHubProjects
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
"400":
description: the Hub is disabled on this instance
/w/{workspace}/hub/project:
get:
summary: get the hub project linked to a workspace folder
+121 -2
View File
@@ -6,13 +6,14 @@ use axum::{
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Router,
Extension, Router,
};
use serde::{Deserialize, Deserializer, Serialize};
use windmill_common::{
error::{to_anyhow, Error},
global_settings::{load_value_from_global_settings, DISABLE_HUB_SETTING},
utils::require_admin,
HUB_BASE_URL,
DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL,
};
pub fn workspaced_service() -> Router {
@@ -48,6 +49,7 @@ pub fn workspaced_service() -> Router {
post(discard_project_update),
)
.route("/project", get(get_project_by_source))
.route("/projects", get(list_projects))
}
#[derive(Deserialize)]
@@ -548,6 +550,84 @@ async fn get_project_by_source(ctx: HubPublishCtx) -> Result<impl IntoResponse,
ctx.get("/projects/by_source").await
}
/// Whether this instance points at the public hub. Compared by parsed host rather than by the
/// string: `hub_base_url` is stored as the operator typed it, so `http://`, a port, a trailing
/// slash, a mixed-case scheme or host, userinfo and a trailing dot all name the same public
/// host — and each spelling that failed to match would send a member's token there. Parsing is
/// what `reqwest` does with the same string a line later, so this reads the host the request
/// will actually go to.
///
/// A value that does not parse answers "not the public hub", so the caller attaches the token —
/// harmless, because `reqwest` cannot build a request from that same value: it is rejected
/// before a connection is opened, and the token never reaches a socket.
fn is_public_hub(hub: &str) -> bool {
fn host_of(url: &str) -> Option<String> {
let parsed = url::Url::parse(url.trim()).ok()?;
if !matches!(parsed.scheme(), "http" | "https") {
return None;
}
Some(
parsed
.host_str()?
.trim_end_matches('.')
.to_ascii_lowercase(),
)
}
match (host_of(hub), host_of(DEFAULT_HUB_BASE_URL)) {
(Some(host), Some(default_host)) => host == default_host,
_ => false,
}
}
// The hub's project catalogue. Read by any workspace member rather than through
// `HubPublishCtx`, which requires an admin: nothing here is workspace-scoped or
// publishing-related. It exists at all because the hub's listing endpoint sends no
// CORS header, so the browser cannot read it directly the way it reads a single
// project. `accept: application/json` is what makes the hub answer with JSON.
//
// The caller's token is sent only to a hub this instance was pointed at deliberately.
// Every other route here is admin-only; this one is not, so forwarding a member's
// bearer token to `hub.windmill.dev` would put a credential replayable against this
// instance on a host outside it — for a listing that needs no credential at all.
async fn list_projects(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Tokened { token }: Tokened,
) -> Result<impl IntoResponse, Error> {
// `disable_hub` turns the hub off for a closed instance, and this handler makes an
// outbound request. The frontend hides its entry points on the same setting, but that
// is presentation: an authenticated member can call this route directly, so the refusal
// has to live here.
let disabled = load_value_from_global_settings(&db, DISABLE_HUB_SETTING)
.await?
.and_then(|v| v.as_bool())
.unwrap_or(false);
if disabled {
return Err(Error::BadRequest(
"The hub is disabled on this instance".to_string(),
));
}
let hub = (**HUB_BASE_URL.load()).clone();
let url = format!("{}/projects", hub);
let mut req = HTTP_CLIENT.get(&url).header("accept", "application/json");
if !is_public_hub(&hub) {
req = req.bearer_auth(&token);
}
let res = req
.send()
.await
.map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?;
let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let text = res
.text()
.await
.map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?;
Ok((status, text))
}
async fn submit_project(
ctx: HubPublishCtx,
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
@@ -645,3 +725,42 @@ async fn forward_to_hub<T: Serialize>(
Ok((status, text))
}
#[cfg(test)]
mod tests {
use super::is_public_hub;
#[test]
fn public_hub_recognized_in_every_spelling() {
// The predicate decides whether a workspace member's bearer token leaves the
// instance, so both directions matter: a miss on the public hub sends the token
// to windmill.dev, and a false match withholds it from a private hub that needs it.
// Every spelling here is one `hub_base_url` can hold and `reqwest` will still send.
for hub in [
"https://hub.windmill.dev",
"http://hub.windmill.dev/",
"HTTPS://hub.windmill.dev",
"https://HUB.WINDMILL.DEV",
"https://hub.windmill.dev:443",
"https://hub.windmill.dev.",
"https://hub.windmill.dev/some/path",
" https://hub.windmill.dev ",
] {
assert!(is_public_hub(hub), "{hub} should be the public hub");
}
for hub in [
"https://hub.internal.example",
"https://hub.windmill.dev.evil.example",
"https://windmill.dev",
// The host is what the request goes to, whatever precedes the `@`.
"https://hub.windmill.dev@hub.internal.example",
// Unparseable, or not a scheme a request can be built from. Grouped with the
// private hubs because the caller then attaches the token, which is harmless here:
// `reqwest` rejects the same value before opening a connection.
"hub.windmill.dev",
"ftp://hub.windmill.dev",
] {
assert!(!is_public_hub(hub), "{hub} should not be the public hub");
}
}
}
+3 -3
View File
@@ -4,10 +4,10 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
It currently carries 42 registered actions across seventeen features (`ai_session`, `ai_chat`,
It currently carries 48 registered actions across eighteen features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`,
`flow_step`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`,
`sso_groups_claim`). Nearly all of the
`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`,
`usage_meter`, `sso_groups_claim`). Nearly all of the
product is uninstrumented, so new user-facing work is the opportunity to change that.
## When to instrument
+5 -45
View File
@@ -1755,7 +1755,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1772,7 +1771,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1789,7 +1787,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1806,7 +1803,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1823,7 +1819,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1840,7 +1835,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1857,7 +1851,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1874,7 +1867,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1891,7 +1883,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1908,7 +1899,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1925,7 +1915,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1942,7 +1931,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1959,7 +1947,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1976,7 +1963,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5579,9 +5565,9 @@
}
},
"node_modules/driver.js": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.3.6.tgz",
"integrity": "sha512-g2nNuu+tWmPpuoyk3ffpT9vKhjPz4NrJzq6mkRDZIwXCrFhrKdDJ9TX5tJOBpvCTBrBYjgRQ17XlcQB15q4gMg==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz",
"integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==",
"license": "MIT"
},
"node_modules/dts-bundle-generator": {
@@ -7583,7 +7569,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -8279,7 +8265,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8300,7 +8285,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8321,7 +8305,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8342,7 +8325,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8363,7 +8345,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8384,7 +8365,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8405,7 +8385,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8426,7 +8405,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8447,7 +8425,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8468,7 +8445,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8489,7 +8465,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -13195,21 +13170,6 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -13989,7 +13949,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -1,29 +0,0 @@
<script lang="ts">
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import BackgroundRunnablesTutorial from './tutorials/app/BackgroundRunnablesTutorial.svelte'
import ConnectionTutorial from './tutorials/app/ConnectionTutorial.svelte'
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
tutorialRouter?.runTutorialById(id, options)
}
</script>
<TutorialRouter
bind:this={tutorialRouter}
tutorials={[
{
id: 'backgroundrunnables',
component: BackgroundRunnablesTutorial,
name: 'backgroundrunnables',
supportsSkipSteps: true
},
{
id: 'connection',
component: ConnectionTutorial,
name: 'connection',
supportsSkipSteps: true
}
]}
/>
-1
View File
@@ -1245,7 +1245,6 @@
<FlowModuleSchemaMap
bind:this={flowModuleSchemaMap}
disableAi
disableTutorials
smallErrorHandler={true}
disableStaticInputs
localModuleStates={showJobStatus ? localModuleStates : {}}
+3 -94
View File
@@ -41,7 +41,7 @@
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { createRawSnippet, getContext, setContext, untrack } from 'svelte'
import { getContext, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -64,30 +64,13 @@
import { setNoteEditorContext } from './graph/noteEditor.svelte'
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
import { cleanFlow } from './flows/utils.svelte'
import {
DiffIcon,
HistoryIcon,
FileJson,
Settings,
Undo,
Redo,
BookOpen,
Circle,
CheckCircle,
RefreshCw,
CheckCheck,
Disc
} from 'lucide-svelte'
import { DiffIcon, HistoryIcon, FileJson, Settings, Undo, Redo, Disc } from 'lucide-svelte'
import Awareness from './Awareness.svelte'
import { getAllModules } from './flows/flowExplorer'
import { type FlowCopilotContext } from './copilot/flow'
import { loadFlowModuleState } from './flows/flowStateUtils.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import FlowTutorials from './FlowTutorials.svelte'
import FlowHistory from './flows/FlowHistory.svelte'
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
import { tutorialsToDo } from '$lib/stores'
import { getTutorialIndex } from '$lib/tutorials/config'
import EditorHeader from './EditorHeader.svelte'
import AutosaveIndicator from './AutosaveIndicator.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
@@ -1158,21 +1141,11 @@
setContext('FlowCopilotContext', flowCopilotContext)
let renderCount = $state(0)
let flowTutorials: FlowTutorials | undefined = $state(undefined)
let jsonViewerDrawer: Drawer | undefined = $state(undefined)
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
let flowHistory: FlowHistory | undefined = $state(undefined)
export function triggerTutorial() {
const urlParams = new URLSearchParams(window.location.search)
const tutorial = urlParams.get('tutorial')
if (tutorial) {
flowTutorials?.runTutorialById(tutorial)
}
}
let baseMenuItems: Item[] = $state([])
const mod = isMac() ? '⌘' : 'Ctrl+'
@@ -1196,56 +1169,6 @@
disabled: $history.index === $history.history.length - 1,
shortcut: `${mod}⇧Z`
},
{
displayName: 'Tutorials',
icon: BookOpen,
separatorTop: true,
extra: (() => {
const remaining = [
getTutorialIndex('flow-live-tutorial'),
getTutorialIndex('troubleshoot-flow')
].filter((i) => $tutorialsToDo.includes(i)).length
return remaining > 0
? createRawSnippet(() => ({
render: () =>
`<span class="ml-auto inline-flex items-center justify-center w-4 h-4 text-[10px] font-medium text-white rounded-full bg-surface-accent-primary">${remaining}</span>`
}))
: undefined
})(),
submenuItems: [
{
displayName: 'Build a flow',
action: () => flowTutorials?.runTutorialById('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'),
icon: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
? Circle
: CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))
? undefined
: 'green'
},
{
displayName: 'Reset tutorials',
action: () => resetAllTodos(),
icon: RefreshCw,
separatorTop: true
},
{
displayName: 'Skip tutorials',
action: () => skipAllTodos(),
icon: CheckCheck
}
]
},
{
displayName: 'Test flow & record',
icon: Disc,
@@ -1515,14 +1438,7 @@
{#if $enterpriseLicense && !newFlow && !inSessionPane}
<Awareness />
{/if}
<div class="relative">
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
{#if $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) || $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))}
<span
class="absolute top-0.5 right-0.5 block w-2 h-2 rounded-full bg-surface-accent-primary pointer-events-none"
></span>
{/if}
</div>
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
{#if diffEnabled && !diffInMenu}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
it never shows on hover. pointer-events-none on the button lets the
@@ -1658,13 +1574,6 @@
{/if}
{/key}
<FlowTutorials
bind:this={flowTutorials}
on:reload={() => {
renderCount += 1
}}
/>
<FlowAssetsHandler
modules={flowStore.val.value.modules}
enableParser
@@ -1,25 +0,0 @@
<script lang="ts">
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import FlowBuilderLiveTutorial from './tutorials/FlowBuilderLiveTutorial.svelte'
import TroubleshootFlowTutorial from './tutorials/TroubleshootFlowTutorial.svelte'
let tutorialRouter: TutorialRouter | undefined = $state(undefined)
export function runTutorialById(id: string) {
tutorialRouter?.runTutorialById(id)
}
</script>
<TutorialRouter
bind:this={tutorialRouter}
tutorials={[
{
id: 'flow-live-tutorial',
component: FlowBuilderLiveTutorial
},
{
id: 'troubleshoot-flow',
component: TroubleshootFlowTutorial
}
]}
/>
@@ -23,9 +23,13 @@
project: ImportProjectSummary
/** Where the project is coming from, shown next to the author. */
hubHost?: string
/** The project's prose, when the caller has it. Falls back to the one-line summary. */
description?: string
/** Off where what the import will create is already spelled out below the card. */
showCounts?: boolean
}
let { project, hubHost = 'hub.windmill.dev' }: Props = $props()
let { project, hubHost = 'hub.windmill.dev', description, showCounts = true }: Props = $props()
// Protocol-relative on purpose: the same hub is https in production and plain
// http when it's a local dev instance, and this way the link follows whichever
@@ -83,7 +87,7 @@
class="shrink-0 text-tertiary opacity-0 transition group-hover:opacity-100"
/>
</a>
<p class="mt-0.5 line-clamp-2 text-xs text-secondary">{project.summary}</p>
<p class="mt-0.5 line-clamp-4 text-xs text-secondary">{description || project.summary}</p>
<p class="mt-1 text-xs text-tertiary">
by <span class="font-medium text-secondary">{project.author}</span>
· <span class="font-mono">{project.slug}</span>
@@ -91,9 +95,11 @@
<!-- What the import will create, aligned under the title rather than in a
band of its own: the counts belong to the project above them. -->
<div class="mt-3">
<ProjectContentBadges counts={project.counts} />
</div>
{#if showCounts}
<div class="mt-3">
<ProjectContentBadges counts={project.counts} />
</div>
{/if}
</div>
<!-- The integrations, minus whichever one is already standing in as the logo. -->
@@ -27,6 +27,27 @@
/** From the hub, for the counts — the export is only fetched during the run. */
project?: ImportProjectSummary
onFolderChange: (folder: string) => void
/**
* Whether to ask which folder the project lands in. Off where the destination was
* not chosen either — importing into the workspace you are already in is one
* decision, and `f/<slug>` is the answer nobody needs to be asked for.
*/
chooseFolder?: boolean
/**
* Whether to spell out what import does to resources and triggers. It is about landing
* on top of what a workspace already holds — a resource it will not overwrite, a
* trigger it re-creates disabled — so a destination with nothing in it has nothing to
* warn about, and the setup step that follows is where the values get filled in.
*/
showNotes?: boolean
/**
* Fill the height given rather than hugging the content, with the actions pinned to the
* bottom. For a surface of a fixed size — a paged dialog, whose height is the taller
* page — where content-height buttons would float mid-panel. `sticky` as well as
* `mt-auto`: a page taller than the box scrolls, and a row that only sat at the end of
* the content would scroll out of reach with it.
*/
fillHeight?: boolean
onFinish: () => void
/** True once the run reveals data tables the destination has yet to configure. */
setupPending?: boolean
@@ -51,6 +72,9 @@
onFolderChange,
onFinish,
onBack,
chooseFolder = true,
showNotes = true,
fillHeight = false,
setupPending = false,
setupUndecided = false,
onExecution,
@@ -312,12 +336,12 @@
}
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 {fillHeight ? 'flex-1' : ''}">
<!-- Only when the destination already exists. A workspace created by this run is
empty, so there is nothing for the project to sit next to and nothing to
choose between — asking would be a question with one answer. It lands in
f/<slug>/ either way; `installProject` creates the folder as it imports. -->
{#if existingWorkspace}
{#if existingWorkspace && chooseFolder}
<div class="max-w-sm">
<!-- The workspace rides on the field label rather than getting a line of its own:
the folder is the only thing being chosen, and naming its container is what
@@ -364,26 +388,32 @@
</Alert>
{/if}
<!-- `info`, not `warning`: nothing here has gone wrong, it is what import does. Borderless
so the collapsed row sits under the checklist as a note rather than competing with it
— `bgClass` is the only lever, the border is baked into each type's classes. -->
<Alert
type="info"
title="What import does to resources and triggers"
size="xs"
bgClass="border-0"
collapsible
>
Resources are imported as empty stubs — set their values after import; one whose path is
already in the workspace is left exactly as it is and reported as already there, so a value
you have since filled in is never overwritten. Trigger kinds are
recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at creation
and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP and Azure
triggers all require Enterprise. Triggers that reference a resource depend on stubs imported
empty, so fill in the resource value before re-enabling the trigger.
</Alert>
{#if showNotes}
<!-- `info`, not `warning`: nothing here has gone wrong, it is what import does. Borderless
so the collapsed row sits under the checklist as a note rather than competing with it
— `bgClass` is the only lever, the border is baked into each type's classes. -->
<Alert
type="info"
title="What import does to resources and triggers"
size="xs"
bgClass="border-0"
collapsible
>
Resources are imported as empty stubs — set their values after import; one whose path is
already in the workspace is left exactly as it is and reported as already there, so a value
you have since filled in is never overwritten. Trigger kinds are recreated disabled, except
GCP and Azure triggers, which manage cloud subscriptions at creation and must be re-created
manually after filling their resource. Kafka, NATS, SQS, GCP and Azure triggers all require
Enterprise. Triggers that reference a resource depend on stubs imported empty, so fill in the
resource value before re-enabling the trigger.
</Alert>
{/if}
<div class="mt-2 flex items-center justify-between gap-2">
<div
class="mt-2 flex items-center justify-between gap-2 {fillHeight
? 'sticky bottom-0 mt-auto bg-surface pb-1 pt-3'
: ''}"
>
<!-- Back is disabled mid-run, and gone once the import has landed: at that point
the plan has already happened and re-answering it would say nothing. -->
{#if !execution?.done}
@@ -48,12 +48,31 @@
* own slug and `installProject` retargets them, so reading the raw paths here would
* look for stubs that are not where they landed. */
folder?: string
onSkip: () => void
onFinish: () => void
/** Left with `outstanding` rows still unfilled, which the caller may want to count. */
onSkip: (outstanding: number) => void
/** Off where the surface already names the step, e.g. a dialog whose title is it. */
showHeading?: boolean
/** Fill the height given, actions pinned to the bottom. See ImportProjectStep. */
fillHeight?: boolean
/**
* Finished. `checked` is false where the export could not be read: the step then has no
* idea what is outstanding, so it offers Finish rather than blocking — and a caller
* counting outcomes must not read that as a step that came out clean.
*/
onFinish: (checked: boolean) => void
onBack?: () => void
}
let { workspace, slug, folder, onSkip, onFinish, onBack }: Props = $props()
let {
workspace,
slug,
folder,
onSkip,
onFinish,
onBack,
showHeading = true,
fillHeight = false
}: Props = $props()
type Row = {
name: string
@@ -710,7 +729,7 @@
})
if (!confirmed) return
}
onSkip()
onSkip(outstanding)
}
/**
@@ -736,9 +755,11 @@
}
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 {fillHeight ? 'flex-1' : ''}">
<div>
<h2 class="text-sm font-semibold text-emphasis">Finish setting up</h2>
{#if showHeading}
<h2 class="text-sm font-semibold text-emphasis">Finish setting up</h2>
{/if}
<!-- Reads as what the user gets out of it, not as what the import failed to do:
the step is skippable, so it has to say why finishing is worth their time. -->
<p class="mt-0.5 text-xs text-secondary">
@@ -780,7 +801,7 @@
{#if row.status === 'done'}
<Check size={20} class="text-emerald-600" />
{:else if row.status === 'running'}
<Loader2 size={20} class="animate-spin text-blue-500" />
<Loader2 size={20} class="animate-spin text-accent" />
{:else if row.status === 'failed'}
<X size={20} class="text-red-500" />
{:else if row.status === 'unknown'}
@@ -982,38 +1003,16 @@
</div>
{/if}
<!-- Three different things to say, and which one depends on what is left. A missing
credential degrades the project; a missing data table ends it, because every app
queries tables that do not exist. Only the credential case is offered as
skippable — saying "you can skip this" above a missing data table would be
telling the user something that is not true. -->
<!-- Two things to say, and which one depends on what is left. A missing credential
degrades the project; a missing data table ends it, because every app queries tables
that do not exist — but that case is not stated here: Skip already asks to confirm
it, in the words the user is about to act on. Nor is it offered as skippable, which
would be telling the user something that is not true. -->
{#if outstanding === 0}
<Alert type="success" title="You're all set" size="xs">
Everything this project needs is configured. Finish, and it is ready to run.
</Alert>
{:else if pendingTables.length > 0}
<Alert
type="warning"
title={missingTables.length > 0
? 'The project will not run without this'
: 'This could not be checked'}
size="xs"
>
{#if missingTables.length > 0}
The tables {missingTables.length === 1
? 'this data table holds'
: 'these data tables hold'}
do not exist, and the project's apps and flows read them. Every one of those fails as soon
as it opens.
{/if}
{#if uncheckedTables.length > 0}
{#if missingTables.length > 0}<br /><br />{/if}
{uncheckedTables.length === 1 ? 'One data table is' : 'Some data tables are'} set up, but
{uncheckedTables.length === 1 ? 'its' : 'their'} schema could not be read, so whether the project's
tables are there is unknown. Check again once the database is reachable.
{/if}
</Alert>
{:else}
{:else if pendingTables.length === 0}
<Alert type="info" title="You can skip this" size="xs" collapsible>
The project's apps and flows will fail wherever they read a credential that is still
missing. Everything else it imported works either way, and you can fill these in from the
@@ -1022,7 +1021,11 @@
{/if}
{/if}
<div class="mt-2 flex items-center justify-between">
<div
class="mt-2 flex items-center justify-between {fillHeight
? 'sticky bottom-0 mt-auto bg-surface pb-1 pt-3'
: ''}"
>
{#if onBack}
<Button
variant="subtle"
@@ -1053,7 +1056,7 @@
variant="accent"
unifiedSize="sm"
disabled={working || loading || (outstanding > 0 && !loadError)}
onClick={onFinish}
onClick={() => onFinish(!loadError)}
>
Finish setup →
</Button>
@@ -25,15 +25,33 @@
* resume and offer to run the whole bundle again.
*/
lowestStep?: number
/**
* The steps before the optional setup one. The wizard route asks all three; a surface
* that opens with the destination already settled passes only the ones it runs.
*/
labels?: string[]
/** What to call the optional setup step, where the surface knows it more precisely. */
setupLabel?: string
/**
* Where a click on an earlier step goes. Without it the step is rewritten in the URL,
* which is how the wizard route holds its position. The guards above it — nothing to
* go back to, an import in flight — apply either way.
*/
onNavigate?: (step: number) => void
}
let { step, hasSetup = false, lowestStep = 1 }: Props = $props()
let {
step,
hasSetup = false,
lowestStep = 1,
labels = IMPORT_WIZARD_LABELS,
setupLabel = IMPORT_WIZARD_SETUP_LABEL,
onNavigate
}: Props = $props()
// Most projects ship no data table migrations, so the wizard is three steps and
// says so. A fourth appears only once there is something to configure.
const tabs = $derived(
hasSetup ? [...IMPORT_WIZARD_LABELS, IMPORT_WIZARD_SETUP_LABEL] : IMPORT_WIZARD_LABELS
)
const tabs = $derived(hasSetup ? [...labels, setupLabel] : labels)
// `maxReachedIndex` is the current step, so Stepper renders everything past it as
// unreachable and only the steps behind it as clickable — the wizard has no way to
@@ -51,6 +69,10 @@
sendUserToast('Wait for the import to finish before going back.', true)
return
}
if (onNavigate) {
onNavigate(index + 1)
return
}
// Every step shares one route, so going back is a `step` rewrite that leaves
// the rest of the wizard's state in the URL alone.
const params = new URLSearchParams($page.url.search)
@@ -1061,8 +1061,7 @@
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
<li
>user usage (author count, operator count, the distinct guests of the last 30 days,
the seats they add past the free allowance, and the workspaces that allow
guests)</li
the seats they add past the free allowance, and the workspaces that allow guests)</li
>
<li>superadmin email addresses</li>
<li>development instance status</li>
@@ -1078,8 +1077,10 @@
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, and how data tables and their migrations are set up and used, last 30
days)</li
the flow editor, how data tables and their migrations are set up and used, how often
an empty workspace home is seen, how often the home pages create menu and hub-project
picker are opened and from which entry point, and the name of any public hub project
imported from the home page and how far that import got, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
@@ -1129,8 +1130,7 @@
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
<li
>user usage (author count, operator count, the distinct guests of the last 30 days,
the seats they add past the free allowance, and the workspaces that allow
guests)</li
the seats they add past the free allowance, and the workspaces that allow guests)</li
>
<li>development instance status</li>
<li
@@ -1140,8 +1140,10 @@
loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a
membership, the plan tier and quota shown when the execution meter is opened, whether
app sandbox isolation is turned on, whether a step's workspace script is edited from
the flow editor, and how data tables and their migrations are set up and used, last 30
days)</li
the flow editor, how data tables and their migrations are set up and used, how often
an empty workspace home is seen, how often the home pages create menu and hub-project
picker are opened and from which entry point, and the name of any public hub project
imported from the home page and how far that import got, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
@@ -1,25 +0,0 @@
<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
/>
@@ -1,25 +0,0 @@
<script lang="ts">
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import WorkspaceOnboardingTutorial from './tutorials/workspace/WorkspaceOnboardingTutorial.svelte'
import WorkspaceOnboardingOperatorTutorial from './tutorials/workspace/WorkspaceOnboardingOperatorTutorial.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
},
{
id: 'workspace-onboarding-operator',
component: WorkspaceOnboardingOperatorTutorial
}
]}
/>
@@ -473,15 +473,6 @@
let appEditorHeader: AppEditorHeader | undefined = $state(undefined)
export function triggerTutorial() {
const urlParams = new URLSearchParams(window.location.search)
const tutorial = urlParams.get('tutorial')
if (tutorial) {
appEditorHeader?.runTutorialById(tutorial)
}
}
let box: HTMLElement | undefined = $state(undefined)
function parseScroll() {
$yTop = box?.scrollTop ?? 0
@@ -7,25 +7,13 @@
import { redo, undo } from '$lib/history.svelte'
import { discardDraftAfterDeploy } from '$lib/userDraftToast'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import {
enterpriseLicense,
tutorialsToDo,
userStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { isMac, type Item, userPathPrefix } from '$lib/utils'
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
import { getTutorialIndex } from '$lib/tutorials/config'
import { random_adj } from '$lib/components/random_positive_adjetive'
import {
AlignHorizontalSpaceAround,
BellOff,
BookOpen,
Bug,
CheckCheck,
CheckCircle,
Circle,
DiffIcon,
Expand,
FileJson,
@@ -33,7 +21,6 @@
FormInput,
History,
Laptop2,
RefreshCw,
Save,
Smartphone,
FileClock,
@@ -61,7 +48,6 @@
import Awareness from '$lib/components/Awareness.svelte'
import { secondaryMenuLeftStore, secondaryMenuRightStore } from './settingsPanel/secondaryMenu'
import Dropdown from '$lib/components/DropdownV2.svelte'
import AppEditorTutorial from './AppEditorTutorial.svelte'
import AppReportsDrawer from './AppReportsDrawer.svelte'
import DebugPanel from './contextPanel/DebugPanel.svelte'
@@ -679,49 +665,9 @@
action: () => {
appExport?.open(toStatic($app, $staticExporter, $summary).app)
}
},
{
displayName: 'Tutorials',
icon: BookOpen,
separatorTop: true,
submenuItems: [
{
displayName: 'Background runnables',
action: () => appEditorTutorial?.runTutorialById('backgroundrunnables'),
icon: $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables'))
? Circle
: CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables'))
? undefined
: 'green'
},
{
displayName: 'Connection',
action: () => appEditorTutorial?.runTutorialById('connection'),
icon: $tutorialsToDo.includes(getTutorialIndex('connection')) ? Circle : CheckCircle,
iconColor: $tutorialsToDo.includes(getTutorialIndex('connection')) ? undefined : 'green'
},
{
displayName: 'Reset tutorials',
action: () => resetAllTodos(),
icon: RefreshCw,
separatorTop: true
},
{
displayName: 'Skip tutorials',
action: () => skipAllTodos(),
icon: CheckCheck
}
]
}
]) as Item[]
let appEditorTutorial: AppEditorTutorial | undefined = $state(undefined)
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
appEditorTutorial?.runTutorialById(id, options)
}
let appReportingDrawerOpen = $state(false)
export function openTroubleshootPanel() {
@@ -1090,15 +1036,7 @@
</div>
{/if}
<div class="flex flex-row gap-2 justify-end items-center overflow-visible shrink-0">
<div class="relative">
<Dropdown items={moreItems} />
{#if $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables')) || $tutorialsToDo.includes(getTutorialIndex('connection'))}
<span
class="absolute top-0.5 right-0.5 block w-2 h-2 rounded-full bg-surface-accent-primary pointer-events-none"
></span>
{/if}
</div>
<AppEditorTutorial bind:this={appEditorTutorial} />
<Dropdown items={moreItems} />
<div class="{compactTopbar ? 'hidden' : 'hidden md:inline'} relative overflow-visible shrink-0">
{#if hasErrors}
@@ -1,35 +0,0 @@
<script lang="ts">
import AppTutorials from '../../AppTutorials.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
let appTutorials: AppTutorials | undefined = $state(undefined)
let targetTutorial: string | undefined = $state(undefined)
export function runTutorialById(id: string, options?: { skipStepsCount?: number }) {
appTutorials?.runTutorialById(id, options)
}
</script>
<AppTutorials
bind:this={appTutorials}
on:reload
on:error={(event: CustomEvent<{ detail: string }>) => {
targetTutorial = event.detail.detail
}}
/>
<ConfirmationModal
open={targetTutorial !== undefined}
title="Tutorial error"
confirmationText="Open new tab"
on:canceled={() => {
targetTutorial = undefined
}}
on:confirmed={async () => {
window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank')
}}
>
<div class="flex flex-col w-full space-y-4">
<span> This tutorial can only be run on a new app.</span>
</div>
</ConfirmationModal>
@@ -11,7 +11,6 @@
left
} from './componentCallbacks.svelte'
import type { AppEditorContext, AppViewerContext } from '../../types'
import { isCurrentlyInTutorial } from '$lib/stores'
const { history, movingcomponents, jobsDrawerOpen, runnableJobEditorPanel } =
getContext<AppEditorContext>('AppEditorContext') as AppEditorContext
@@ -34,8 +33,7 @@
if (
(typeof classes === 'string' && classes.includes('inputarea')) ||
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!) ||
$runnableJobEditorPanel.focused ||
isCurrentlyInTutorial.val
$runnableJobEditorPanel.focused
) {
return
}
@@ -14,10 +14,9 @@
import { defaultCode } from '../component'
import WorkspaceScriptList from '../settingsPanel/mainInput/WorkspaceScriptList.svelte'
import RunnableSelector from '../settingsPanel/mainInput/RunnableSelector.svelte'
import { defaultScripts, isCurrentlyInTutorial } from '$lib/stores'
import { defaultScripts } from '$lib/stores'
import DefaultScripts from '$lib/components/DefaultScripts.svelte'
import type { Preview } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
import type { InlineScript } from '../../sharedTypes'
interface Props {
@@ -122,13 +121,7 @@
</DrawerContent>
</Drawer>
<div
class={twMerge(
'flex flex-col px-4 gap-2 text-sm',
isCurrentlyInTutorial.val ? 'h-full overflow-y-clip' : ''
)}
id="app-editor-empty-runnable"
>
<div class="flex flex-col px-4 gap-2 text-sm" id="app-editor-empty-runnable">
<div class="mt-2 flex justify-between gap-4" id="app-editor-runnable-header">
<div class="font-bold items-baseline truncate">Choose a language</div>
<div class="flex gap-2">
@@ -7,10 +7,6 @@
import { getAllScriptNames } from '../../utils'
import PanelSection from '../settingsPanel/common/PanelSection.svelte'
import { getAppScripts } from './utils'
import AppTutorials from '$lib/components/AppTutorials.svelte'
import { tutorialsToDo } from '$lib/stores'
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
import { tutorialInProgress } from '$lib/tutorialUtils'
import DocLink from '../settingsPanel/DocLink.svelte'
import HideButton from '../settingsPanel/HideButton.svelte'
import { BG_PREFIX } from '../appUtilsCore'
@@ -37,10 +33,6 @@
}
function createBackgroundScript() {
if ($tutorialsToDo.includes(5) && !$ignoredTutorials?.includes(5) && !tutorialInProgress()) {
appTutorials?.runTutorialById('backgroundrunnables', { skipStepsCount: 2 })
}
for (const [index, script] of $app.hiddenInlineScripts.entries()) {
if (script.hidden) {
delete script.hidden
@@ -75,7 +67,6 @@
selectScript(`${BG_PREFIX}${$app.hiddenInlineScripts.length - 1}`)
}
let appTutorials: AppTutorials | undefined = $state(undefined)
const dispatch = createEventDispatcher()
let runnables = $derived(getAppScripts($app.grid, $app.subgrids))
// When selected component changes, update selectedScriptComponentId
@@ -248,5 +239,3 @@
</div>
</div>
</PanelSection>
<AppTutorials bind:this={appTutorials} on:reload />
@@ -4,7 +4,18 @@
/** One level of a paginated dialog. Order is the order given: the page on screen sits at rest
* and every other waits off the side it is listed on, so a deeper page arrives from the right
* and the way back arrives from the left without anyone naming a direction. */
export type ModalPage = { key: string; content: Snippet }
export type ModalPage = {
key: string
content: Snippet
/**
* Drawn in place of `content` for a page that has not been opened yet, so the first
* navigation to it has something to slide in — without one, the box arrives empty and
* fills a frame later, which reads as the animation being broken rather than as
* loading. A skeleton is enough: it is on screen for the length of the transition.
* Unnecessary under `warm`, which builds every page up front.
*/
placeholder?: Snippet
}
</script>
<script lang="ts">
@@ -162,7 +173,8 @@
<div bind:this={box} class="relative overflow-hidden {c}">
{#each pages as page, i (page.key)}
{#if visited.includes(page.key) || warmed}
{@const built = visited.includes(page.key) || warmed}
{#if built || page.placeholder}
<!-- Pages are laid over each other rather than laid out, so the dialog cannot change
height as one replaces another. That needs a definite height from the caller.
`inert` and not just opacity: an off-screen page keeps its DOM, so without it the
@@ -177,7 +189,11 @@
style="transform: translateX({(i - index) * TRAVEL_PERCENT}%)"
inert={i !== index}
>
{@render page.content()}
{#if built}
{@render page.content()}
{:else}
{@render page.placeholder?.()}
{/if}
</div>
{/if}
{/each}
@@ -52,11 +52,6 @@
use:clickOutside={{
eventToListenName: 'pointerdown',
stopPropagation: true,
exclude: async () => {
const tutorial = document.querySelector('#driver-popover-content') as HTMLElement | null
if (tutorial) return [tutorial]
return []
},
onClickOutside: () => (open = false)
}}
>
@@ -77,9 +77,7 @@
<svelte:window onhashchange={hashChange} />
{#if !hideTabs}
<ScrollableX class={wrapperClass}>
<!-- `scrollbar-hidden` is inert on this non-scrolling row (ScrollableX owns the
scroll), but TroubleshootFlowTutorial targets it as a selector hook — keep it. -->
<div class={twMerge('border-b flex flex-row whitespace-nowrap scrollbar-hidden', c)} {style}>
<div class={twMerge('border-b flex flex-row whitespace-nowrap', c)} {style}>
{@render children?.({ selected })}
</div>
</ScrollableX>
-1
View File
@@ -9,7 +9,6 @@ export type FlowBuilderWhitelabelCustomUi = {
export?: boolean
history?: boolean
aiBuilder?: boolean
tutorials?: boolean
diff?: boolean
extraDeployOptions?: boolean
editableSummary?: boolean
@@ -55,7 +55,6 @@
interface Props {
loading: boolean
disableStaticInputs?: boolean
disableTutorials?: boolean
disableAi?: boolean
disableSettings?: boolean
disabledFlowInputs?: boolean
@@ -99,7 +98,6 @@
let {
loading,
disableStaticInputs = false,
disableTutorials = false,
disableAi = false,
disableSettings = false,
disabledFlowInputs = false,
@@ -391,7 +389,6 @@
bind:this={flowModuleSchemaMap}
controlsPosition={compactGraphOverlay ? 'bottom' : 'top'}
{disableStaticInputs}
{disableTutorials}
{disableAi}
{disableSettings}
{smallErrorHandler}
@@ -1,62 +0,0 @@
<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
/>
@@ -128,7 +128,6 @@
{/if}
</div>
{:else}
<!-- Index 0 is used by the tutorial to identify the first "Add step" -->
<InsertModulePopover
{disableAi}
placement={'bottom'}
@@ -21,7 +21,6 @@
import { locateModules, groupByParent } from '../multiSelectUtils'
import { workspaceStore } from '$lib/stores'
import FlowTutorials from '$lib/components/FlowTutorials.svelte'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import { replaceId } from '../flowStore.svelte'
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
@@ -55,7 +54,6 @@
interface Props {
sidebarSize?: number | undefined
disableStaticInputs?: boolean
disableTutorials?: boolean
disableAi?: boolean
disableSettings?: boolean
newFlow?: boolean
@@ -86,7 +84,6 @@
let {
sidebarSize = $bindable(undefined),
disableStaticInputs = false,
disableTutorials = false,
disableAi = false,
disableSettings = false,
newFlow = false,
@@ -888,7 +885,3 @@
/>
</div>
</div>
{#if !disableTutorials}
<FlowTutorials on:reload />
{/if}
@@ -15,6 +15,7 @@
Loader2,
Workflow,
Import,
Store,
PanelLeftClose
} from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
@@ -26,6 +27,25 @@
import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { createDropdownMenu, melt } from '@melt-ui/svelte'
import YAML from 'yaml'
import type { Snippet } from 'svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
interface Props {
/** Replaces the default `New` button, e.g. with an inline text link. */
trigger?: Snippet
/** The node `trigger` renders: what the menu anchors to and what opens it. */
triggerElement?: HTMLElement
/** Which entry point this menu hangs off, for telemetry. */
source?: 'toolbar' | 'empty_state'
/**
* Opens the hub project picker. The menu only offers the entry; the picker and the
* import dialog belong to the host, which is the one place a single import modal can
* serve both this menu and the empty state's own link.
*/
onImportHubProject?: () => void
}
let { trigger, triggerElement, source = 'toolbar', onImportHubProject }: Props = $props()
type Variant = {
label: string
@@ -228,8 +248,15 @@
}
let activeKey = $state(allOptions[0]?.key)
// every option's import action, surfaced together under the bottom "Import" submenu
const importActions: Extra[] = allOptions.flatMap((o) => o.extras ?? [])
// every option's import action, surfaced together under the bottom "Import" submenu.
// The hub project leads and is separated below: the others each paste one artifact the
// user already holds, while this one brings a whole project in from somewhere else.
const importActions: Extra[] = $derived([
...(onImportHubProject
? [{ label: 'Import a hub project', onSelect: onImportHubProject }]
: []),
...allOptions.flatMap((o) => o.extras ?? [])
])
// melt dropdown menu: arrow-key nav, typeahead, focus management and outside/escape
// close all come for free; we only drive the doc panel off the highlighted item.
@@ -306,7 +333,7 @@
// styling — melt element stores are callable on a node, exactly like `use:melt`.
let triggerEl: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined)
$effect(() => {
const el = triggerEl
const el = triggerElement ?? triggerEl
if (!el) return
const applied = conditionalMelt(el, menuTrigger as any) as {
destroy?: () => void
@@ -314,6 +341,18 @@
return applied?.destroy
})
// Which entry point people actually create from: the toolbar button, or the inline
// link in the empty state. Only the open edge counts — melt writes the store on
// close and on every re-render of the menu.
let wasOpen = false
$effect(() => {
const isOpen = $open
if (isOpen && !wasOpen) {
logFeatureUsage('home', 'new_menu_open', { key: source })
}
wasOpen = isOpen
})
const SHOW_DOC_SETTING = 'home_create_show_doc'
let showDoc = $state(getLocalSetting(SHOW_DOC_SETTING) !== 'false')
function setShowDoc(value: boolean) {
@@ -366,191 +405,204 @@
}
</script>
<div>
<Button
{...$menuTrigger}
id="create-new-button"
aiId="home-create-new"
aiDescription="Create a new script, flow or app"
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
endIcon={{ icon: ChevronDown }}
bind:element={triggerEl}
>
New
</Button>
{#if $open && active}
<div
use:melt={$menu}
data-arrow-loop
class="z-[6000] flex flex-row rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
style={showDoc ? 'width: 780px;' : ''}
<!-- `contents` so a custom inline trigger (the empty state's text link) keeps flowing
with the sentence around it instead of becoming a block of its own. -->
<div class={trigger ? 'contents' : ''}>
{#if trigger}
{@render trigger()}
{:else}
<Button
{...$menuTrigger}
id="create-new-button"
aiId="home-create-new"
aiDescription="Create a new script, flow or app"
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
endIcon={{ icon: ChevronDown }}
bind:element={triggerEl}
>
{#if showDoc}
<!-- explanation of the highlighted editor -->
<div class="flex flex-col gap-3 p-5 flex-1 min-w-0">
<div class="flex flex-row items-center gap-3">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 {activeAc.tile}"
>
<active.icon size={26} class={activeAc.iconText} />
</div>
<div class="min-w-0">
<div class="flex flex-row items-center gap-2">
<h3 class="font-semibold text-primary leading-tight">{active.label}</h3>
{#if active.badge}
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {active
.badge.class}"
>
{active.badge.label}
</span>
{/if}
</div>
<p class="text-xs text-tertiary">{active.tagline}</p>
</div>
</div>
New
</Button>
{/if}
</div>
<p class="text-xs text-secondary leading-relaxed">{active.description}</p>
<ul class="flex flex-col gap-1.5 mt-1">
{#each active.bullets as bullet (bullet)}
<li class="flex flex-row items-center gap-2 text-xs text-secondary">
<ChevronRight size={14} class={activeAc.iconText} />
{bullet}
</li>
{/each}
</ul>
<button
class="mt-auto self-start inline-flex items-center gap-1 pt-2 text-[10px] text-tertiary hover:text-secondary transition-colors"
title="Hide descriptions"
tabindex={-1}
onclick={() => setShowDoc(false)}
{#if $open && active}
<div
use:melt={$menu}
data-arrow-loop
class="z-[6000] flex flex-row rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
style={showDoc ? 'width: 780px;' : ''}
>
{#if showDoc}
<!-- explanation of the highlighted editor -->
<div class="flex flex-col gap-3 p-5 flex-1 min-w-0">
<div class="flex flex-row items-center gap-3">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 {activeAc.tile}"
>
<PanelLeftClose size={12} />
Hide descriptions
<active.icon size={26} class={activeAc.iconText} />
</div>
<div class="min-w-0">
<div class="flex flex-row items-center gap-2">
<h3 class="font-semibold text-primary leading-tight">{active.label}</h3>
{#if active.badge}
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {active
.badge.class}"
>
{active.badge.label}
</span>
{/if}
</div>
<p class="text-xs text-tertiary">{active.tagline}</p>
</div>
</div>
<p class="text-xs text-secondary leading-relaxed">{active.description}</p>
<ul class="flex flex-col gap-1.5 mt-1">
{#each active.bullets as bullet (bullet)}
<li class="flex flex-row items-center gap-2 text-xs text-secondary">
<ChevronRight size={14} class={activeAc.iconText} />
{bullet}
</li>
{/each}
</ul>
<button
class="mt-auto self-start inline-flex items-center gap-1 pt-2 text-[10px] text-tertiary hover:text-secondary transition-colors"
title="Hide descriptions"
tabindex={-1}
onclick={() => setShowDoc(false)}
>
<PanelLeftClose size={12} />
Hide descriptions
</button>
</div>
{/if}
<!-- option list -->
<div class="flex flex-col gap-0.5 p-2 w-[18rem] shrink-0">
{#snippet rowBody(option: Option, ac: (typeof accentClasses)[string])}
<div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 {ac.tile}">
<option.icon size={14} class={ac.iconText} />
</div>
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
{option.label}
</span>
{#if option.badge}
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {option
.badge.class}"
>
{option.badge.label}
</span>
{/if}
{/snippet}
{#each allOptions as option (option.key)}
{@const ac = accentClasses[option.accent]}
{@const rowClass =
'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'}
{#if option.variants}
<button
use:melt={$wacSubTrigger}
class={rowClass}
onfocusin={() => (activeKey = option.key)}
onpointerenter={() => (activeKey = option.key)}
>
{@render rowBody(option, ac)}
<ChevronRight size={14} class="shrink-0 text-tertiary" />
</button>
{#if $wacSubOpen}
<div
use:melt={$wacSubMenu}
use:hugViewportRight
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
>
{#each option.variants ?? [] as variant (variant.label)}
{@const VariantIcon = variant.icon}
<button
use:melt={$item}
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onclick={() => variant.onSelect()}
>
<VariantIcon width={14} height={14} />
<span class="text-xs font-medium text-primary">{variant.label}</span>
</button>
{/each}
</div>
{/if}
{:else}
<button
use:melt={$item}
class={rowClass}
onfocusin={() => (activeKey = option.key)}
onpointerenter={() => (activeKey = option.key)}
onclick={() => option.onSelect()}
>
{@render rowBody(option, ac)}
</button>
{/if}
{/each}
<!-- bottom import section: one entry whose submenu imports any artifact -->
<div class="mx-1 my-1 border-t border-gray-200 dark:border-gray-700"></div>
<button
use:melt={$importSubTrigger}
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
>
<div
class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700"
>
<Import size={14} class="text-gray-600 dark:text-gray-300" />
</div>
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
Import
</span>
<ChevronRight size={14} class="shrink-0 text-tertiary" />
</button>
{#if $importSubOpen}
<div
use:melt={$importSubMenu}
use:hugViewportRight
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
>
{#each importActions as action, i (action.label)}
<button
use:melt={$item}
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onclick={() => action.onSelect()}
>
{#if onImportHubProject && i === 0}
<Store size={14} class="shrink-0 text-tertiary" />
{:else}
<Import size={14} class="shrink-0 text-tertiary" />
{/if}
<span class="text-xs font-medium text-primary whitespace-nowrap">
{action.label}
</span>
</button>
{#if onImportHubProject && i === 0}
<div class="mx-1 my-0.5 border-t border-gray-200 dark:border-gray-700"></div>
{/if}
{/each}
</div>
{/if}
<!-- option list -->
<div class="flex flex-col gap-0.5 p-2 w-[18rem] shrink-0">
{#snippet rowBody(option: Option, ac: (typeof accentClasses)[string])}
<div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 {ac.tile}">
<option.icon size={14} class={ac.iconText} />
</div>
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
{option.label}
</span>
{#if option.badge}
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {option
.badge.class}"
>
{option.badge.label}
</span>
{/if}
{/snippet}
{#each allOptions as option (option.key)}
{@const ac = accentClasses[option.accent]}
{@const rowClass =
'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'}
{#if option.variants}
<button
use:melt={$wacSubTrigger}
class={rowClass}
onfocusin={() => (activeKey = option.key)}
onpointerenter={() => (activeKey = option.key)}
>
{@render rowBody(option, ac)}
<ChevronRight size={14} class="shrink-0 text-tertiary" />
</button>
{#if $wacSubOpen}
<div
use:melt={$wacSubMenu}
use:hugViewportRight
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
>
{#each option.variants ?? [] as variant (variant.label)}
{@const VariantIcon = variant.icon}
<button
use:melt={$item}
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onclick={() => variant.onSelect()}
>
<VariantIcon width={14} height={14} />
<span class="text-xs font-medium text-primary">{variant.label}</span>
</button>
{/each}
</div>
{/if}
{:else}
<button
use:melt={$item}
class={rowClass}
onfocusin={() => (activeKey = option.key)}
onpointerenter={() => (activeKey = option.key)}
onclick={() => option.onSelect()}
>
{@render rowBody(option, ac)}
</button>
{/if}
{/each}
<!-- bottom import section: one entry whose submenu imports any artifact -->
<div class="mx-1 my-1 border-t border-gray-200 dark:border-gray-700"></div>
{#if !showDoc}
<button
use:melt={$importSubTrigger}
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
class="mt-1 px-2 py-1 text-left text-[10px] text-tertiary/70 hover:text-tertiary hover:underline transition-colors"
title="Show descriptions"
tabindex={-1}
onclick={() => setShowDoc(true)}
>
<div
class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700"
>
<Import size={14} class="text-gray-600 dark:text-gray-300" />
</div>
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
Import
</span>
<ChevronRight size={14} class="shrink-0 text-tertiary" />
Show descriptions
</button>
{#if $importSubOpen}
<div
use:melt={$importSubMenu}
use:hugViewportRight
class="z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none"
>
{#each importActions as action (action.label)}
<button
use:melt={$item}
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onclick={() => action.onSelect()}
>
<Import size={14} class="shrink-0 text-tertiary" />
<span class="text-xs font-medium text-primary whitespace-nowrap">
{action.label}
</span>
</button>
{/each}
</div>
{/if}
{#if !showDoc}
<button
class="mt-1 px-2 py-1 text-left text-[10px] text-tertiary/70 hover:text-tertiary hover:underline transition-colors"
title="Show descriptions"
tabindex={-1}
onclick={() => setShowDoc(true)}
>
Show descriptions
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<!-- shared import drawer (YAML / JSON) for the bottom "Import" submenu actions -->
<Drawer bind:this={importDrawer} size="800px">
@@ -0,0 +1,48 @@
<script lang="ts">
import { untrack } from 'svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import HubTemplatePicker from './HubTemplatePicker.svelte'
import type { HubProjectPick } from '$lib/hubProject'
interface Props {
open: boolean
/** A project was chosen. The host closes this and opens the import dialog on it. */
onPick: (project: HubProjectPick) => void
onClose: () => void
}
let { open, onPick, onClose }: Props = $props()
// Bound, not one-way, for the same reason the import dialog binds it: `Modal` dispatches
// `confirmed`/`canceled` only, so the X, Escape and the backdrop are visible to the caller
// through this value and nowhere else.
let modalOpen = $state(false)
let wasOpen = false
$effect(() => {
const shouldBeOpen = open
if (shouldBeOpen !== untrack(() => modalOpen)) {
modalOpen = shouldBeOpen
if (shouldBeOpen) logFeatureUsage('home', 'template_picker_open', { key: 'new_menu' })
}
})
$effect(() => {
const isOpen = modalOpen
untrack(() => {
if (!isOpen && wasOpen) onClose()
wasOpen = isOpen
})
})
</script>
<!-- A dialog rather than the empty state's popover: this one opens from an item inside an
already-open dropdown, and a popover anchored there leaves two melt layers arguing over
focus and dismissal. The height is fixed so the list inside has a definite box to page in,
the same requirement the popover meets with `fitViewport`. -->
<!-- `kind="X"`: picking a card is the action, so a Cancel button under the list would be the
only thing in the dialog that looks like one. -->
<Modal bind:open={modalOpen} kind="X" title="Import a hub project" class="sm:!max-w-[560px]">
<div class="flex h-[min(70vh,520px)] flex-col">
<HubTemplatePicker fullWidth {onPick} />
</div>
</Modal>
@@ -0,0 +1,151 @@
<script lang="ts">
import { untrack } from 'svelte'
import { ArrowRight, ArrowUpRight, LayoutGrid, Star } from 'lucide-svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import {
hubAppIcon,
hubBrowserUrl,
hubProjectCatalogue,
type HubProjectPick
} from '$lib/hubProject'
import { workspaceStore } from '$lib/stores'
interface Props {
onPick: (project: HubProjectPick) => void
/** Take the width given instead of the popover's own, for a host that sets one. */
fullWidth?: boolean
}
let { onPick, fullWidth = false }: Props = $props()
let list: InfiniteList | undefined = $state(undefined)
// The hub serves its whole catalogue in one response, so paging happens here: the list
// asks for a window and gets a slice of what `hubProjectCatalogue` already holds. Should
// the hub ever paginate, only this loader changes.
// `InfiniteList` shows its `empty` snippet for a list that came back empty and for one that
// failed, so the loader records which happened: a reachable hub that has published nothing
// is not a hub that could not be reached.
let loadFailed = $state(false)
// Armed once per workspace, and inside `untrack`: `setLoader` loads immediately, and both
// it and `loadData` read the list's own reactive state as well as writing it — called
// tracked, this effect depends on what the load changes and re-runs itself. An empty
// catalogue never settles that cycle, which spins the tab at full CPU.
let loadedFor: string | undefined = undefined
$effect(() => {
const workspace = $workspaceStore
if (!list || !workspace || loadedFor === workspace) return
loadedFor = workspace
untrack(() => {
list?.setLoader(async (page: number, perPage: number) => {
try {
const all = await hubProjectCatalogue(workspace)
loadFailed = false
return all.slice((page - 1) * perPage, page * perPage)
} catch (error) {
loadFailed = true
throw error
}
})
})
})
let hubUrl = $state('https://hub.windmill.dev')
void hubBrowserUrl()
.then((u) => (hubUrl = u))
.catch(() => {})
// `hubBrowserUrl` hands back the instance setting as the admin wrote it, so a scheme-less
// one would make `new URL` throw — in render, which takes the popover down with it.
let hubHost = $derived.by(() => {
try {
return new URL(hubUrl).host
} catch {
return hubUrl
}
})
</script>
<!-- The popover gives this box a definite height; the list takes what the header leaves and
scrolls inside it, which is also what lets it page. -->
<div class="flex min-h-0 flex-col {fullWidth ? 'w-full flex-1' : 'w-[380px]'}">
<!-- The hub is named once, as the link to it: a footer row saying the same thing again is
a second line spent on somewhere the reader is not going. -->
<p class="px-3 pb-2 pt-3 text-[11.5px] leading-snug text-hint">
Working projects from
<a
href="{hubUrl}/projects"
target="_blank"
rel="noreferrer"
class="inline-flex items-baseline gap-0.5 text-secondary hover:text-emphasis hover:underline"
>
{hubHost}<ArrowUpRight size={11} class="self-center" />
</a>
— imported as a folder in this workspace.
</p>
<div class="min-h-0 flex-1 border-t border-border-light">
<!-- The height comes from the flex chain above: DataTable's own container is `h-full`, so
the scroll box inside it is bounded, which is what lets the list page. -->
<InfiniteList bind:this={list} noBorder rounded={false}>
{#snippet customRow({ item }: { item: HubProjectPick })}
{@const Icon = hubAppIcon(item.iconApps[0] ?? '')}
<tr>
<td class="p-0">
<button
class="group flex w-full items-start gap-3 border-b border-border-light px-3 py-2.5 text-left hover:bg-surface-hover"
onclick={() => onPick(item)}
>
<div class="flex size-[22px] shrink-0 items-center justify-center">
{#if item.logoUrl}
<img src={item.logoUrl} alt="" class="max-h-[22px] max-w-[22px] object-contain" />
{:else if Icon}
<Icon size={20} />
{:else}
<LayoutGrid size={18} class="text-tertiary" />
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate text-xs font-semibold text-emphasis">{item.name}</span>
{#if item.stars > 0}
<span
class="flex shrink-0 items-center gap-0.5 text-2xs font-normal text-tertiary"
>
<Star size={11} />{item.stars}
</span>
{/if}
<!-- The arrow is the only thing that appears on hover: the whole row is the
control, so it says where the row goes rather than acting as a button. -->
<ArrowRight
size={13}
class="shrink-0 text-tertiary opacity-0 transition group-hover:opacity-100"
/>
</div>
<!-- What the project is, in its own words. The item counts it used to carry say
nothing about whether this is the project you want; the wizard shows them on
the step where they matter, right before the import runs. -->
<p
class="mt-1 line-clamp-4 text-[11.5px] font-normal leading-relaxed text-secondary"
>
{item.description || item.summary}
</p>
</div>
</button>
</td>
</tr>
{/snippet}
{#snippet empty()}
<p class="px-3 py-6 text-xs text-secondary">
{#if loadFailed}
Could not reach the hub. You can still browse its projects in a new tab.
{:else}
This hub has no projects yet.
{/if}
</p>
{/snippet}
</InfiniteList>
</div>
</div>
@@ -0,0 +1,363 @@
<script lang="ts">
import { untrack } from 'svelte'
import { watch } from 'runed'
import Modal from '$lib/components/common/modal/Modal.svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import ImportWizardSteps from '$lib/components/ImportWizardSteps.svelte'
import ImportProjectStep from '$lib/components/ImportProjectStep.svelte'
import ImportSetupStep from '$lib/components/ImportSetupStep.svelte'
import ImportProjectCard, {
type ImportProjectSummary
} from '$lib/components/ImportProjectCard.svelte'
import { fetchHubProject, hubBrowserUrl, type HubProjectPick } from '$lib/hubProject'
import type { ImportExecution } from '$lib/importWizard/execution.svelte'
import { useSetupStep } from '$lib/importWizard/setupStep.svelte'
import type { ImportPlan } from '$lib/importWizard/plan'
import { workspaceStore } from '$lib/stores'
import { hubProjectUsageKey, logFeatureUsage } from '$lib/utils/featureUsage'
import { sendUserToast } from '$lib/toast'
/**
* Writes a hub project's items into the active workspace. It checks no permission itself,
* so a caller must not offer it to an operator or in a workspace whose direct-deploy
* protection has cleared `showEditButtons` — `ItemsList` gates both of its entry points on
* exactly that, and the import would otherwise fail item by item against the server's own
* checks, after the dialog had promised to run.
*/
interface Props {
/** The project the picker chose. Setting it opens the dialog. */
pick: HubProjectPick | undefined
onClose: () => void
/** The import landed: the caller reloads its list, which replaces the empty state. */
onImported?: () => void
}
let { pick, onClose, onImported }: Props = $props()
let slug = $derived(pick?.slug)
// Bound, not one-way: the dialog closes itself on the X, Escape and the backdrop, and
// `Modal` reports none of those — it dispatches `confirmed`/`canceled` only. Left unbound,
// a dismissal hid the dialog while `pick` still held the project, so re-picking the same
// one did nothing, and a run in flight kept writing with no UI in front of it.
let modalOpen = $state(false)
let wasOpen = false
$effect(() => {
const shouldBeOpen = pick !== undefined
if (shouldBeOpen !== untrack(() => modalOpen)) modalOpen = shouldBeOpen
})
$effect(() => {
const isOpen = modalOpen
untrack(() => {
if (!isOpen && wasOpen) dismiss()
wasOpen = isOpen
})
})
/**
* Dismissed rather than finished. A run in flight is stopped at the next phase boundary —
* nothing can abort a request already sent — and what it has already written stays: a
* second run asks the workspace what it holds, so reopening the project carries on from
* there rather than importing twice.
*/
function dismiss() {
// Where they left, which is the half of the funnel Finish cannot report: `running`
// walked out on an import in progress, `setup` on the credentials it asked for,
// `done` closed a landed import instead of pressing Finish, `idle` opened the dialog
// and picked nothing up. `done` is its own bucket because it is a normal exit — folded
// into `idle` it would read as people bouncing off a dialog they never used.
if (!finishing) {
const stage: AbandonStage = execution?.running
? 'running'
: onSetupStep
? 'setup'
: execution?.done
? 'done'
: 'idle'
logFeatureUsage('home', 'template_abandon', { key: stage })
}
if (execution?.running) {
execution.abandon()
// Not on the way out through Finish: `done` survives a retry, so Finish is clickable
// while the run is going again. Stopping it is still right — nothing is watching it
// any more — but a toast saying the import was stopped contradicts the click.
if (!finishing) {
sendUserToast('Import stopped. What it already added stays; reopen the project to finish.')
}
}
// A run that started wrote items, whether it finished, was abandoned midway or failed
// partway — so the list behind this dialog is stale either way, and only `finish()`
// was reloading it. Closing a landed import with the X left an emptied-out home
// showing its placeholder rows over a workspace that now holds a project.
if (!finishing && execution) void reloadWhenSettled(execution, onImported)
finishing = false
onClose()
}
const RELOAD_BACKSTOP_MS = 15_000
/**
* The one way this dialog reloads the caller's list: after the run stops writing. Both
* exits use it, because both can be taken mid-write — `abandon()` only stops the *next*
* phase and the request already sent still lands, and `done` survives a retry so Finish is
* clickable while the run is going again. Reading the list at either of those moments
* reads it before the write commits, which is what the reload exists to prevent.
*/
async function reloadWhenSettled(run: ImportExecution, reload: (() => void) | undefined) {
// A backstop as well, because `installProject` issues its writes serially and takes no
// signal: one request left pending after earlier items committed would leave those
// invisible until the next page load. It does not replace the settlement reload — that
// was the flaw in the timeout this grew out of — so a hung run reloads on the bound and
// again if it ever finishes.
const backstop = setTimeout(() => {
if (run.running) reload?.()
}, RELOAD_BACKSTOP_MS)
await run.whenIdle()
clearTimeout(backstop)
reload?.()
}
// The wizard route asks step 1 which workspace to import into and step 2 which one it
// is. Opened from inside a workspace both answers are already given, so the dialog
// starts at the import itself and the plan is fixed rather than URL-driven.
let folder = $state<string | undefined>(undefined)
let onSetupStep = $state(false)
let execution = $state<ImportExecution | undefined>(undefined)
let plan = $derived<ImportPlan>({
slug: slug ?? '',
destination: { kind: 'existing', workspaceId: $workspaceStore },
folder
})
const setup = useSetupStep(
() => execution,
() => $workspaceStore
)
// The catalogue the picker reads carries no item counts, and both the header card and
// the import step show them, so the detail is fetched for the one project chosen. The
// card renders from the pick until it lands — counts are the only thing missing, and
// zero counts render as no badges rather than as zeroes.
//
// `fetchHubProject` takes no abort signal and nothing orders the responses, so a fetch
// records its answer only while its own slug is still the chosen one, tagged with that
// slug. A late answer for a project the user has moved on from can then neither reach the
// card nor take the current project's counts back off it. Not `resource()`, which
// publishes whatever its fetcher returns, superseded or not — that is the second half
// back again, since the card would read a value the slug guard can only reject.
let answered = $state<{ slug: string; project: ImportProjectSummary } | undefined>(undefined)
watch(
() => slug,
(s) => {
if (!s) return
void fetchHubProject(s)
.then((project) => {
if (s === slug) answered = { slug: s, project }
})
.catch((error) => console.error('Could not load the hub project:', error))
}
)
let fetchedDetail = $derived<ImportProjectSummary | undefined>(
answered && answered.slug === slug ? answered.project : undefined
)
let project = $derived<ImportProjectSummary | undefined>(
fetchedDetail ??
(pick
? {
slug: pick.slug,
name: pick.name,
summary: pick.summary,
author: pick.author,
apps: pick.apps,
logoUrl: pick.logoUrl,
iconApps: pick.iconApps,
counts: { apps: 0, flows: 0, scripts: 0, resources: 0 }
}
: undefined)
)
// Asked for on the first pick, not at init: this dialog is mounted by the home list for
// every user on every arrival, and the host is a string only the project card renders.
let hubHost = $state('hub.windmill.dev')
let hubHostAsked = false
$effect(() => {
if (!slug || hubHostAsked) return
hubHostAsked = true
void hubBrowserUrl()
.then((u) => (hubHost = new URL(u).host))
.catch(() => {})
})
// Each open is its own import: a dialog reopened for another project must not inherit
// the previous run, or its step would offer to resume a bundle from a different slug.
$effect(() => {
if (slug === undefined) {
folder = undefined
onSetupStep = false
execution = undefined
}
})
// The counters' key vocabularies, enumerated here so the whole set is reviewable at once.
type AbandonStage = 'running' | 'setup' | 'done' | 'idle'
type SetupOutcome = 'filled' | 'skipped' | 'none' | 'unchecked'
type SetupBucket = 'filled' | 'none' | 'unchecked' | 'skipped_1' | 'skipped_2_5' | 'skipped_6plus'
// Set for the closing that Finish itself asks for, since that closing reaches `dismiss()`
// by the same falling edge as the X.
let finishing = false
/**
* How the credentials step ended, counted alongside the import itself: `filled` only when
* nothing was left outstanding — the step disables Finish until then — `none` where the
* project asked for nothing, `unchecked` where the step could not read the export and so
* offered Finish over lists it never filled, and a `skipped_*` bucket carrying roughly how
* many rows were walked away from, since skipping with one credential left and skipping
* with eight are different problems.
*
* A bucket rather than `value`: `value` is an increment, so counting rows there would make
* `skipped` a sum of rows while its siblings count imports — two units in one counter, and
* no way to recover filled-versus-skipped.
*/
function setupKey(outcome: SetupOutcome, outstanding: number): SetupBucket {
if (outcome !== 'skipped') return outcome
if (outstanding <= 1) return 'skipped_1'
return outstanding <= 5 ? 'skipped_2_5' : 'skipped_6plus'
}
function finish(setupOutcome: SetupOutcome, outstanding = 1) {
// On the way out rather than on the pick: what is worth counting is an import that
// landed, not a dialog that was opened and abandoned.
if (slug) logFeatureUsage('home', 'template_import', { key: hubProjectUsageKey(slug) })
logFeatureUsage('home', 'template_setup', { key: setupKey(setupOutcome, outstanding) })
finishing = true
// Through the same deferred reload every closing uses. `done` survives a retry, so
// Finish is clickable while the run is going again — reloading here would read the
// list mid-write, and `finishing` then stops `dismiss()` from reloading after it.
// One reload per closing, always after the writing stops.
if (execution) void reloadWhenSettled(execution, onImported)
else onImported?.()
onClose()
}
// The two pages this dialog has, named the way the dialog titles them. The route wizard
// asks two more questions before these; here both were answered by being in a workspace.
const IMPORT_PAGE = 'Import the project'
const SETUP_PAGE = 'Fill credentials'
let currentPage = $derived(onSetupStep ? SETUP_PAGE : IMPORT_PAGE)
// Whether this import ends on the credentials step, predicted before it runs so the stepper
// can name both steps from the first frame rather than growing one mid-flow. The hub's
// count is every resource the project ships, while the step only asks about the ones
// something in it points at, so this errs towards naming a step the dialog then skips —
// `setup.needed` is the real answer and lands with the export. Forward navigation is
// blocked on that one, so an over-named step is a label, never a page with nothing on it.
let setupExists = $derived((project?.counts?.resources ?? 0) > 0 || setup.needed || onSetupStep)
// The box height, decided when the dialog opens and left alone. It cannot follow
// `setupExists`: that turns true when the detail fetch lands a moment after opening, and a
// box that grows then is the dialog visibly loading in two steps. The catalogue already
// says which integrations a project uses, which is what its credential stubs are, so the
// answer is there in the first frame. A page that outgrows the box scrolls instead, with
// its actions pinned.
let tallBox = $state(false)
$effect(() => {
const opened = pick
untrack(() => {
if (opened) tallBox = (opened.apps?.length ?? 0) > 0
})
})
</script>
{#snippet importPage()}
<div class="flex flex-1 flex-col gap-4 overflow-y-auto">
{#if project}
<!-- What is about to be imported, before the checklist says what will happen to
it: the project's own logo, name and prose. -->
<ImportProjectCard {project} {hubHost} description={pick?.description} showCounts={false} />
{/if}
<!-- The note stays: this dialog always imports into a workspace that already holds
things, which is the case it is about. Only a brand-new workspace has nothing to
say about resources it will not overwrite or triggers that arrive disabled. -->
<ImportProjectStep
chooseFolder={false}
fillHeight
{plan}
{project}
setupPending={setup.needed}
setupUndecided={setup.undecided}
onFolderChange={(f) => (folder = f)}
onFinish={() => (setup.needed ? (onSetupStep = true) : finish('none'))}
onBack={onClose}
onExecution={(e) => (execution = e)}
resume={execution}
/>
</div>
{/snippet}
{#snippet setupPlaceholder()}
<!-- The shape of the credentials page — a line of prose, then the rows to fill — so the
slide has something to carry. The real step mounts on arrival and replaces it. -->
<div class="flex flex-1 flex-col gap-4 pt-1">
<Skeleton layout={[[2], 0.5, [1], 0.8, [3], 0.5, [3], 0.5, [3]]} />
</div>
{/snippet}
{#snippet setupPage()}
<div class="flex flex-1 flex-col overflow-y-auto">
<ImportSetupStep
fillHeight
workspace={$workspaceStore ?? ''}
slug={slug ?? ''}
{folder}
showHeading={false}
onSkip={(outstanding) => finish('skipped', outstanding)}
onFinish={(checked) => finish(checked ? 'filled' : 'unchecked')}
onBack={execution ? () => (onSetupStep = false) : undefined}
/>
</div>
{/snippet}
<!-- No title: the stepper names the step and the card names the project, so a third label for
what those two already say would only compete with them. -->
<Modal
title=""
paginated
bind:open={modalOpen}
enterConfirms={false}
class="sm:!max-w-[640px]"
kind="X"
>
{#if slug}
<!-- The wizard's own stepper, not the dialog's page breadcrumb: this is the same flow
the /projects/import route runs, minus the two steps a workspace already answers.
Outside the pages, so it stays put while they slide. `lowestStep` closes the way
back once the run this dialog held is gone. -->
<ImportWizardSteps
step={onSetupStep ? 2 : 1}
labels={['Import']}
setupLabel={SETUP_PAGE}
hasSetup={setupExists}
lowestStep={execution ? 1 : 2}
onNavigate={(s) => (onSetupStep = s === 2)}
/>
<!-- A definite height, which laid-over pages need: they are absolutely positioned, so
without one the box collapses. Fixed per shape rather than per page — a dialog that
resizes as a page slides in is the jump this pattern exists to avoid — but the setup
page is much the taller of the two, so a project that has no setup step is not given
its room. -->
<PagedContent
class={tallBox ? 'h-[min(72vh,560px)]' : 'h-[380px]'}
current={currentPage}
onNavigate={(key) => {
if (key === IMPORT_PAGE && execution) onSetupStep = false
else if (key === SETUP_PAGE && setup.needed) onSetupStep = true
}}
pages={[
{ key: IMPORT_PAGE, content: importPage },
{ key: SETUP_PAGE, content: setupPage, placeholder: setupPlaceholder }
]}
/>
{/if}
</Modal>
+215 -10
View File
@@ -16,7 +16,7 @@
} from '$lib/gen'
import { resource } from 'runed'
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { disableHubStore, userStore, workspaceStore } from '$lib/stores'
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ArrowDownUp,
@@ -40,6 +40,10 @@
type FilterSchemaRec
} from '$lib/components/FilterSearchbar.svelte'
import NoItemFound from './NoItemFound.svelte'
import WorkspaceEmptyState from './WorkspaceEmptyState.svelte'
import HubProjectPickerModal from './HubProjectPickerModal.svelte'
import ImportProjectModal from './ImportProjectModal.svelte'
import type { HubProjectPick } from '$lib/hubProject'
import ListFilters from './ListFilters.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
@@ -696,6 +700,10 @@
// runnables an owner holds. A scope change (sort/archive/kind/…) doesn't go
// through here: the counts resource keys on those itself.
async function reloadItemsAndCounts(): Promise<void> {
// The answer can change with the rows: archiving the last item leaves the listing empty
// with something archived behind it, and a cached "nothing archived" would then call
// the workspace empty and hide the way to it until a page load.
archivedProbe = undefined
// A mutated row can be gone, or sit at a new path, afterwards: snapshot what
// was on screen so the selection can drop what this reload removes instead of
// keeping a dead path. `tick` lets the reloaded rows re-register first.
@@ -986,6 +994,105 @@
treeLazyMode && ownerCountsRes.current == undefined && ownerCountsRes.loading
)
// An import just landed, so the rows about to replace the empty state are all new: they
// fade in one after another rather than appearing as a finished list. Cleared on a timer
// because nothing else marks the end — the reload resolves before the rows animate.
let justImported = $state(false)
let justImportedTimer: ReturnType<typeof setTimeout> | undefined
function onImported() {
reloadItemsAndCounts()
justImported = true
clearTimeout(justImportedTimer)
justImportedTimer = setTimeout(() => (justImported = false), 2500)
}
// The hub import, owned here rather than by either entry point: the empty state's link and
// the create menu's Import section open the same dialog, and mounting one per entry point
// would put two of them on the page at once while the workspace is still empty.
let hubPick = $state<HubProjectPick | undefined>(undefined)
let hubPickerOpen = $state(false)
/**
* Whether a workspace the default listing found empty is empty at all, or just has nothing
* unarchived — two different states that want two different things said about them. Asked
* only in that case, and once per workspace: one request for one row, never on a workspace
* with something in it. `hasArchived` is undefined when the request failed — see the catch
* for what that leaves standing.
*/
let archivedProbe = $state<{ workspace: string; hasArchived: boolean | undefined } | undefined>(
undefined
)
$effect(() => {
const ws = $workspaceStore
if (!ws || !workspaceEmpty || archivedProbe?.workspace === ws) return
untrack(() => void probeArchived(ws))
})
async function probeArchived(workspace: string) {
try {
// `includeWithoutMain` to match the listing: the backend drops `auto_kind = 'lib'`
// without it, so a workspace holding only archived library scripts would answer
// "nothing archived". Always true here — hiding library scripts puts a filter in
// `activeFilters`, which `workspaceEmpty` requires to be empty.
const res = await ScriptService.listRunnables({
workspace,
showArchived: true,
includeWithoutMain: true,
perPage: 1
})
archivedProbe = { workspace, hasArchived: (res.items?.length ?? 0) > 0 }
} catch (error) {
// Undefined, not false: false would say the workspace is empty and — since the
// toolbar is inert on the strength of the placeholder carrying the way to archived
// items — leave no way to them at all. Unknown keeps the ordinary caption, which
// promises nothing, and leaves the searchbar live as the fallback it used to be.
console.error('Could not check for archived items:', error)
archivedProbe = { workspace, hasArchived: undefined }
}
}
let emptyStateAnswered = $derived(archivedProbe?.workspace === $workspaceStore)
/**
* The probe could not tell. The toolbar stays usable in that case: `inert` is only right
* while the placeholder is the way to archived items, and here it cannot be.
*/
let archivedUnknown = $derived(emptyStateAnswered && archivedProbe?.hasArchived === undefined)
/**
* Whether this user may be offered the create actions. The empty state's template import
* and create menu do no permission check of their own, so an operator — or a workspace
* whose direct-deploy protection cleared `showEditButtons` — must not be shown them.
* Reading archived items is not a write, so it is not gated on this.
*/
let canCreateHere = $derived(!$userStore?.operator && showEditButtons)
// The workspace itself holds nothing — no filter is narrowing the list away. It stays
// false until the first load resolves: a skeleton already means "loading", and the
// empty state must not be mistaken for one. The controls it dims stay mounted, so
// nothing moves when the first item lands.
let workspaceEmpty = $derived(
!loading &&
!treeCountsPending &&
!contentActive &&
activeFilters.length === 0 &&
filteredItems != undefined &&
filteredItems.length === 0 &&
visiblePipelineFolders.size === 0 &&
!hasMoreServer
)
/**
* Whether the placeholder below takes the toolbar's job over — it renders under the same
* conditions. Standing the toolbar down depends on something else offering a way onwards:
* where the placeholder holds back, as it does for an operator in a workspace that is
* simply empty, these controls are all there is and stay live.
*/
let placeholderTakesOver = $derived(
workspaceEmpty && emptyStateAnswered && (archivedProbe?.hasArchived === true || canCreateHere)
)
/**
* The toolbar is dimmed either way; `inert` also takes it off the pointer, which is only
* right while the placeholder carries the way to archived items. A probe that could not
* tell leaves it live as the fallback.
*/
let toolbarInert = $derived(placeholderTakesOver && !archivedUnknown)
// Owners the counts found the user has something in, split by kind. They cover
// what the folder/username lists miss: an item shared individually out of a
// folder or user space the user is otherwise not a member of.
@@ -1651,7 +1758,12 @@
}}
>
{#if !contentActive}
<div class="flex justify-start">
<!-- Kept mounted, not hidden, so the toolbar doesn't reflow the moment the first item
lands; `inert` takes it out of the tab order and off the pointer meanwhile. A
workspace with nothing but archived items reaches them from its own placeholder,
so these controls are not the way there — but only where that placeholder
renders, which is what `placeholderTakesOver` tracks. -->
<div class="flex justify-start" class:opacity-40={placeholderTakesOver} inert={toolbarInert}>
<ToggleButtonGroup
selected={itemKind}
onSelected={(v) => {
@@ -1692,9 +1804,10 @@
</div>
{/if}
{#if !loading && !contentActive}
{#if !loading && !contentActive && !workspaceEmpty}
<!-- List controls, between the kind toggle and the searchbar: select mode, tree
view, expand/collapse (tree only), sort. -->
view, expand/collapse (tree only), sort. Nothing to select, group or order on
an empty workspace, so the whole row goes. -->
<div class="flex items-center gap-2">
{#if homeSelection.available && !homeSelection.active}
<Button
@@ -1749,7 +1862,11 @@
{/if}
<div class="flex grow items-center justify-end gap-2 min-w-0">
<div class="relative text-primary w-full min-w-[200px] max-w-[26rem]">
<div
class="relative text-primary w-full min-w-[200px] max-w-[26rem]"
class:opacity-40={placeholderTakesOver}
inert={toolbarInert}
>
<FilterSearchbar
schema={searchbarSchema}
bind:value={filterValues.val}
@@ -1764,8 +1881,12 @@
<!-- Same gate the old create actions used: hidden from operators and in workspaces
whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since
the menu itself does no permission check. -->
{#if !$userStore?.operator && showEditButtons}
<CreateActionsMenu />
{#if canCreateHere}
<!-- No hub entry where the instance has the hub turned off: the same setting the
script and flow hub pickers observe. -->
<CreateActionsMenu
onImportHubProject={$disableHubStore ? undefined : () => (hubPickerOpen = true)}
/>
{/if}
</div>
</div>
@@ -1793,7 +1914,7 @@
/>
</div>
{/if}
{#if filteredItems?.length == 0}
{#if filteredItems?.length == 0 && !workspaceEmpty}
<div class="mt-10"></div>
{/if}
<div class="mt-3">
@@ -1820,7 +1941,28 @@
<!-- Pipelines aren't part of the text filter, so only fall through to show
them (list rows / injected tree folders) when not actively searching;
a no-match search still reads as empty. -->
<NoItemFound {activeFilters} />
{#if workspaceEmpty}
<!-- Held until the archived probe answers rather than drawn and swapped: the two
placeholders say different things, and showing the wrong one first says the
workspace is empty when it is not. -->
{#if emptyStateAnswered}
{#if archivedProbe?.hasArchived || canCreateHere}
<!-- Shown to whoever has something to do here: the archived notice to
everyone, since reading archived items is not a write, and the create
actions only to a user who may take them. -->
<WorkspaceEmptyState
archivedOnly={archivedProbe?.hasArchived === true}
canCreate={canCreateHere}
onPick={(project) => (hubPick = project)}
onShowArchived={() => (filterValues.val = { ...filterValues.val, archived: true })}
/>
{:else}
<NoItemFound {activeFilters} />
{/if}
{/if}
{:else}
<NoItemFound {activeFilters} />
{/if}
{#if hasMoreServer && !searching}
<!-- The active filter matched nothing on the loaded pages, but the server
has more: keep paging reachable so matches on later pages aren't lost. -->
@@ -1863,7 +2005,7 @@
/>
{/key}
{:else}
<div class="border rounded-md bg-surface-tertiary">
<div class="border rounded-md bg-surface-tertiary" class:wm-imported={justImported}>
{#if filter === ''}
{#each [...visiblePipelineFolders].sort() as folder (folder)}
<a
@@ -1936,3 +2078,66 @@
onDone={reloadItemsAndCounts}
/>
{/if}
<HubProjectPickerModal
open={hubPickerOpen}
onClose={() => (hubPickerOpen = false)}
onPick={(project) => {
hubPickerOpen = false
hubPick = project
}}
/>
<ImportProjectModal pick={hubPick} onClose={() => (hubPick = undefined)} {onImported} />
<style>
/* Rows arriving after an import, one after another. The animation is declared on the
container's children rather than on each row: a wrapper element around a row would make
every row `first-of-type` and `last-of-type`, which is how Row draws its corners and
separators. The delay steps for the first rows only — past those the stagger is longer
than anyone waits, so they share the last one. */
@keyframes wm-row-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
.wm-imported > :global(*) {
animation: wm-row-in 260ms ease-out both;
animation-delay: 320ms;
}
.wm-imported > :global(*:nth-child(1)) {
animation-delay: 0ms;
}
.wm-imported > :global(*:nth-child(2)) {
animation-delay: 40ms;
}
.wm-imported > :global(*:nth-child(3)) {
animation-delay: 80ms;
}
.wm-imported > :global(*:nth-child(4)) {
animation-delay: 120ms;
}
.wm-imported > :global(*:nth-child(5)) {
animation-delay: 160ms;
}
.wm-imported > :global(*:nth-child(6)) {
animation-delay: 200ms;
}
.wm-imported > :global(*:nth-child(7)) {
animation-delay: 240ms;
}
.wm-imported > :global(*:nth-child(8)) {
animation-delay: 280ms;
}
@media (prefers-reduced-motion: reduce) {
.wm-imported > :global(*) {
animation: none;
}
}
</style>
@@ -1,178 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import CloseButton from '$lib/components/common/CloseButton.svelte'
import { GraduationCap } 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,
TUTORIAL_BANNER_DISMISSED_KEY
} from '$lib/tutorialUtils'
import { tutorialsToDo, userStore, skippedAll } from '$lib/stores'
import { TUTORIALS_CONFIG } from '$lib/tutorials/config'
import { hasRoleAccess } from '$lib/tutorials/roleUtils'
import { onMount } from 'svelte'
type BannerState = 'hidden' | 'start' | 'new'
// Deciding what to show needs an API round-trip, so the banner paints the state the last visit
// resolved to and reconciles once the sync answers. Guessing wrong once in a while beats
// reflowing the home page on every load; nothing cached means hidden, the direction that does
// not push the page down.
const TUTORIAL_BANNER_STATE_KEY = 'tutorial_banner_state'
const cachedState =
getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true'
? 'hidden'
: getLocalSetting(TUTORIAL_BANNER_STATE_KEY)
let isDismissed = $state(cachedState !== 'start' && cachedState !== 'new')
let hasCompletedAny = $state(cachedState === 'new')
/**
* Get all tutorial indexes that are accessible to the current user based on their role.
* Automatically recomputes when $userStore changes.
*/
const accessibleTutorialIndexes = $derived.by(() => {
const indexes = new Set<number>()
const user = $userStore
for (const tab of Object.values(TUTORIALS_CONFIG)) {
// Check if user has access to this tab category
if (!hasRoleAccess(user, tab.roles)) {
continue
}
for (const tutorial of tab.tutorials) {
// Check if tutorial has an index and user has access to it
if (tutorial.index !== undefined && hasRoleAccess(user, tutorial.roles)) {
indexes.add(tutorial.index)
}
}
}
return indexes
})
function resolveState(state: BannerState) {
isDismissed = state === 'hidden'
hasCompletedAny = state === 'new'
// Last: persisting is best-effort, and a storage failure must not leave the banner stuck on
// whatever the cache said
storeLocalSetting(TUTORIAL_BANNER_STATE_KEY, state)
}
// The banner is interactive while the initial sync is still in flight, so a dismiss or a skip
// can land mid-await. Once that happens the user's choice wins and the sync must not resurrect
// the banner.
let userHidBanner = false
function hideBannerForUser() {
userHidBanner = true
resolveState('hidden')
}
onMount(async () => {
// Manually dismissed via the X button (soft dismiss, per-device). Checked before the network
// call so a dismissed banner can never flash back in.
if (getLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY) === 'true') {
resolveState('hidden')
return
}
try {
// Sync tutorial progress from backend first
await syncTutorialsTodos()
} catch (error) {
console.error('Failed to sync tutorial progress:', error)
// Keep whatever the last successful sync resolved to rather than guessing again
return
}
if (userHidBanner) {
return
}
// Check if user deliberately skipped all tutorials (permanent dismiss, from backend)
if ($skippedAll) {
resolveState('hidden')
return
}
// Safe to check tutorialsToDo here since we awaited syncTutorialsTodos() above
// Filter tutorialsToDo to only include tutorials accessible to the user's role
const remainingAccessibleTutorials = $tutorialsToDo.filter((index) =>
accessibleTutorialIndexes.has(index)
)
// Hide banner if all accessible tutorials are completed (but can reappear with new tutorials)
if (remainingAccessibleTutorials.length === 0) {
resolveState('hidden')
return
}
// Having completed at least one accessible tutorial switches the wording to
// "New tutorial available!" instead of "Learn with interactive tutorials"
resolveState(
remainingAccessibleTutorials.length < accessibleTutorialIndexes.size ? 'new' : 'start'
)
})
async function handleSkipAllTutorials() {
// Skip all tutorials and set skipped_all flag in backend (permanent)
await skipAllTodos()
await syncTutorialsTodos()
// No need to set the dismissed flag - backend skipped_all flag is the source of truth
hideBannerForUser()
}
function dismissBanner() {
storeLocalSetting(TUTORIAL_BANNER_DISMISSED_KEY, 'true')
hideBannerForUser()
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}
<!-- A standing invitation, not an announcement: it sits inline at the start of the row rather
than filling the page, so it reads as one more control and not as a card the user has to
dispatch before getting to their work. -->
<div class="flex items-center gap-2 mt-4 mb-4">
<span class="text-hint text-xs truncate min-w-0">
{#if hasCompletedAny}
New tutorial available!
{:else}
First time?
{/if}
</span>
<Button
unifiedSize="sm"
variant="default"
onclick={goToTutorials}
startIcon={{ icon: GraduationCap }}
>
Tutorials
</Button>
<CloseButton small noBg title="Dismiss tutorial banner" onClick={dismissBanner} />
</div>
{/if}
@@ -1,124 +0,0 @@
<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>
@@ -0,0 +1,146 @@
<script lang="ts">
import { onMount } from 'svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { HubProjectPick } from '$lib/hubProject'
import { disableHubStore } from '$lib/stores'
import CreateActionsMenu from './CreateActionsMenu.svelte'
import HubTemplatePicker from './HubTemplatePicker.svelte'
interface Props {
/** A project was chosen here. The list owns the import dialog, and opens it on this. */
onPick: (project: HubProjectPick) => void
/**
* The workspace holds items, all of them archived. It is not empty and must not be
* told it is: the caption names what is there and offers the way to it instead.
*/
archivedOnly?: boolean
/** Switches the list to the archived view. */
onShowArchived: () => void
/**
* Whether to offer the template import and the create menu. Neither checks permissions
* itself, so an operator gets the state described without the two actions it may not
* take — the archived link stays, since reading archived items is not a write.
*/
canCreate?: boolean
}
let { onPick, archivedOnly = false, onShowArchived, canCreate = true }: Props = $props()
// Row opacities: the list fading out of existence. Static on purpose — motion is what
// makes a skeleton mean "loading", and this state means "empty".
// `border-border-*`, not `border-*`: the Tailwind colour keys are themselves named
// `border-light` / `border-normal`, so `border-light` matches no utility and silently
// falls back to the global default border colour in app.css. Bars and dashes both sit on
// `border-light`; only the container outline steps up, so nothing outweighs its frame.
const rowOpacities = [1, 0.7, 0.4]
// The inline "create a new one" link is the anchor for the very same New menu the
// toolbar button opens, so the menu pops next to the words that promised it.
let newLinkEl: HTMLButtonElement | undefined = $state(undefined)
onMount(() => {
// Only the empty case: the counter answers how many workspaces sit empty and what
// their owners do next, and a workspace whose items are all archived is neither.
if (!archivedOnly) logFeatureUsage('home', 'empty_state_view')
})
// The catalogue is fetched when the picker opens, never on render: `disable_hub` says an
// instance makes no hub requests at all, and it loads asynchronously, so anything fired
// from here goes out before the setting that forbids it is known.
</script>
<div
class="rounded-md border-[1.5px] border-dashed border-border-normal/60 bg-surface"
role="status"
aria-label={archivedOnly ? 'Everything here is archived' : 'Your workspace is empty'}
>
{#each rowOpacities as opacity, i (i)}
<div
aria-hidden="true"
class="flex items-center gap-[14px] px-4 py-[13px] {i > 0
? 'border-t border-dashed border-border-light'
: ''}"
style="opacity: {opacity}"
>
<div class="size-4 shrink-0 rounded bg-border-light"></div>
<div>
<div class="h-[9px] w-[140px] rounded-full bg-border-light"></div>
<div class="mt-[5px] h-[7px] w-[70px] rounded-full bg-border-light/60"></div>
</div>
</div>
{/each}
<!-- A <div>, not a <p>: CreateActionsMenu wraps its trigger in an element, which a
paragraph may not contain. -->
<div
class="border-t border-dashed border-border-light px-4 pb-[22px] pt-[18px] text-center text-[13.5px] leading-relaxed text-hint"
>
{#if archivedOnly}
<!-- Its own line: the state and the invitation are two sentences, and splicing them
into one leaves a link doing the work of a conjunction.
Every link in this caption is a bare <button>, signed off by design: <Button>
carries its own padding and background and cannot sit inline in running text.
They take `text-accent`, never a raw Tailwind blue. -->
<span class="block">
Everything in this workspace is archived.
<button
class="border-b border-transparent text-accent hover:border-accent"
onclick={onShowArchived}>Show archived items</button
>.
</span>
{:else}
Your scripts, flows and apps will show up here.
{/if}
{#if canCreate}
<!-- The hub half goes when the instance has the hub turned off, and the remaining link
opens the sentence instead of continuing it. -->
{#if !$disableHubStore}
<!-- Opens downward into the page rather than upward into the hero: the caption sits
high when the AI composer is hidden, so the room is below it. `fitViewport` caps
the box on a short viewport, which is why the height below is definite and the
list inside fills it — a squeezed box with a fixed-height list inside overflows
its own frame. -->
<Popover
floatingConfig={{
placement: 'bottom',
strategy: 'absolute',
gutter: 8,
overflowPadding: 16,
flip: { fallbackPlacements: ['top', 'bottom-start', 'top-start'] },
fitViewport: true,
overlap: false
}}
contentClasses="p-0 flex"
contentStyle="height: min(72vh, 520px);"
class="border-b border-transparent text-accent hover:border-accent"
triggerAttrs={{ 'aria-label': 'Start from a template' }}
on:openChange={(e) =>
e.detail && logFeatureUsage('home', 'template_picker_open', { key: 'empty_state' })}
>
{#snippet trigger()}Start from a template{/snippet}
{#snippet content({ close })}
<HubTemplatePicker
onPick={(project) => {
close()
onPick(project)
}}
/>
{/snippet}
</Popover>
or
{/if}
<CreateActionsMenu source="empty_state" triggerElement={newLinkEl}>
{#snippet trigger()}
<!-- The full stop rides inside the snippet: across a component boundary Svelte
keeps the markup whitespace, which would leave a gap before it. -->
<button
bind:this={newLinkEl}
class="border-b border-transparent text-accent hover:border-accent"
>{$disableHubStore ? 'Create a new one' : 'create a new one'}</button
>.
{/snippet}
</CreateActionsMenu>
{/if}
</div>
</div>
@@ -12,10 +12,11 @@
Building,
Calendar,
ServerCog,
GraduationCap,
Table2
Table2,
GraduationCap
} from 'lucide-svelte'
import { base } from '$lib/base'
import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour'
import MultiplayerMenu from './MultiplayerMenu.svelte'
import { Plus } from 'lucide-svelte'
@@ -25,9 +26,7 @@
superadmin,
usedTriggerKinds,
userWorkspaces,
workspaceStore,
tutorialsToDo,
skippedAll
workspaceStore
} from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { USER_SETTINGS_HASH } from './settings'
@@ -56,22 +55,10 @@
[
{ 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 },
// 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
}
]
: [])
{ label: 'Schedules', id: 'schedules', href: `${base}/schedules`, icon: Calendar }
].filter(
(link) =>
link.id === 'home' ||
link.id === 'tutorials' ||
($userWorkspaces &&
$workspaceStore &&
$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] ===
@@ -243,6 +230,21 @@
<Settings size={14} />
Account settings
</MenuItem>
<MenuItem
href="{base}/?{TOUR_PARAM}={TOUR_PARAM_VALUE}"
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2',
sidebarClasses.text,
'transition-colors',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
lightMode
{item}
>
<GraduationCap size={14} />
Take the tour
</MenuItem>
</div>
<div role="none">
@@ -12,7 +12,6 @@
Building,
Moon,
Sun,
GraduationCap,
BookOpen,
Github,
Newspaper,
@@ -120,7 +119,6 @@
}
const helpItems: Item[] = [
{ displayName: 'Tutorials', icon: GraduationCap, href: `${base}/tutorials` },
{
displayName: 'Docs',
icon: BookOpen,
@@ -8,12 +8,9 @@
workspaceStore,
isCriticalAlertsUIOpen,
enterpriseLicense,
devopsRole,
tutorialsToDo,
skippedAll
devopsRole
} from '$lib/stores'
import { isForkOwner } from '$lib/utils/workspaceHierarchy'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
import {
BookOpen,
@@ -26,7 +23,6 @@
FolderCog,
FolderOpen,
Github,
GraduationCap,
HelpCircle,
Home,
LogOut,
@@ -51,7 +47,6 @@
import DiscordIcon from '../icons/brands/Discord.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { twMerge } from 'tailwind-merge'
import { onMount } from 'svelte'
import { base } from '$lib/base'
import { page } from '$app/state'
import SideBarNotification from './SideBarNotification.svelte'
@@ -116,11 +111,6 @@
'boolean'
)
onMount(async () => {
// Sync tutorial progress on mount
await syncTutorialsTodos()
})
function openChangelogs() {
markChangelogsOpened()
hasNewChangelogs = false
@@ -131,14 +121,6 @@
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/',
@@ -269,19 +251,7 @@
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-groups',
aiDescription: 'Button to navigate to groups'
},
// 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'
}
]
: [])
}
].filter((l) => !excludeMainLabels.includes(l.label))
)
let defaultExtraTriggerLinks = $derived([
@@ -1,754 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import {
isFlowTainted,
triggerPointerDown,
clickButtonBySelector,
DELAY_SHORT,
DELAY_MEDIUM,
DELAY_LONG,
DELAY_ANIMATION,
DELAY_ANIMATION_LONG,
DELAY_TYPING,
DELAY_CODE_CHAR,
DELAY_CODE_NEWLINE,
moveCursorToElement,
createFakeCursor
} 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)
// 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 create and animate a fake cursor (extended version with start element support)
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 - 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() {
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}
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 createFakeCursorWithStart(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 createFakeCursorWithStart(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
}}
/>
@@ -0,0 +1,81 @@
<script lang="ts">
import type { DriveStep } from 'driver.js'
import { wait } from '$lib/utils'
import Tutorial from './Tutorial.svelte'
import { markOperatorTourSeen, MENU_OPEN_DELAY_MS } from './operatorTour'
let tutorial: Tutorial | undefined = $state(undefined)
let running = false
export function runTutorial() {
// A second driver mounted over a live one leaves an overlay that nothing closes.
if (running) return
running = true
tutorial?.runTutorial()
}
// Recorded however the tour ends, not only on the last step: someone who closes it has
// answered the question, and the sidebar entry is how they get it back.
function onDestroyed() {
running = false
void markOperatorTourSeen()
}
</script>
<Tutorial
bind:this={tutorial}
{onDestroyed}
getSteps={(driver) => {
const steps: DriveStep[] = [
{
popover: {
title: 'Welcome to Windmill! 🎉',
description:
"Let's take a quick tour! We'll show you the three main tools you can use: Scripts, Flows, and Apps."
}
},
{
popover: {
title: 'Scripts - Run automated tasks',
description:
'<img src="/script-tutorial-operator.png" alt="Script Example" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Scripts</strong> are ready-to-use tasks that do things automatically for you.</p><p style="margin-top: 8px;">You can <strong>run scripts</strong> whenever you need them - like generating a report, sending notifications, or processing data.</p>'
},
element: '[data-value="script"]'
},
{
popover: {
title: 'Flows - Run step-by-step processes',
description:
'<img src="/flow.png" alt="Flow" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Flows</strong> are processes that run multiple tasks in order, one after another.</p><p style="margin-top: 8px;">You can <strong>start a flow</strong> and watch it complete each step automatically - perfect for tasks that have multiple stages.</p>'
},
element: '[data-value="flow"]'
},
{
popover: {
title: 'Apps - Use custom tools',
description:
'<img src="/app.png" alt="App" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Apps</strong> are easy-to-use tools with buttons, forms, and displays built just for your team.</p><p style="margin-top: 8px;">You can <strong>open an app</strong> to work with your data, fill out forms, or trigger tasks - no technical knowledge needed!</p>'
},
element: '[data-value="app"]'
},
{
popover: {
title: 'Finally, the Menu section',
description:
'Explore available tabs where you can access your history of runs, your scheduled scripts, and your workspaces.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to see this again?</strong> Pick <strong>Take the tour</strong> from that same menu.</p>',
onNextClick: async () => {
// The step points into the menu, so it has to be open before the popover
// lands on it — and open is also where the entry to re-run the tour is.
const menuButton = document.querySelector('[role="menuitem"]') as HTMLElement | null
menuButton?.click()
await wait(MENU_OPEN_DELAY_MS)
driver.destroy()
}
},
element: '[role="menuitem"]'
}
]
return steps
}}
/>
@@ -1,510 +0,0 @@
<script lang="ts">
import Tutorial from './Tutorial.svelte'
import type { DriveStep } from 'driver.js'
import { updateProgress } from '$lib/tutorialUtils'
import { JobService, FlowService, type Flow } from '$lib/gen'
import { workspaceStore, userStore } from '$lib/stores'
import { wait } from '$lib/utils'
import { waitJob } from '$lib/components/waitJob'
import {
DELAY_SHORT,
DELAY_MEDIUM,
DELAY_LONG,
createFakeCursor,
animateCursorToElementAndClick,
animateFakeCursorClick
} from './utils'
import { goto } from '$app/navigation'
import { base } from '$lib/base'
import { sendUserToast } from '$lib/toast'
interface Props {
index: number
}
let { index }: Props = $props()
let tutorial: Tutorial | undefined = $state(undefined)
let tutorialFlowPaths: string[] = $state([])
// Flags to track if steps are complete
let step2Complete = $state(false)
let step3Complete = $state(false)
let step5Complete = $state(false)
let step6Complete = $state(false)
// Create a simple flow
async function createTutorialFlow(): Promise<string> {
const flowPath = `f/tutorial/runs-tutorial-flow-${Date.now()}`
const flow: Flow = {
summary: 'Tutorial: Simple Hello World Flow',
description: 'A simple flow created for the runs tutorial',
value: {
modules: [
{
id: 'hello',
value: {
type: 'rawscript',
content:
'export async function main() {\n return {\n message: "Hello from the Runs tutorial!",\n timestamp: new Date().toISOString()\n };\n}',
language: 'bun',
input_transforms: {}
},
summary: 'Say hello'
}
]
},
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {},
required: [],
order: []
},
path: flowPath,
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {}
}
await FlowService.createFlow({
workspace: $workspaceStore!,
requestBody: flow
})
return flowPath
}
// Create a broken flow that will fail
async function createBrokenFlow(): Promise<string> {
const flowPath = `f/tutorial/runs-tutorial-broken-${Date.now()}`
const flow: Flow = {
summary: 'Tutorial: Broken Flow Example',
description: 'A flow that intentionally fails to demonstrate error handling',
value: {
modules: [
{
id: 'error',
value: {
type: 'rawscript',
content:
'export async function main() {\n throw new Error("Intentional error for tutorial - this demonstrates how failed jobs appear in the runs list");\n}',
language: 'bun',
input_transforms: {}
},
summary: 'Throw error'
}
]
},
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {},
required: [],
order: []
},
path: flowPath,
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {}
}
await FlowService.createFlow({
workspace: $workspaceStore!,
requestBody: flow
})
return flowPath
}
// Run the flow and wait for completion
async function runFlowAndWait(flowPath: string): Promise<string> {
const jobId = await JobService.runFlowByPath({
workspace: $workspaceStore!,
path: flowPath,
requestBody: {},
skipPreprocessor: true
})
// Wait for job to complete
await waitJob(jobId)
return jobId
}
function getTutorialSteps(driver: any): DriveStep[] {
return [
{
popover: {
title: 'Welcome to your Monitoring Dashboard!',
description:
"<p>Before we dive in, let's define a key term: a Job. A &quot;Job&quot; is simply a single run of a script or flow. Every time you run code, Windmill creates a Job to track if it succeeded or failed, how long it took, and what the results were.</p><p style='margin-top: 12px;'>In this tutorial, we will explore:</p><ul style='margin-top: 8px; padding-left: 20px;'><li style='margin-bottom: 8px;'><svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='color: #22c55e; display: inline-block; vertical-align: middle; margin-right: 6px;'><circle cx='12' cy='12' r='10'/><path d='m9 12 2 2 4-4'/></svg>A successful job execution.</li><li style='margin-bottom: 8px;'><svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='color: #ef4444; display: inline-block; vertical-align: middle; margin-right: 6px;'><circle cx='12' cy='12' r='10'/><path d='m12 8v4'/><path d='m12 16h.01'/></svg>A failed job execution.</li><li><svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='color: #3b82f6; display: inline-block; vertical-align: middle; margin-right: 6px;'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>How to filter your monitoring view.</li></ul>",
onNextClick: () => {
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#runs-table-wrapper',
onHighlighted: async () => {
step2Complete = false
await wait(DELAY_MEDIUM)
// Find all jobs
const allJobRows = Array.from(
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
) as HTMLElement[]
// Find successful job (green badge/check icon) - first one that's not failed
const successfulJobRow =
allJobRows.find((el) => {
const hasRedBadge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
const hasGreenBadge = el.querySelector('[class*="bg-green"], [class*="text-green"]')
return !hasRedBadge && hasGreenBadge !== null
}) || allJobRows[0]
if (successfulJobRow) {
// Create cursor
const cursor = createFakeCursor()
// Click on successful job
await animateCursorToElementAndClick(cursor, successfulJobRow)
// Wait for navigation to job details page
await wait(DELAY_LONG)
// Navigate back to runs page using SvelteKit navigation
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
await wait(DELAY_LONG)
// Remove the cursor
cursor.remove()
step2Complete = true
}
},
popover: {
title:
'Exploring successful job runs <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #22c55e; display: inline-block; vertical-align: middle;"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
description:
"Let's click on a successful job to see how to inspect a completed execution.",
side: 'bottom',
onNextClick: async () => {
if (!step2Complete) {
sendUserToast(
'Please wait for the job click to complete...',
false,
[],
undefined,
3000
)
return
}
// Click on the successful job again (without showing cursor)
const successfulJobRow =
(Array.from(document.querySelectorAll('#runs-table-wrapper .cursor-pointer')).find(
(el) => {
const hasRedBadge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
const hasGreenBadge = el.querySelector(
'[class*="bg-green"], [class*="text-green"]'
)
return !hasRedBadge && hasGreenBadge !== null
}
) as HTMLElement) ||
(Array.from(
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
)[0] as HTMLElement)
if (successfulJobRow) {
successfulJobRow.click()
await wait(DELAY_SHORT)
// Wait for navigation to job details page
await wait(DELAY_LONG)
// Navigate back to runs page using SvelteKit navigation
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
await wait(DELAY_LONG)
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#runs-table-wrapper',
onHighlighted: async () => {
step3Complete = false
await wait(DELAY_MEDIUM)
// Find all jobs
const allJobRows = Array.from(
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
) as HTMLElement[]
// Find failed job (red badge/X icon)
const failedJobRow = allJobRows.find((el) => {
const badge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
return badge !== null
}) as HTMLElement
if (failedJobRow) {
// Create cursor
const cursor = createFakeCursor()
// Click on failed job
await animateCursorToElementAndClick(cursor, failedJobRow)
// Wait for navigation to job details page
await wait(DELAY_LONG)
// Navigate back to runs page using SvelteKit navigation
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
await wait(DELAY_LONG)
// Remove the cursor
cursor.remove()
step3Complete = true
}
},
popover: {
title:
'Exploring failed job runs <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #ef4444; display: inline-block; vertical-align: middle;"><circle cx="12" cy="12" r="10"/><path d="m12 8v4"/><path d="m12 16h.01"/></svg>',
description: "Now let's click on a failed job to see how to inspect a failed execution.",
side: 'bottom',
onNextClick: async () => {
if (!step3Complete) {
sendUserToast(
'Please wait for the job click to complete...',
false,
[],
undefined,
3000
)
return
}
// Click on the failed job again (without showing cursor)
const failedJobRow = Array.from(
document.querySelectorAll('#runs-table-wrapper .cursor-pointer')
).find((el) => {
const badge = el.querySelector('[class*="bg-red"], [class*="text-red"]')
return badge !== null
}) as HTMLElement
if (failedJobRow) {
failedJobRow.click()
await wait(DELAY_SHORT)
// Wait for navigation to job details page
await wait(DELAY_LONG)
// Navigate back to runs page using SvelteKit navigation
await goto(`${base}/runs?tutorial=runs-tutorial`, { replaceState: true })
await wait(DELAY_LONG)
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#runs-chart',
popover: {
title: 'Visual run history',
description:
'This chart gives you a visual overview of your run history at a glance. The duration chart shows how long each job takes to complete over time.',
side: 'bottom',
onNextClick: () => {
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#runs-chart',
onHighlighted: async () => {
step5Complete = false
await wait(DELAY_MEDIUM)
// Switch to the Concurrency chart tab
const concurrencyButton = document.querySelector(
'#runs-chart-concurrency-tab'
) as HTMLElement
if (concurrencyButton) {
await animateFakeCursorClick(concurrencyButton)
await wait(DELAY_MEDIUM)
step5Complete = true
}
},
popover: {
title: 'Switching chart views',
description:
'You can switch between different chart views to analyze your runs. The concurrency chart allows you to see how many jobs are running concurrently over time.',
side: 'bottom',
onNextClick: () => {
if (!step5Complete) {
sendUserToast(
'Please wait for the chart switch to complete...',
false,
[],
undefined,
3000
)
return
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#status',
onHighlighted: async () => {
step6Complete = false
await wait(DELAY_MEDIUM)
// Find the success and failure filter buttons
const successButton = document.querySelector(
'button[data-value="success"]'
) as HTMLElement
const failureButton = document.querySelector(
'button[data-value="failure"]'
) as HTMLElement
if (successButton && failureButton) {
// Create cursor once for both clicks
const cursor = createFakeCursor()
// Click on failure button first
await animateCursorToElementAndClick(cursor, failureButton)
await wait(DELAY_MEDIUM)
// Click on success button
await animateCursorToElementAndClick(cursor, successButton)
// Remove the cursor
cursor.remove()
await wait(DELAY_MEDIUM)
step6Complete = true
}
},
popover: {
title: 'Filtering jobs date, kind, status',
description:
'You can filter jobs, for example by status (failed, running, success). This helps you focus on specific types of executions.',
side: 'bottom',
onNextClick: () => {
if (!step6Complete) {
sendUserToast(
'Please wait for the filter clicks to complete...',
false,
[],
undefined,
3000
)
return
}
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
element: '#runs-filters-bar',
popover: {
title: 'More filtering options',
description:
"Even more filters are available to help you find exactly what you're looking for. Explore the additional filtering options to refine your search.",
side: 'bottom',
onNextClick: () => {
driver.moveNext()
},
onPrevClick: () => {
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
{
popover: {
title: 'Tutorial complete! 🎉',
description:
'You now know how to use the Runs page to monitor your executions, view successful results, and debug failed jobs.<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.</p>',
onNextClick: async () => {
updateProgress(index)
driver.destroy()
// Cleanup tutorial flows silently
await cleanupTutorialFlows()
}
}
}
]
}
// Cleanup function to delete tutorial flows
async function cleanupTutorialFlows() {
// Don't delete flows if user is an operator (they don't have permission)
if ($userStore?.operator) {
return
}
for (const flowPath of tutorialFlowPaths) {
try {
await FlowService.deleteFlowByPath({
workspace: $workspaceStore!,
path: flowPath
})
} catch (error) {
console.error(`Error deleting tutorial flow ${flowPath}:`, error)
}
}
}
// Start tutorial - create and run both jobs first
export async function runTutorial() {
// Create and run both flows at the beginning
try {
const successfulFlowPath = await createTutorialFlow()
const brokenFlowPath = await createBrokenFlow()
// Store flow paths for cleanup
tutorialFlowPaths = [successfulFlowPath, brokenFlowPath]
// Run both flows in parallel
await Promise.all([runFlowAndWait(successfulFlowPath), runFlowAndWait(brokenFlowPath)])
// Wait a bit for jobs to appear
await wait(DELAY_LONG)
} catch (error) {
console.error('Error creating/running tutorial flows:', error)
}
tutorial?.runTutorial()
}
</script>
<Tutorial
bind:this={tutorial}
{index}
name="runs-tutorial"
tainted={false}
on:error
on:skipAll
getSteps={(driver) => {
return getTutorialSteps(driver)
}}
/>
@@ -1,32 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import Button from '../common/button/Button.svelte'
import { CheckCircle } from 'lucide-svelte'
const dispatch = createEventDispatcher()
</script>
<div class="flex flex-row gap-2 justify-end w-full pt-6 pb-2">
<Button
size="xs"
startIcon={{ icon: CheckCircle }}
variant="default"
btnClasses="font-normal"
on:click={() => {
dispatch('skipThis')
}}
>
Mark this tutorial as completed
</Button>
<Button
size="xs"
startIcon={{ icon: CheckCircle }}
btnClasses="font-normal"
variant="default"
on:click={() => {
dispatch('skipAll')
}}
>
Mark all tutorials as completed
</Button>
</div>
@@ -1,441 +0,0 @@
<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'
import { DELAY_SHORT, DELAY_MEDIUM, DELAY_LONG, createFakeCursor } from './utils'
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 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 with start position
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 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 createFakeCursorWithStart(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
}}
/>
@@ -1,155 +1,101 @@
<script lang="ts">
import { driver, type Driver, type DriveStep } from 'driver.js'
import { createEventDispatcher, mount } from 'svelte'
import { updateProgress } from '$lib/tutorialUtils'
import { ignoredTutorials } from './ignoredTutorials'
import SkipTutorials from './SkipTutorials.svelte'
import { mount, onDestroy } from 'svelte'
import TutorialControls from './TutorialControls.svelte'
import TutorialInner from './TutorialInner.svelte'
import { isCurrentlyInTutorial } from '$lib/stores'
type Options = {
indexToInsertAt?: number
skipStepsCount?: number
}
interface Props {
index?: number;
name?: string;
tainted?: boolean;
onDestroyed?: (() => void) | undefined;
getSteps?: (driver: Driver, options?: Options | undefined) => DriveStep[];
/** Called once the tour ends, however it ended: last step, close button, or Escape. */
onDestroyed?: () => void
getSteps: (driver: Driver) => DriveStep[]
}
let {
index = 0,
name = 'action',
tainted = false,
onDestroyed = undefined,
getSteps = () => []
}: Props = $props();
let { onDestroyed = undefined, getSteps }: Props = $props()
let totalSteps = 0
let tutorial: Driver | undefined = $state(undefined)
const dispatch = createEventDispatcher()
// Render controls needs to be exposed so steps that have a custom render can call it
export function renderControls({ config, state }) {
// driver.js renders its popover as plain DOM, so the controls are mounted into it rather
// than declared in markup — which is also why they are re-mounted on every step.
function renderControls(activeIndex: number) {
const popoverContent = document.querySelector('#driver-popover-content')
popoverContent?.addEventListener('pointerdown', (event) => {
event.stopPropagation()
})
const popoverDescription = document.querySelector('#driver-popover-description')
if (!tutorial) {
if (!tutorial || !popoverDescription) {
return
}
if (state.activeIndex == 0) {
const div = document.createElement('div')
mount(SkipTutorials, {
target: div,
events: {
skipAll: () => {
dispatch('skipAll')
tutorial?.destroy()
},
skipThis: () => {
updateProgress(index)
tutorial?.destroy()
}
}
})
if (popoverDescription) {
popoverDescription.appendChild(div)
}
}
const controls = document.createElement('div')
mount(TutorialControls, {
target: controls,
props: {
activeIndex: state.activeIndex,
totalSteps
},
events: {
next: () => {
activeIndex,
totalSteps,
// A step that defines `onNextClick` owns its own advance — that is how a step
// that has to open something first waits for it before moving on.
onNext: () => {
const step = tutorial?.getActiveStep()
if (step) {
if (tutorial?.getActiveStep()?.popover?.onNextClick) {
const activeElement = tutorial?.getActiveElement()
tutorial?.getActiveStep()?.popover?.onNextClick?.(activeElement, step, {
config,
state,
driver: tutorial
})
} else {
tutorial?.moveNext()
}
if (!step) return
const onNextClick = step.popover?.onNextClick
if (onNextClick) {
onNextClick(tutorial?.getActiveElement(), step, {
config: tutorial!.getConfig(),
state: tutorial!.getState(),
driver: tutorial!,
index: activeIndex
})
} else {
tutorial?.moveNext()
}
},
previous: () => {
onPrevious: () => {
const step = tutorial?.getActiveStep()
if (step) {
if (tutorial?.getActiveStep()?.popover?.onPrevClick) {
const activeElement = tutorial?.getActiveElement()
tutorial?.getActiveStep()?.popover?.onPrevClick?.(activeElement, step, {
config,
state,
driver: tutorial
})
} else {
tutorial?.movePrevious()
}
if (!step) return
const onPrevClick = step.popover?.onPrevClick
if (onPrevClick) {
onPrevClick(tutorial?.getActiveElement(), step, {
config: tutorial!.getConfig(),
state: tutorial!.getState(),
driver: tutorial!,
index: activeIndex
})
} else {
tutorial?.movePrevious()
}
}
}
})
if (popoverDescription) {
popoverDescription.appendChild(controls)
}
popoverDescription.appendChild(controls)
}
export const runTutorial = (options?: Options | undefined) => {
if (tainted) {
dispatch('error', { detail: name })
return
}
isCurrentlyInTutorial.val = true
export function runTutorial() {
tutorial = driver({
allowClose: true,
disableActiveInteraction: true,
showButtons: ['close'],
showProgress: false,
overlayColor: 'rgba(0, 0, 0, 0.8)',
onPopoverRender: (popover, { config, state }) => {
renderControls({ config, state })
onPopoverRender: (_popover, { state }) => {
renderControls(state.activeIndex ?? 0)
},
onDestroyed: () => {
onDestroyed?.()
if (!tutorial?.hasNextStep()) {
$ignoredTutorials = Array.from(new Set([...$ignoredTutorials, index]))
}
isCurrentlyInTutorial.val = false
}
})
const steps = getSteps(tutorial, options)
const steps = getSteps(tutorial)
totalSteps = steps.length
tutorial.setSteps(steps)
tutorial.drive()
}
// driver.js appends its overlay to the body, so leaving the page mid-tour would strand it
// over whatever renders next. Destroying also runs `onDestroyed`, which is where the tour
// is recorded as seen — so navigating away counts as having been shown it.
onDestroy(() => tutorial?.destroy())
</script>
{#if tutorial}
@@ -1,51 +1,39 @@
<script lang="ts">
import { ArrowLeft, ArrowRight } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { createEventDispatcher } from 'svelte'
import Alert from '../common/alert/Alert.svelte'
interface Props {
activeIndex?: number | undefined;
totalSteps?: number | undefined;
activeIndex: number
totalSteps: number
onPrevious: () => void
onNext: () => void
}
let { activeIndex = undefined, totalSteps = undefined }: Props = $props();
const dispatch = createEventDispatcher()
let { activeIndex, totalSteps, onPrevious, onNext }: Props = $props()
</script>
<div class="flex flex-col gap-4 w-full pt-4">
{#if activeIndex === 0}
<Alert size="xs" title="Help">
<li> UI is not interactive during tutorial, press next at every step </li>
<li> You can use the arrow keys to navigate </li>
<li>UI is not interactive during the tour, press next at every step</li>
<li>You can use the arrow keys to navigate</li>
</Alert>
{/if}
<div class="flex flex-row gap-2 justify-between w-full items-center">
{#if activeIndex !== undefined && totalSteps !== undefined}
<div class="text-xs">
Step {activeIndex + 1} of {totalSteps}
</div>
{/if}
<div class="text-xs">
Step {activeIndex + 1} of {totalSteps}
</div>
<div class="flex flex-row gap-2">
<Button
size="xs2"
color="light"
unifiedSize="xs"
variant="default"
startIcon={{ icon: ArrowLeft }}
on:click={() => {
dispatch('previous')
}}
onclick={onPrevious}
>
Previous
</Button>
<Button
size="xs2"
variant="accent"
endIcon={{ icon: ArrowRight }}
on:click={() => {
dispatch('next')
}}
>
<Button unifiedSize="xs" variant="accent" endIcon={{ icon: ArrowRight }} onclick={onNext}>
Next
</Button>
</div>
@@ -1,29 +0,0 @@
<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>
@@ -1,64 +0,0 @@
<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}
@@ -1,36 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte'
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(untrack(() => 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,91 +0,0 @@
<script lang="ts">
import { updateProgress } from '$lib/tutorialUtils'
import { type DriveStep } from 'driver.js'
import Tutorial from '../Tutorial.svelte'
import { clickButtonBySelector } from '../utils'
interface Props {
name: string;
index: number;
}
let { name, index }: Props = $props();
let tutorial: Tutorial | undefined = $state(undefined)
export function runTutorial(skipStepsCount: number | undefined = undefined) {
tutorial?.runTutorial({ skipStepsCount })
}
</script>
<Tutorial
bind:this={tutorial}
{index}
{name}
on:error
on:skipAll
getSteps={(driver, options) => {
const steps: DriveStep[] = [
{
element: '#app-editor-runnable-panel',
popover: {
title: 'Runnable panel',
description:
'This is the runnable panel. Here you can add runnables to your app. Runnables are scripts that can be executed in the background. You can add as many runnables as you want.'
}
},
{
element: '#create-background-runnable',
popover: {
title: 'Create a runnable',
description:
'Click here to create a runnable. Runnables are scripts that can be executed in the background. You can add as many runnables as you want.',
onNextClick: () => {
clickButtonBySelector('#create-background-runnable')
setTimeout(() => driver.moveNext())
}
}
},
{
element: '#app-editor-empty-runnable',
popover: {
title: 'Empty runnable panel',
description:
'This is the empty runnable panel. Here you can add runnables to your app. Runnables are scripts that can be executed in the background. You can add as many runnables as you want. You can also select a script or a flow from your workspace or the Hub.'
}
},
{
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.',
onNextClick: () => {
setTimeout(() => {
driver.moveNext()
updateProgress(index)
})
}
}
}
]
// Remove steps if we want to skip them (excpet the first one)
if (options?.skipStepsCount) {
steps.splice(1, options.skipStepsCount)
}
return steps
}}
/>
@@ -1,128 +0,0 @@
<script lang="ts">
import { insertNewGridItem, appComponentFromType } from '$lib/components/apps/editor/appUtils'
import type { AppComponent } 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 } from '../utils'
import { updateProgress } from '$lib/tutorialUtils'
interface Props {
name: string;
index: number;
}
let { name, index }: Props = $props();
let tutorial: Tutorial | undefined = $state(undefined)
const { app, selectedComponent, focusedGrid } = getContext<AppViewerContext>('AppViewerContext')
const { history } = getContext<AppEditorContext>('AppEditorContext')
export function runTutorial() {
tutorial?.runTutorial()
}
function addComponent(): void {
push(history, $app)
const id = insertNewGridItem(
$app,
appComponentFromType('textcomponent') as (id: string) => AppComponent,
$focusedGrid
)
$selectedComponent = [id]
$app = $app
}
</script>
<Tutorial
bind:this={tutorial}
{index}
{name}
on:error
on:skipAll
getSteps={(driver) => [
{
popover: {
title: 'Connection tutorial',
description: 'We will connect the input of a text component to an output.',
onNextClick: () => {
addComponent()
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: `#component-input`,
popover: {
title: 'Data source',
description:
'Here we can set the data source of the text component: it can be static, the result of an evaluation or the result of script or flow. We are going to connect the data source to an output.',
onNextClick: () => {
clickButtonBySelector('#component-input')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '[data-connection-button] button[title="Connect"]',
popover: {
title: 'Connect the text component',
description: 'Click on the plug icon to connect the text component',
onNextClick: () => {
clickButtonBySelector('[data-connection-button] button[title="Connect"]')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '#output-ctx',
popover: {
title: 'Select the output',
description:
"You can now select the output in the output menu. Let's select your email in the app context",
onNextClick: () => {
clickButtonBySelector('#output-ctx')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
element: '.val',
popover: {
title: 'Click on the output',
description: 'Simply click on the output to connect it',
onNextClick: () => {
clickButtonBySelector('.val')
setTimeout(() => {
driver.moveNext()
})
}
}
},
{
popover: {
title: 'Connection done',
description: 'You can now see the email output connected to the text component input',
onNextClick: () => {
updateProgress(index)
setTimeout(() => {
driver.moveNext()
})
}
}
}
]}
/>
@@ -1,33 +0,0 @@
<script lang="ts">
import Tutorial from '../Tutorial.svelte'
interface Props {
name: string;
index: number;
}
let { name, index }: Props = $props();
let tutorial: Tutorial | undefined = $state(undefined)
export function runTutorial() {
tutorial?.runTutorial()
}
</script>
<Tutorial
bind:this={tutorial}
{index}
{name}
on:error
on:skipAll
getSteps={(driver) => [
{
popover: {
title: 'Expression evaluation tutorial',
description:
'Learn how to build our first branch to be executed on a condition. You can use arrow keys to navigate'
}
}
]}
/>
@@ -1,3 +0,0 @@
import { writable } from 'svelte/store'
export const ignoredTutorials = writable<number[]>([])
@@ -0,0 +1,47 @@
import { UserService } from '$lib/gen'
/**
* The tour's slot in the `tutorial_progress` bitmask. Slot 6 is reserved for it across
* versions: an operator who has already been through the tour must not meet it again, and
* a slot that another tutorial writes would read as finished on day one.
*/
const OPERATOR_TOUR_BIT = 6
/** URL parameter the sidebar entry uses to ask the home page for a run. */
export const TOUR_PARAM = 'tour'
export const TOUR_PARAM_VALUE = 'operator'
/** Long enough for the home page's tabs to exist before the first step points at one. */
export const TOUR_START_DELAY_MS = 500
/** Time for the sidebar to open before the last step points into it. */
export const MENU_OPEN_DELAY_MS = 300
export async function hasSeenOperatorTour(): Promise<boolean> {
// A failure answers "seen": the tour interrupts the page, and interrupting someone who
// has already been through it is worse than never offering it, which the sidebar entry
// covers anyway.
try {
const progress = (await UserService.getTutorialProgress()).progress ?? 0
return (progress & (1 << OPERATOR_TOUR_BIT)) !== 0
} catch (error) {
console.error('Could not read tutorial progress:', error)
return true
}
}
export async function markOperatorTourSeen(): Promise<void> {
try {
// Read-modify-write, because the row is shared: it carries every slot's state, and a
// write of this bit alone would clear the rest. `skipped_all` rides along for the same
// reason — and the handler rejects a body without it, whatever the generated type says.
const current = await UserService.getTutorialProgress()
await UserService.updateTutorialProgress({
requestBody: {
progress: (current.progress ?? 0) | (1 << OPERATOR_TOUR_BIT),
skipped_all: current.skipped_all ?? false
}
})
} catch (error) {
console.error('Could not record tutorial progress:', error)
}
}
@@ -1,328 +0,0 @@
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)
}
@@ -1,141 +0,0 @@
<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/state'
import { wait } from '$lib/utils'
import { DELAY_MEDIUM } from '../utils'
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-operator`)
} else {
tutorial?.runTutorial()
}
}
</script>
<Tutorial
bind:this={tutorial}
{index}
name="workspace-onboarding-operator"
tainted={false}
on:skipAll
getSteps={(driver) => {
const steps: DriveStep[] = [
{
popover: {
title: 'Welcome to Windmill! 🎉',
description:
"Let's take a quick tour! We'll show you the three main tools you can use: Scripts, Flows, and Apps.",
onNextClick: () => {
// Wait a bit to ensure the page is fully rendered before moving to next step
setTimeout(() => {
// Try to find the script tab button
const scriptsButton = document.querySelector('[data-value="script"]') as HTMLElement | null
if (scriptsButton) {
driver.moveNext()
} else {
// If we can't find the button, just move to next step anyway
driver.moveNext()
}
}, 100)
}
}
},
{
popover: {
title: 'Scripts - Run automated tasks',
description:
'<img src="/script-tutorial-operator.png" alt="Script Example" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Scripts</strong> are ready-to-use tasks that do things automatically for you.</p><p style="margin-top: 8px;">You can <strong>run scripts</strong> whenever you need them - like generating a report, sending notifications, or processing data.</p>',
onNextClick: async () => {
// Move to the next step (Flows)
setTimeout(() => {
const flowsButton = document.querySelector('[data-value="flow"]') as HTMLElement | null
if (flowsButton) {
driver.moveNext()
} else {
driver.moveNext()
}
}, 100)
}
},
element: '[data-value="script"]'
},
{
popover: {
title: 'Flows - Run step-by-step processes',
description:
'<img src="/flow.png" alt="Flow" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Flows</strong> are processes that run multiple tasks in order, one after another.</p><p style="margin-top: 8px;">You can <strong>start a flow</strong> and watch it complete each step automatically - perfect for tasks that have multiple stages.</p>',
onNextClick: async () => {
// Move to the next step (Apps)
setTimeout(() => {
const appsButton = document.querySelector('[data-value="app"]') as HTMLElement | null
if (appsButton) {
driver.moveNext()
} else {
driver.moveNext()
}
}, 100)
}
},
element: '[data-value="flow"]'
},
{
popover: {
title: 'Apps - Use custom tools',
description:
'<img src="/app.png" alt="App" style="width: 100%; max-width: 400px; margin-bottom: 12px; border-radius: 8px; display: block; margin-left: auto; margin-right: auto;" /><p><strong>Apps</strong> are easy-to-use tools with buttons, forms, and displays built just for your team.</p><p style="margin-top: 8px;">You can <strong>open an app</strong> to work with your data, fill out forms, or trigger tasks - no technical knowledge needed!</p>',
onNextClick: async () => {
// Move to the next step (cursor animation)
driver.moveNext()
}
},
element: '[data-value="app"]'
},
{
popover: {
title: 'Finally, the Menu section',
description: 'Explore available tabs where you can access your history of runs, your scheduled scripts, your tutorials progress etc.<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.</p>',
onNextClick: async () => {
// Find the target button and click it
const targetButton = document.querySelector('[role="menuitem"]') as HTMLElement | null
if (targetButton) {
targetButton.click()
}
// Wait for menu to open
await wait(DELAY_MEDIUM)
// 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: '[role="menuitem"]'
}
]
return steps
}}
/>
@@ -1,95 +0,0 @@
<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/state'
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}
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: async () => {
// The New menu button mounts once an async permission check resolves, so
// wait for it before highlighting it in the next step.
for (let i = 0; i < 20 && !document.querySelector('#create-new-button'); i++) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
driver.moveNext()
}
}
},
{
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>Open the <strong>New</strong> menu to create a script. Scripts turn code into tools. Write in Python, TypeScript, Go, Bash, SQL and more. Run them manually, on schedule, or via webhooks.</p>',
onNextClick: () => {
driver.moveNext()
}
},
element: '#create-new-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>The same <strong>New</strong> menu lets you create a flow. Flows orchestrate multiple scripts. Chain them together with branching, loops, and error handling to build complex workflows.</p>',
onNextClick: () => {
driver.moveNext()
}
},
element: '#create-new-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>And from the <strong>New</strong> menu you can also create an app. Apps are custom UIs built with drag-and-drop. Combine tables, forms, charts, and buttons that trigger your scripts and flows. That\'s it for the tour!</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-new-button'
}
]
return steps
}}
/>
@@ -0,0 +1,237 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import CreateWorkspaceInner from './CreateWorkspaceInner.svelte'
import { UserService, WorkspaceService } from '$lib/gen'
import { usersWorkspaceStore } from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
import { sendUserToast } from '$lib/toast'
import {
toWorkspaceId,
validateWorkspaceId,
WORKSPACE_NAME_MAX_LENGTH
} from '$lib/utils/workspaceId'
import {
defaultWorkspaceName,
loadUsernamePolicy,
WORKSPACE_HANDOVER_MS
} from '$lib/workspaceCreation'
/**
* Creates a workspace. It enforces no permission of its own — `create_workspace` is the
* gate, and it refuses when `CREATE_WORKSPACE_REQUIRE_SUPERADMIN` is on and the caller is
* not one — so a surface that offers this form to someone who may not create is offering
* an action that ends in a 401. A caller must establish that first. The workspace picker
* asks `canCreateWorkspace()`; the onboarding step does not, because it is reached only
* from the cloud sign-in path, where the setting is off by definition — a surface with
* any other way in owes the check.
*/
interface Props {
/** Where to go once the workspace exists. It is already the active one by then. */
onCreated: () => void
/**
* Rendered at the head of the action row — a host's own way back, next to Advanced
* settings rather than stranded under the button that finishes the form.
*/
leading?: Snippet
}
let { onCreated, leading }: Props = $props()
let name = $state('')
let creating = $state(false)
// The full form — id, colour, username, invites — for the person who wants it. Forced on
// when the instance does not derive usernames: one is required and a name field has
// nowhere to ask for it.
let advanced = $state(false)
let automateUsername = $state(true)
let suggestedUsername = $state<string | undefined>(undefined)
/**
* Whether the username policy is known, which is what this form may not submit without.
* `create_workspace` refuses a username on an instance that automates them and requires
* one on an instance that does not, so a client that has not read the setting cannot
* pick a request shape — there is no safe default to fall back to, only two shapes the
* server rejects. Unknown therefore blocks Create and says why, with a retry.
*/
let policyLoaded = $state(false)
let policyFailed = $state(false)
/** Someone typed while the prefill was in flight; their name wins over the suggestion. */
let nameEdited = false
async function load() {
// Settled apart: the policy decides whether this form may submit at all, the suggested
// name is cosmetic, and neither failure should decide the other.
policyFailed = false
const [me, policy] = await Promise.allSettled([
UserService.globalWhoami(),
loadUsernamePolicy()
])
if (!nameEdited) {
name =
me.status === 'fulfilled'
? defaultWorkspaceName(me.value.name, me.value.email)
: 'My workspace'
}
if (policy.status === 'rejected') {
console.error('Could not read the username policy:', policy.reason)
policyFailed = true
return
}
automateUsername = policy.value.automate
suggestedUsername = policy.value.suggested
if (!policy.value.automate && !policy.value.suggested) advanced = true
policyLoaded = true
}
void load()
const problem = $derived(
!name.trim()
? 'A name is required'
: name.trim().length > WORKSPACE_NAME_MAX_LENGTH
? `The name is too long (max ${WORKSPACE_NAME_MAX_LENGTH} characters).`
: undefined
)
/**
* The id the name implies. `Bob's workspace` is Bob's, so the id is `bob` — slugifying the
* whole name would make `bob-s-workspace`, which is what nobody would have typed. A name
* that is not possessive is slugified as it stands.
*/
function idSeed(workspaceName: string): string {
const owner = workspaceName.replace(/[']s\s+workspace$/i, '').trim()
return toWorkspaceId(owner || workspaceName) || 'workspace'
}
/**
* The id nearest that seed which is both valid and free: `-2`, `-3`, … so two people named
* Bob both get something readable. `validateWorkspaceId` answers with the *reason* an id is
* unusable, so a falsy answer is the valid one — and an invalid candidate is skipped rather
* than returned: `global` is reserved while `global-2` is not. Undefined when no candidate
* works, which is the caller's cue to ask for one rather than post a name the server
* refuses.
*/
async function freeWorkspaceId(seed: string): Promise<string | undefined> {
for (let n = 1; n <= 20; n++) {
const next = n === 1 ? seed : `${seed}-${n}`
if (validateWorkspaceId(next)) continue
if (!(await WorkspaceService.existsWorkspace({ requestBody: { id: next } }))) return next
}
return undefined
}
async function create() {
if (problem || creating || !policyLoaded) return
creating = true
const workspaceName = name.trim()
const started = Date.now()
let id: string | undefined
try {
id = await freeWorkspaceId(idSeed(workspaceName))
if (!id) {
sendUserToast(
'No workspace ID could be derived from that name. Pick one in advanced settings.',
true
)
advanced = true
creating = false
return
}
await WorkspaceService.createWorkspace({
requestBody: {
id,
name: workspaceName,
username: automateUsername ? undefined : suggestedUsername
}
})
} catch (error) {
console.error('Could not create the workspace:', error)
sendUserToast('Could not create the workspace: ' + (error?.body || error?.message), true)
creating = false
return
}
// The workspace exists from here on, so nothing below may report failure or hand the
// form back: a retry would pick the next free id and create a second one. A refresh
// that fails is worth a log and nothing more — the list reloads on the next page load,
// and the workspace this hands over to is real either way.
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (error) {
console.error('Created the workspace but could not refresh the list:', error)
}
switchWorkspace(id)
const left = WORKSPACE_HANDOVER_MS - (Date.now() - started)
if (left > 0) await new Promise((resolve) => setTimeout(resolve, left))
// Left up rather than cleared: the navigation it hands over to loads the workspace
// layout for the first time, and dropping back to the form under it would show the
// button again for as long as that takes.
onCreated()
}
</script>
{#if creating}
<div class="flex flex-col items-center gap-3 py-12 text-sm text-secondary">
<Loader2 size={20} class="animate-spin" />
Creating {name.trim()}
</div>
{:else if advanced}
<CreateWorkspaceInner inModal onFinish={onCreated} />
<!-- The full form has no way back to this one, so the host's way out of the step stays
reachable here too — below it, since that form ends on its own action row. -->
{#if leading}
<div class="mt-6 flex items-center">{@render leading()}</div>
{/if}
{:else}
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
<TextInput
bind:value={name}
inputProps={{
autofocus: true,
maxlength: WORKSPACE_NAME_MAX_LENGTH,
oninput: () => (nameEdited = true),
onkeydown: (e) => e.key === 'Enter' && create()
}}
/>
{#if problem && name.trim()}
<span class="text-2xs font-normal text-red-500">{problem}</span>
{/if}
{#if policyFailed}
<span class="mt-1 text-2xs font-normal text-red-500">
This instance's settings could not be read, so a workspace cannot be created yet.
<button class="text-accent hover:underline" onclick={() => void load()}>Try again</button>
</span>
{/if}
<div class="mt-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
{@render leading?.()}
<!-- A bare <button> as a quiet text link, signed off by design: a second <Button> here
would compete with Create workspace for the eye. -->
<!-- Closed while the policy is unknown, or it would be a way around the gate beside
it: the full form asks the same question of the same setting and submits on
its own optimistic default, so with no policy neither route creates. -->
<button
class="text-xs text-secondary hover:text-emphasis disabled:opacity-50 disabled:hover:text-secondary"
disabled={!policyLoaded}
title={policyFailed ? "This instance's settings could not be read" : undefined}
onclick={() => (advanced = true)}
>
Advanced settings
</button>
</div>
<Button
variant="accent"
unifiedSize="md"
disabled={!!problem || !policyLoaded}
onClick={create}
>
Create workspace
</Button>
</div>
</div>
{/if}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./gen', () => ({ HubPublishService: {}, SettingService: {} }))
vi.mock('./components/icons', () => ({ appIconComponent: () => undefined }))
import { hubProjectDescription } from './hubProject'
describe('hubProjectDescription', () => {
it('prefers the description field when the hub has one', () => {
expect(hubProjectDescription({ description: ' Runs payroll. ', readme: '# Other' })).toBe(
'Runs payroll.'
)
})
it('reads the readme intro when it does not, which is every published project', () => {
expect(
hubProjectDescription({
description: '',
readme: 'Audiences and campaigns,\nwith a sending engine.\n\n## Concepts\n\n- A flow'
})
).toBe('Audiences and campaigns, with a sending engine.')
})
it('skips a leading heading rather than stopping at it', () => {
expect(
hubProjectDescription({
readme: '## Description\n\nManages Odoo records.\n\n## Usage\n\n1. Generate a key'
})
).toBe('Manages Odoo records.')
})
it('strips inline markdown', () => {
expect(
hubProjectDescription({ readme: 'A **bold** clone of [Bitly](https://bitly.com) with `js`.' })
).toBe('A bold clone of Bitly with js.')
})
it('cuts on a word boundary, so a long one reads as shortened not corrupted', () => {
const long = hubProjectDescription({ readme: 'lorem ipsum '.repeat(40) })
expect(long.length).toBeLessThanOrEqual(321)
expect(long.endsWith('…')).toBe(true)
expect(long).not.toMatch(/lore…$/)
})
it('falls back to the summary when there is no prose at all', () => {
expect(hubProjectDescription({ readme: '## Usage\n', summary: 'Short links' })).toBe(
'Short links'
)
expect(hubProjectDescription({})).toBe('')
})
})
+121 -1
View File
@@ -1,6 +1,6 @@
import type { Component } from 'svelte'
import { appIconComponent } from '$lib/components/icons'
import { SettingService } from '$lib/gen'
import { HubPublishService, SettingService } from '$lib/gen'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte'
@@ -85,3 +85,123 @@ const HUB_APP_ICON_ALIAS: Record<string, string> = { postgres: 'postgresql' }
export function hubAppIcon(app: string): Component | undefined {
return appIconComponent(HUB_APP_ICON_ALIAS[app] ?? app)
}
/** One row of the hub's catalogue (`GET <hub>/projects`), which carries no item counts. */
interface HubProjectListRow {
slug: string
name: string
summary: string
description: string
readme: string
author: string
apps: string[]
hasLogo: boolean
stars: number
}
const DESCRIPTION_MAX = 320
/**
* What a project says about itself, in prose.
*
* The hub's `description` field is empty on every published project the writing all
* goes in the readme so the readme's opening paragraphs stand in. Everything from the
* first heading onwards is dropped: that is the "Windmill concepts demonstrated" /
* "Usage" material, which is documentation rather than a description. A readme that
* *starts* with a heading (`## Description`) has it skipped rather than treated as the
* end of the intro.
*/
export function hubProjectDescription(row: {
description?: string
readme?: string
summary?: string
}): string {
if (row.description?.trim()) return row.description.trim()
const lines = (row.readme ?? '').split('\n')
let i = 0
while (i < lines.length && (lines[i].trim() === '' || lines[i].startsWith('#'))) i++
const intro: string[] = []
for (; i < lines.length; i++) {
if (lines[i].startsWith('#')) break
intro.push(lines[i])
}
const text = intro
.join(' ')
// Inline markdown only — the block syntax is already gone with the headings.
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`]/g, '')
.replace(/\s+/g, ' ')
.trim()
if (!text) return row.summary?.trim() ?? ''
if (text.length <= DESCRIPTION_MAX) return text
// Cut on a word boundary: a description sliced mid-word reads as corrupted rather
// than shortened.
const cut = text.slice(0, DESCRIPTION_MAX)
const lastSpace = cut.lastIndexOf(' ')
return `${(lastSpace > DESCRIPTION_MAX * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}`
}
/**
* A card in the template picker. Everything it shows comes from the catalogue listing,
* so a whole page of cards costs one request; the item counts, which only the import
* step needs, are fetched per project by `fetchHubProject` when one is picked.
* `id` is what `InfiniteList` dedupes rows by.
*/
export interface HubProjectPick {
id: string
slug: string
name: string
summary: string
description: string
author: string
apps: string[]
logoUrl?: string
iconApps: string[]
stars: number
}
let catalogue: { workspace: string; projects: Promise<HubProjectPick[]> } | undefined
/**
* Every published project, most-starred first, fetched once per workspace and held for
* the life of the page.
*
* Through the workspace-scoped proxy rather than straight at the hub the way
* `fetchHubProject` goes: the catalogue endpoint sends no `Access-Control-Allow-Origin`,
* so the browser cannot read it directly.
*/
export function hubProjectCatalogue(workspace: string): Promise<HubProjectPick[]> {
if (catalogue?.workspace !== workspace) {
const projects = loadCatalogue(workspace).catch((e) => {
// A cached rejection would make the failure permanent for the whole session;
// dropping it lets the next open try again.
if (catalogue?.projects === projects) catalogue = undefined
throw e
})
catalogue = { workspace, projects }
}
return catalogue.projects
}
async function loadCatalogue(workspace: string): Promise<HubProjectPick[]> {
const raw = await HubPublishService.listHubProjects({ workspace })
const rows = ((typeof raw === 'string' ? JSON.parse(raw) : raw)?.projects ??
[]) as HubProjectListRow[]
const hub = await hubBrowserUrl()
return rows
.map((row) => ({
id: row.slug,
slug: row.slug,
name: row.name,
summary: row.summary,
description: hubProjectDescription(row),
author: row.author,
apps: row.apps ?? [],
logoUrl: row.hasLogo ? `${hub}/projects/${encodeURIComponent(row.slug)}/logo` : undefined,
iconApps: row.apps ?? [],
stars: row.stars ?? 0
}))
.sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name))
}
@@ -150,6 +150,30 @@ describe('abandoning mid-import', () => {
expect(run.itemResults.length).toBe(3)
})
// What a caller acting on the run's leftovers depends on: `abandon()` only stops the next
// phase, so a reload issued when it is called reads the workspace while the request already
// sent is still landing. `whenIdle()` is the difference between reloading then and after.
it('whenIdle resolves only once the abandoned run has stopped writing', async () => {
const run = new ImportExecution(PLAN, deps)
let idleResolved = false
hooks.afterFirstItem = () => {
run.abandon()
void run.whenIdle().then(() => (idleResolved = true))
// Still inside the run: the promise must not have resolved yet.
expect(run.running).toBe(true)
expect(idleResolved).toBe(false)
}
await run.run()
await run.whenIdle()
expect(run.running).toBe(false)
expect(idleResolved).toBe(true)
})
it('whenIdle resolves immediately when no run is in flight', async () => {
const run = new ImportExecution(PLAN, deps)
await expect(run.whenIdle()).resolves.toBeUndefined()
})
it('stops the migrate row spinning when it is abandoned mid-migration', async () => {
const run = new ImportExecution(PLAN, depsWithMigration)
// After `onMigrationsStart`, which is where the row is actually set to running —
@@ -229,6 +229,29 @@ export class ImportExecution {
*/
async run(): Promise<void> {
if (this.running) return
const settled = this.#runInternal()
// Handled here so an abandoned or failed run does not surface as an unhandled
// rejection through `whenIdle()`, but still reported: `#runInternal` has no `catch` of
// its own, and a throw outside its inner ones leaves a stalled run with nothing on
// screen — the console is the only place that says why.
this.#idle = settled.catch((error) => console.error('import run failed:', error))
return settled
}
/**
* Resolves when the run in flight at the moment of the call is no longer writing
* immediately when there is none. Callers that act on what a run left behind need this
* rather than a poll on `running`: `abandon()` stops the run at the next phase boundary,
* so the request already sent lands after it, and reading the workspace before then reads
* it mid-write. A caller that holds the promise across the start of a *second* run is
* resolved by the first, so re-read it if the surface stays open.
*/
whenIdle(): Promise<void> {
return this.#idle
}
#idle: Promise<void> = Promise.resolve()
async #runInternal(): Promise<void> {
this.#abandoned = false
this.running = true
runState.active = true
@@ -0,0 +1,73 @@
import { WorkspaceService } from '$lib/gen'
import type { ImportExecution } from './execution.svelte'
/**
* Whether a finished import leaves a setup step behind it, and whether that is still
* being decided.
*
* Known only once the run has fetched the export and the destination's data tables can
* be compared against it, so it is false for the whole wizard until the import
* finishes which is exactly when it is first read. `undecided` matters as much as
* `needed`: without it the run reads as finished with no fourth step, and Finish leaves
* before the check comes back and discovers a data table that is missing.
*
* Shared by the wizard route and the in-workspace modal so the two cannot disagree
* about whether an import is over.
*/
export function useSetupStep(
getExecution: () => ImportExecution | undefined,
getWorkspace: () => string | undefined
) {
let needed = $state(false)
let undecided = $state(false)
$effect(() => {
const execution = getExecution()
const names = execution?.datatableNames ?? []
const workspace = getWorkspace()
if (!execution?.done || !workspace) {
needed = false
undecided = false
return
}
// `resourceCount` is the referenced subset — the resources something in the project
// points at — and each one arrives as an empty stub, so any project that has them has
// something to fill in. The step itself re-checks and shows only what is genuinely
// outstanding, which is what makes a re-import quiet.
if (execution.resourceCount > 0) {
needed = true
undecided = false
return
}
if (names.length === 0) {
needed = false
undecided = false
return
}
let cancelled = false
undecided = true
void WorkspaceService.listDataTables({ workspace })
.then((tables) => {
if (cancelled) return
const present = new Set(tables.map((t) => t.name))
needed = names.some((n) => !present.has(n))
})
.catch(() => {
// Can't tell — don't invent a step the user then cannot complete.
if (!cancelled) needed = false
})
.finally(() => {
if (!cancelled) undecided = false
})
return () => (cancelled = true)
})
return {
get needed() {
return needed
},
get undecided() {
return undecided
}
}
}
+25 -9
View File
@@ -1,23 +1,39 @@
import { get } from 'svelte/store'
import { CancelablePromise, UserService, type GlobalUserInfo } from '$lib/gen'
import { CancelablePromise, CancelError, UserService, type GlobalUserInfo } from '$lib/gen'
import { superadmin, devopsRole } from './stores.js'
let promise: CancelablePromise<GlobalUserInfo> | null = null
async function _refreshSuperadmin(): Promise<void> {
let shouldFetch = get(superadmin) == undefined || get(devopsRole) == undefined
/**
* `force` asks the server even when the stores already hold an answer. Worth it where a wrong
* answer changes what the page offers rather than how it looks: a logged-out load sets both
* stores to `false` the request 401s and without `force` nothing asks again for the rest
* of the session, so the user who signs in next reads as neither superadmin nor devops.
*/
async function _refreshSuperadmin(opts?: { force?: boolean }): Promise<void> {
let shouldFetch = opts?.force || get(superadmin) == undefined || get(devopsRole) == undefined
if (!shouldFetch) return undefined
promise?.cancel()
promise = UserService.globalWhoami()
// Held locally so the check at the end can tell this request from a later caller's, which
// by then owns `promise`.
const mine = UserService.globalWhoami()
promise = mine
try {
const me = await promise
const me = await mine
superadmin.set(me.super_admin ? me.email : false)
devopsRole.set(me.devops || me.super_admin ? me.email : false)
} catch (error) {
superadmin.set(false)
devopsRole.set(false)
console.error('error refreshing superadmin/devops role', error)
// A cancellation says nothing about this user, so it must not be written down as an
// answer: `clearStores` cancels on logout, and a second caller cancels the first — and
// `false` here is precisely the stale state `force` exists to get out of.
if (!(error instanceof CancelError)) {
superadmin.set(false)
devopsRole.set(false)
console.error('error refreshing superadmin/devops role', error)
}
}
promise = null
// Only if nobody has started another: clearing a live request's handle would put it beyond
// the reach of `cancel()`, and it would then land on a session that had been cleared.
if (promise === mine) promise = null
}
export const refreshSuperadmin = Object.assign(_refreshSuperadmin, {
+4 -4
View File
@@ -68,8 +68,6 @@ export function clearWorkspaceFromStorage() {
sessionStorage.removeItem('workspace')
}
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)
@@ -120,6 +118,10 @@ export const superadmin = writable<string | false | undefined>(undefined)
export const devopsRole = writable<string | false | undefined>(undefined)
export const lspTokenStore = writable<string | undefined>(undefined)
export const hubBaseUrlStore = writable<string>(DEFAULT_HUB_BASE_URL)
// Whether the store above is the instance's answer or still the default it was seeded with.
// It reads as the public hub either way, which is fine for a link and wrong for anything
// deciding what may be reported about a hub — those must treat unknown as private.
export const hubBaseUrlKnown = writable<boolean>(false)
export const wsBaseUrlStore = writable<string | undefined>(undefined)
export const disableHubStore = writable<boolean>(false)
// What a superadmin standing in a workspace they are not a member of needs to see it as a
@@ -333,8 +335,6 @@ export const workspaceColor: Readable<string | null | undefined> = derived(
}
)
export const isCurrentlyInTutorial: StateStore<boolean> = createState({ val: false })
export function getFlatTableNamesFromSchema(dbSchema: DBSchema | undefined): string[] {
const schema = dbSchema?.schema ?? {}
const tableNames: string[] = []
-224
View File
@@ -1,224 +0,0 @@
import { get } from 'svelte/store'
import { tutorialsToDo, skippedAll } from './stores'
import { UserService } from './gen'
import { TUTORIALS_CONFIG } from './tutorials/config'
/**
* LocalStorage key for tracking if the tutorial banner has been dismissed.
* Shared between tutorialUtils and TutorialBanner component.
*/
export const TUTORIAL_BANNER_DISMISSED_KEY = 'tutorial_banner_dismissed'
/**
* 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
if (!aft.includes(i)) {
bits = bits | mask
}
}
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: false } })
}
export async function skipAllTodos() {
let bits = 0
for (let i = 0; i <= MAX_TUTORIAL_ID; i++) {
let mask = 1 << i
bits = bits | mask
}
tutorialsToDo.set([])
skippedAll.set(true)
await UserService.updateTutorialProgress({ requestBody: { progress: bits, skipped_all: true } })
}
export async function resetAllTodos() {
let todos: number[] = []
for (let i = 0; i <= MAX_TUTORIAL_ID; i++) {
todos.push(i)
}
tutorialsToDo.set(todos)
skippedAll.set(false)
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 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
if ((bits & mask) == 0) {
todos.push(i)
}
}
tutorialsToDo.set(todos)
skippedAll.set(skipped)
}
export function tutorialInProgress() {
const svg = document.getElementsByClassName('driver-overlay driver-overlay-animated')
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
}
-159
View File
@@ -1,159 +0,0 @@
import type { ComponentType } from 'svelte'
import { Workflow, GraduationCap, Wrench, PlayCircle, Link2, History } 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', 'operator'],
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`
},
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`
},
index: 3,
active: true,
comingSoon: false,
roles: ['admin','developer'],
order: 3
},
{
id: 'runs-tutorial',
icon: History,
title: 'Discover your monitoring dashboard',
description: 'Learn how to monitor, filter, and manage your script and flow executions.',
onClick: () => {
window.location.href = `${base}/runs?tutorial=runs-tutorial`
},
index: 7,
active: true,
comingSoon: false,
roles: ['admin', 'developer','operator'],
order: 4
},
{
id: 'workspace-onboarding-operator',
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-operator`
},
index: 6,
active: true,
comingSoon: false,
roles: ['operator'],
order: 1
},
]
},
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`
},
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`
},
index: 5,
active: true,
comingSoon: false,
roles: ['developer', 'admin'],
order: 5
}
]
}
} as const
-68
View File
@@ -1,68 +0,0 @@
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.
* Uses exact role matching - only shows tutorials explicitly marked for the preview role.
*/
export function hasRoleAccessForPreview(
previewRole: Role,
roles?: Role[]
): boolean {
// No roles specified = available to everyone
if (!roles || roles.length === 0) return true
// Exact role match - tutorial must explicitly include the preview role
return roles.includes(previewRole)
}
+73 -2
View File
@@ -1,10 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('$lib/gen', () => ({ OpenAPI: { BASE: '/api' } }))
vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: () => () => {} } }))
// Stores `get()` can read, so a test can say which hub the instance points at and whether
// the instance has answered at all.
const hubBaseUrl = vi.hoisted(() => {
const readable = <T>(initial: T) => {
let value = initial
return {
set: (v: T) => (value = v),
store: {
subscribe: (run: (v: T) => void) => {
run(value)
return () => {}
}
}
}
}
return { url: readable('https://hub.windmill.dev'), known: readable(true) }
})
vi.mock('$lib/stores', () => ({
workspaceStore: { subscribe: () => () => {} },
hubBaseUrlStore: hubBaseUrl.url.store,
hubBaseUrlKnown: hubBaseUrl.known.store
}))
import {
createFeatureUsageBuffer,
hubProjectUsageKey,
hubScriptUsageKey,
type FeatureUsageEventPayload
} from './featureUsage'
@@ -107,3 +131,50 @@ describe('hubScriptUsageKey', () => {
).toBe('acme/list_a_user_s_items_sorted')
})
})
describe('hubProjectUsageKey', () => {
// The fixture is module-level and mutable, so each case states the world it needs rather
// than inheriting whatever the case above it left behind.
beforeEach(() => {
hubBaseUrl.url.set('https://hub.windmill.dev')
hubBaseUrl.known.set(true)
})
it('reports the slug for every spelling of the public hub', () => {
for (const hub of [
'https://hub.windmill.dev',
'http://hub.windmill.dev/',
'HTTPS://hub.windmill.dev',
'https://HUB.WINDMILL.DEV',
'https://hub.windmill.dev:443',
' https://hub.windmill.dev '
]) {
hubBaseUrl.url.set(hub)
expect(hubProjectUsageKey('stripe-invoices'), hub).toBe('stripe-invoices')
}
})
it('answers private until the instance has said which hub it points at', () => {
// The store is seeded with the public hub, so a settings read that failed must not
// read as permission to report the name.
hubBaseUrl.known.set(false)
expect(hubProjectUsageKey('acme-payroll')).toBe('private')
hubBaseUrl.known.set(true)
expect(hubProjectUsageKey('acme-payroll')).toBe('acme-payroll')
})
it("keeps a private hub's project names off the wire", () => {
// The slug is the customer's own content on an instance running its own hub, and the
// disclosure only claims public project names.
for (const hub of [
'https://hub.internal.example',
'https://hub.windmill.dev.evil.example',
'https://windmill.dev',
'hub.windmill.dev',
'not a url'
]) {
hubBaseUrl.url.set(hub)
expect(hubProjectUsageKey('acme-payroll'), hub).toBe('private')
}
})
})
+33 -2
View File
@@ -1,7 +1,7 @@
import { get } from 'svelte/store'
import { OpenAPI } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
import { hubBaseUrlKnown, hubBaseUrlStore, workspaceStore } from '$lib/stores'
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
// Anonymous product-usage counters (e.g. AI session activity), batched into the
// backend `feature_usage` accumulator. Only aggregated counts ever leave the
@@ -187,3 +187,34 @@ export function hubScriptUsageKey(script: {
if (!app) return PRIVATE_HUB_KEY
return (summary ? `${app}/${summary}` : app).slice(0, 100)
}
/**
* A hub project's slug is only reportable when it names something on the public hub. An
* instance pointed at its own hub imports its own projects, whose names are the customer's
* content the same reason `hubScriptUsageKey` collapses a private script to `private`,
* and what the disclosure means by "the name of any public hub project".
*
* Compared by host, so the port, scheme and trailing slash an operator may have typed do
* not decide it. Anything unparseable, and anything not yet read, answers private.
*/
export function hubProjectUsageKey(slug: string): string {
// `hubBaseUrlKnown` and not the URL alone: the store is seeded with the public hub, so an
// instance whose setting could not be read would otherwise report its own project names.
if (!get(hubBaseUrlKnown) || !isPublicHub(get(hubBaseUrlStore))) return PRIVATE_HUB_KEY
return slug.slice(0, 100)
}
function isPublicHub(hub: string): boolean {
const host = (url: string): string | undefined => {
try {
const parsed = new URL(url.trim())
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.hostname.replace(/\.$/, '').toLowerCase()
: undefined
} catch {
return undefined
}
}
const configured = host(hub)
return configured !== undefined && configured === host(DEFAULT_HUB_BASE_URL)
}
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest'
// The module reaches the API for the username policy and the workspace list; the name
// helper touches neither. `getGlobal` is a spy so the policy's failure path can be driven.
const getGlobal = vi.fn()
vi.mock('./gen', () => ({
SettingService: {
get getGlobal() {
return getGlobal
}
},
UserService: {},
WorkspaceService: {}
}))
vi.mock('./stores', () => ({ usersWorkspaceStore: { set: () => {} } }))
vi.mock('./storeUtils', () => ({ switchWorkspace: () => {} }))
vi.mock('./cloud', () => ({ isCloudHosted: () => false }))
import { defaultWorkspaceName, loadUsernamePolicy, usernameFromName } from './workspaceCreation'
describe('defaultWorkspaceName', () => {
it('names the workspace after the person, not the address', () => {
expect(defaultWorkspaceName(undefined, 'bob@example.com')).toBe("Bob's workspace")
expect(defaultWorkspaceName(undefined, 'ada.lovelace@example.com')).toBe(
"Ada Lovelace's workspace"
)
expect(defaultWorkspaceName(undefined, 'jean-luc_picard+wm@example.com')).toBe(
"Jean Luc Picard Wm's workspace"
)
})
it('prefers the name the login provider gave', () => {
expect(defaultWorkspaceName('Ruben', 'r.k@example.com')).toBe("Ruben's workspace")
// Blank is not a name: fall back rather than produce "'s workspace".
expect(defaultWorkspaceName(' ', 'bob@example.com')).toBe("Bob's workspace")
})
it('falls back rather than offering a name the backend refuses', () => {
// Over the 50-char cap the field would be prefilled with something rejected on submit.
expect(defaultWorkspaceName('Bartholomew Maximilian Featherstonehaugh III', undefined)).toBe(
'My workspace'
)
// Nothing to derive from at all.
expect(defaultWorkspaceName(undefined, undefined)).toBe('My workspace')
expect(defaultWorkspaceName(undefined, '@example.com')).toBe('My workspace')
})
})
describe('usernameFromName', () => {
// The `proper_username` constraint is `^[\w-]+$`, so a suggestion outside it is posted and
// then refused by the database, with the form showing nothing that explains why.
it('keeps only what the username constraint accepts', () => {
expect(usernameFromName("O'Connor")).toBe('oconnor')
expect(usernameFromName('alice+demo')).toBe('alicedemo')
expect(usernameFromName('Jean-Luc')).toBe('jean-luc')
expect(usernameFromName('ada.lovelace')).toBe('adalovelace')
})
it('answers undefined when nothing usable is left', () => {
// The caller opens the full form instead of prefilling something unusable.
expect(usernameFromName('++')).toBeUndefined()
expect(usernameFromName('')).toBeUndefined()
})
it('answers undefined rather than a value the column cannot hold', () => {
// `usr.username` is VARCHAR(50) while the name and email it is derived from run to 255,
// and `create_workspace` inserts it untruncated.
expect(usernameFromName('a'.repeat(50))).toBe('a'.repeat(50))
expect(usernameFromName('a'.repeat(51))).toBeUndefined()
})
})
describe('loadUsernamePolicy', () => {
// Neither default is safe — `create_workspace` refuses a username on an automating
// instance and requires one otherwise — so an unreadable setting has to reach the caller
// as a failure rather than as a guess it cannot tell apart from an answer.
it('rejects rather than guessing when the setting cannot be read', async () => {
getGlobal.mockRejectedValueOnce(new Error('502'))
await expect(loadUsernamePolicy()).rejects.toThrow('502')
})
it('automates when the setting says so, and when it is unset', async () => {
getGlobal.mockResolvedValueOnce(true)
expect(await loadUsernamePolicy()).toEqual({ automate: true })
getGlobal.mockResolvedValueOnce(null)
expect(await loadUsernamePolicy()).toEqual({ automate: true })
})
})
+50 -1
View File
@@ -14,6 +14,7 @@ import { usersWorkspaceStore } from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import { WORKSPACE_NAME_MAX_LENGTH } from '$lib/utils/workspaceId'
/**
* Whether this user may create a workspace at all. Self-hosted instances default
@@ -44,11 +45,34 @@ export interface UsernamePolicy {
suggested?: string
}
/** What `usr.username` holds, and neither the provider name nor the email is bounded by it. */
const USERNAME_MAX_LENGTH = 50
/**
* A username the whole `usr.username` contract accepts: the `proper_username` constraint
* (`^[\w-]+$`, so word characters and hyphens and nothing else) and the column's own 50
* characters. Anything outside the class is dropped rather than substituted `O'Connor` is
* `oconnor`, not `o-connor`.
*
* Undefined where nothing usable is left or where what is left is too long, which is the
* caller's cue to ask for one: `create_workspace` inserts this value with no truncation, so a
* name the column refuses would fail on insert with nothing on screen naming the field.
*/
export function usernameFromName(name: string): string | undefined {
const cleaned = name.toLowerCase().replace(/[^\w-]/g, '')
return cleaned === '' || cleaned.length > USERNAME_MAX_LENGTH ? undefined : cleaned
}
/**
* `createWorkspace` rejects a username when the instance automates them and
* requires one when it does not, so the field only exists in the second case.
*/
export async function loadUsernamePolicy(): Promise<UsernamePolicy> {
// Rejects rather than defaulting when the setting cannot be read, because neither
// default is safe: `create_workspace` refuses a username on an instance that automates
// them and requires one on an instance that does not (`workspaces.rs:5820`). A caller
// that cannot read this cannot pick a request shape, and must say so instead of posting
// one of the two the server rejects.
const automate =
((await SettingService.getGlobal({
key: 'automate_username_creation'
@@ -57,7 +81,7 @@ export async function loadUsernamePolicy(): Promise<UsernamePolicy> {
try {
const me = await UserService.globalWhoami()
const from = me.name ? me.name.split(' ')[0] : me.email.split('@')[0]
return { automate: false, suggested: from.replace(/\./g, '').toLowerCase() }
return { automate: false, suggested: usernameFromName(from) }
} catch {
return { automate: false }
}
@@ -78,3 +102,28 @@ export async function enterNewWorkspace(id: string): Promise<void> {
await refreshWorkspaceList()
switchWorkspace(id)
}
/**
* How long a screen that hands over to a workspace stays up, whatever the server does.
* Creating or naming one takes a few hundred milliseconds, and a button that swaps the page in
* that time reads as nothing having happened the floor is what makes it read as an action
* that ran, and it covers the workspace layout's first load on the other side.
*/
export const WORKSPACE_HANDOVER_MS = 900
/**
* What to call a workspace before its owner has said. The login provider's name when it gave
* one, else the email local part read as a name: `bob@…` is Bob, `ada.lovelace@…` is Ada
* Lovelace. Capped at what `create_workspace` accepts, since it is prefilled rather than
* typed and a name the server would reject must never appear in the field.
*/
export function defaultWorkspaceName(name: string | undefined, email: string | undefined): string {
const display = (name?.trim() || (email ?? '').split('@')[0])
.split(/[._\-+\s]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
.trim()
const proposed = display ? `${display}'s workspace` : 'My workspace'
return proposed.length > WORKSPACE_NAME_MAX_LENGTH ? 'My workspace' : proposed
}
@@ -32,6 +32,7 @@
type UserExt,
defaultScripts,
hubBaseUrlStore,
hubBaseUrlKnown,
wsBaseUrlStore,
disableHubStore,
usedTriggerKinds,
@@ -60,7 +61,6 @@
} from '$lib/components/sidebar/FavoriteMenu.svelte'
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { isCloudHosted } from '$lib/cloud'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { PanelLeftClose, PanelLeftOpen, Home, Play, Search, WandSparkles } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { confirmPendingLoginMethod } from '$lib/lastLoginMethod'
@@ -468,7 +468,6 @@
function onLoad() {
loadFavorites()
syncTutorialsTodos()
loadHubBaseUrl()
loadWsBaseUrl()
loadDisableHub()
@@ -476,10 +475,18 @@
}
async function loadHubBaseUrl() {
$hubBaseUrlStore =
((await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string) ||
((await SettingService.getGlobal({ key: 'hub_base_url' })) as string) ||
DEFAULT_HUB_BASE_URL
// A read that throws leaves the store on its seeded default, which names the public hub
// — so the flag, not the value, is what says the instance has answered. An instance that
// simply has no setting still answers: the chain falls through to the default.
try {
$hubBaseUrlStore =
((await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string) ||
((await SettingService.getGlobal({ key: 'hub_base_url' })) as string) ||
DEFAULT_HUB_BASE_URL
$hubBaseUrlKnown = true
} catch (error) {
console.error('Could not read the hub URL:', error)
}
}
async function loadWsBaseUrl() {
@@ -1107,7 +1114,7 @@
<div
id="sidebar"
class={classNames(
'flex flex-col fixed inset-y-0 z-40 ',
'wm-sidebar-in flex flex-col fixed inset-y-0 z-40 ',
sidebarTransitionClass,
devOnly ? '!hidden' : ''
)}
@@ -1441,3 +1448,31 @@
<CreateWorkspaceInner isFork inModal onFinish={() => (globalForkModal.val = undefined)} />
{/if}
</Modal2>
<style>
/* The rail sliding in from the edge it lives on. This layout mounts when the app is entered —
signup, the workspace picker and onboarding all sit outside it — so the animation plays on
arrival, and on a hard reload of any page under it, but never on a navigation within the
app. Paired with the home page's own fade, it reads as the workspace coming forward from
behind whatever was on top of it. */
@keyframes wm-sidebar-in {
from {
opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: none;
}
}
:global(#sidebar.wm-sidebar-in) {
animation: wm-sidebar-in 500ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
:global(#sidebar.wm-sidebar-in) {
animation: none;
}
}
</style>
@@ -23,16 +23,19 @@
import { goto, replaceState } from '$app/navigation'
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte'
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'
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
import { z } from 'zod'
import HomeAIChat from '$lib/components/home/HomeAIChat.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { onMount, untrack } from 'svelte'
import OperatorTour from '$lib/components/tutorials/OperatorTour.svelte'
import {
hasSeenOperatorTour,
TOUR_PARAM,
TOUR_PARAM_VALUE,
TOUR_START_DELAY_MS
} from '$lib/components/tutorials/operatorTour'
type Tab = 'hub' | 'workspace'
@@ -87,38 +90,41 @@
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
}
})
let showCreateButtons = $state(false)
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 (tutorialParam === 'workspace-onboarding-operator') {
// Small delay to ensure page is fully loaded
setTimeout(() => {
workspaceTutorials?.runTutorialById('workspace-onboarding-operator')
}, 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)
}
let operatorTour: OperatorTour | undefined = $state(undefined)
// Delayed so the tabs the first steps point at exist. `runTutorial` refuses while a tour is
// already running, which is the guard that matters — the tour ends by telling the operator
// to start it again from the menu, so a start has to be possible for the life of the page.
function startTour() {
setTimeout(() => operatorTour?.runTutorial(), TOUR_START_DELAY_MS)
}
// The sidebar entry asks by URL parameter so it works from any page an operator can be on.
// Read reactively rather than on mount: arriving from the menu while already on the home
// page is a parameter change, not a new page.
$effect(() => {
if (page.url.searchParams.get(TOUR_PARAM) !== TOUR_PARAM_VALUE) return
const user = $userStore
if (!user) return
untrack(() => {
const url = new URL(page.url)
url.searchParams.delete(TOUR_PARAM)
replaceState(url, page.state)
// Gated here too: the parameter is part of a URL anyone can type, and the tour
// describes a home page that only operators see.
if (user.operator) startTour()
})
})
onMount(async () => {
// Operators get the tour once, and only when they have not been through it: they cannot
// create anything, so the home page is the whole product to them and it is worth naming
// its three tabs. Anyone who can build gets nothing — they have the create button.
if (!$userStore?.operator || page.url.searchParams.has(TOUR_PARAM)) return
if (await hasSeenOperatorTour()) return
startTour()
})
</script>
@@ -257,14 +263,12 @@
</Drawer>
<div
class="flex flex-col w-full h-full overflow-y-auto items-center"
class="wm-page-in flex flex-col w-full h-full overflow-y-auto items-center"
style="scrollbar-gutter: stable both-edges;"
>
<ForkWorkspaceBanner />
<WorkspaceDraftsBanner />
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
<TutorialBanner />
<!-- HomeAIChat carries both the AI composer and the AI-independent CLI/MCP connect row,
so it shows whenever the sessions beta is on; the composer itself is gated on operator
status and on the workspace inside the component, which owns its own vertical spacing
@@ -363,4 +367,31 @@
{/if}
</div>
<WorkspaceTutorials bind:this={workspaceTutorials} />
{#if $userStore?.operator}
<OperatorTour bind:this={operatorTour} />
{/if}
<style>
/* The page's content arriving, rather than being there. The layout has already painted the
sidebar and the surface behind it, so only what is new to this route fades. It plays on
every arrival at Home, not just the one off a workspace hand-over — that is the arrival it
is for, and a soft one costs nothing on the others. */
@keyframes wm-page-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.wm-page-in {
animation: wm-page-in 500ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.wm-page-in {
animation: none;
}
}
</style>
@@ -15,7 +15,7 @@
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { emptyApp } from '$lib/components/apps/editor/appUtils'
import { importStore } from '$lib/components/apps/store'
import { onDestroy, tick, untrack } from 'svelte'
import { onDestroy, untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft } from '$lib/userDraft.svelte'
import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag'
@@ -23,7 +23,6 @@
import { runResetToDeployed } from '$lib/userDraftToast'
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
let appEditor: AppEditor | undefined = $state(undefined)
/** Seeded from a hub app this load; AppEditor relaxes a few authoring affordances. */
let fromHub = $state(false)
let savedApp:
@@ -193,20 +192,6 @@
path: pathParam ?? '',
policy: seedPolicy
}
// Tutorial links ("/apps/add?tutorial=...") land here via the
// redirect; fire once AppEditor has mounted and the runnable
// panel the tour points at exists.
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam) {
await tick()
let attempts = 0
while (attempts < 20 && !document.querySelector('#app-editor-runnable-panel')) {
await new Promise((resolve) => setTimeout(resolve, 100))
attempts++
}
if (tok !== loadAppToken) return
appEditor?.triggerTutorial()
}
return
}
// Falling through with `?new_draft=true` still set means the draft is
@@ -474,7 +459,6 @@
{#if app}
<div class="h-screen">
<AppEditor
bind:this={appEditor}
{fromHub}
onSavedNewAppPath={(url) => {
goto(`/apps/edit/${url}`)
@@ -310,20 +310,6 @@
loading = false
selectedId = page.url.searchParams.get('selected') ?? seedSelectedId ?? 'settings-metadata'
renderEditor = true
// Tutorial links ("/flows/add?tutorial=...") land here via the
// redirect; fire once the builder has mounted and the flow input
// anchor the tour points at exists.
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam) {
await tick()
let attempts = 0
while (attempts < 20 && !document.querySelector('#flow-editor-virtual-Input')) {
await new Promise((resolve) => setTimeout(resolve, 100))
attempts++
}
if (tok !== loadFlowToken) return
flowBuilder?.triggerTutorial()
}
return
}
// Falling through with `?new_draft=true` still set means the draft is
@@ -14,6 +14,7 @@
import ImportSetupStep from '$lib/components/ImportSetupStep.svelte'
import ImportWizardSteps from '$lib/components/ImportWizardSteps.svelte'
import type { ImportExecution } from '$lib/importWizard/execution.svelte'
import { useSetupStep } from '$lib/importWizard/setupStep.svelte'
import WorkspaceTreeView from '$lib/components/workspace/WorkspaceTreeView.svelte'
import { superadmin, usersWorkspaceStore } from '$lib/stores'
import { get } from 'svelte/store'
@@ -244,54 +245,13 @@
goto('/')
}
// Whether a fourth step exists. Known only once the run has fetched the export and
// the destination's data tables can be compared against it, so it is false for the
// whole wizard until the import finishes — which is exactly when it is first read.
let execution = $state<ImportExecution | undefined>(undefined)
let setupNeeded = $state(false)
// True while the answer is still being fetched. Without it the run reads as finished
// with no fourth step, and Finish leaves for the workspace before the check comes back
// and discovers a data table that is missing.
let setupUndecided = $state(false)
$effect(() => {
const names = execution?.datatableNames ?? []
const workspace = planWorkspaceId(plan)
if (!execution?.done || !workspace) {
setupNeeded = false
setupUndecided = false
return
}
// `resourceCount` is the referenced subset — the resources something in the project
// points at — and each one arrives as an empty stub, so any project that has them has
// something to fill in. The step itself re-checks and shows only what is genuinely
// outstanding, which is what makes a re-import quiet.
if (execution.resourceCount > 0) {
setupNeeded = true
setupUndecided = false
return
}
if (names.length === 0) {
setupNeeded = false
setupUndecided = false
return
}
let cancelled = false
setupUndecided = true
void WorkspaceService.listDataTables({ workspace })
.then((tables) => {
if (cancelled) return
const present = new Set(tables.map((t) => t.name))
setupNeeded = names.some((n) => !present.has(n))
})
.catch(() => {
// Can't tell — don't invent a step the user then cannot complete.
if (!cancelled) setupNeeded = false
})
.finally(() => {
if (!cancelled) setupUndecided = false
})
return () => (cancelled = true)
})
const setup = useSetupStep(
() => execution,
() => planWorkspaceId(plan)
)
const setupNeeded = $derived(setup.needed)
const setupUndecided = $derived(setup.undecided)
</script>
{#if leaving}
@@ -321,7 +281,7 @@
{#if step === 1}
·
<a
class="text-blue-500 hover:underline"
class="text-accent hover:underline"
href="{base}/user/logout?rd={encodeURIComponent(logoutReturnTo)}"
>
Switch account
@@ -519,6 +479,7 @@
<ImportProjectStep
{plan}
{project}
showNotes={plan.destination?.kind === 'existing'}
setupPending={setupNeeded}
{setupUndecided}
onFolderChange={(folder) => go({ folder }, 3, { replace: true })}
@@ -2,27 +2,10 @@
<script lang="ts">
import { page } from '$app/state'
import { onMount } from 'svelte'
import RunsPage from '../../../../../lib/components/RunsPage.svelte'
import RunsTutorial from '$lib/components/tutorials/RunsTutorial.svelte'
let runsTutorial: RunsTutorial
// Get the path from route params (e.g., /runs/u/user/script → "u/user/script")
let initialPath = $derived(page.params.path ?? '')
onMount(() => {
// Check if there's a tutorial parameter in the URL
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam === 'runs-tutorial') {
// Small delay to ensure page is fully loaded
setTimeout(() => {
runsTutorial?.runTutorial()
}, 500)
}
})
</script>
<RunsPage {initialPath} />
<RunsTutorial bind:this={runsTutorial} index={7} />
@@ -1,428 +0,0 @@
<script lang="ts">
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Tab } from '$lib/components/common'
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'
import PageHeader from '$lib/components/PageHeader.svelte'
// 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
// Always use preview mode for admins to show role-specific tutorials
// This ensures admins only see tutorials for the selected role
const usePreview = user?.is_admin
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>
<PageHeader
title="Tutorials"
tooltip="Learn how to use Windmill with our interactive tutorials"
documentationLink="https://www.windmill.dev/docs/intro"
>
{#if activeTabs.length > 0}
<div class="flex gap-2">
<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}
</PageHeader>
<div class="flex flex-col gap-4 pb-2 my-4 mr-2">
{#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>
@@ -1,9 +1,12 @@
<script lang="ts">
import { ArrowLeft } from 'lucide-svelte'
import { UserService } from '$lib/gen/services.gen'
import { UserService, WorkspaceService } from '$lib/gen/services.gen'
import { goto } from '$lib/navigation'
import { usersWorkspaceStore } from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
import { page } from '$app/state'
import { toSameOriginRelativePath } from '$lib/logoutRedirect'
import SimpleCreateWorkspace from '$lib/components/workspaceSettings/SimpleCreateWorkspace.svelte'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -26,8 +29,9 @@
// Define step names as constants for better maintainability
const STEP_SOURCE = 'source'
const STEP_USE_CASE = 'use_case'
const STEP_WORKSPACE = 'workspace'
type OnboardingStep = typeof STEP_SOURCE | typeof STEP_USE_CASE
type OnboardingStep = typeof STEP_SOURCE | typeof STEP_USE_CASE | typeof STEP_WORKSPACE
let currentStep = $state<OnboardingStep>(STEP_SOURCE)
let useCaseText = $state('')
@@ -37,6 +41,34 @@
let otherPopoverOpen = $state(false)
let otherInputRef: HTMLInputElement | undefined = $state()
// Whether this user has somewhere to go already, in which case there is nothing to create.
// A pending invite counts: it is a `workspace_invite` row until `accept_invite` runs, so an
// invited teammate reaches onboarding owning nothing, and creating them a personal
// workspace is not what they came for — the picker is where the invite is. Loaded up front
// so the last step is settled by the time the survey is answered, and true when the load
// fails, since the picker can work the decision out and the create step has no way back.
let alreadyPlaced = $state(false)
// The survey was skipped, so the last step has nothing to go back to.
let skippedSurvey = $state(false)
async function loadWorkspaceStep() {
try {
const [workspaces, invites] = await Promise.all([
WorkspaceService.listUserWorkspaces(),
UserService.listWorkspaceInvites()
])
usersWorkspaceStore.set(workspaces)
alreadyPlaced = workspaces.workspaces.some((w) => w.id !== 'admins') || invites.length > 0
} catch (error) {
console.error('Could not prepare the workspace step:', error)
alreadyPlaced = true
}
}
// Held, not dropped: Skip awaits one POST that can finish before these GETs do, and
// branching on `alreadyPlaced` before they land would skip the step this flow exists for.
// Both exits await it; `isSubmitting` already covers the wait.
const workspaceStepReady = loadWorkspaceStep()
const sources = [
{ id: 'ai_search', label: 'AI search', icon: Bot },
{ id: 'search_engine', label: 'Search engine', icon: Search },
@@ -77,7 +109,39 @@
}
function goToPreviousStep() {
currentStep = STEP_SOURCE
currentStep = currentStep === STEP_WORKSPACE ? STEP_USE_CASE : STEP_SOURCE
}
/**
* Where to go once onboarding is done. A destination carried by the sign-in — a hub project
* import, say — is what the user came for, so it wins. Otherwise the one workspace this
* flow just made, or the one an invite already gave them, is where they belong and the
* picker would be a page with a single choice on it. It is reached only when there is an
* actual choice to make: several workspaces, or an invite still to accept.
*/
async function leaveOnboarding() {
// `toSameOriginRelativePath` rather than a local check: it already rejects `//host`,
// `/\\host` (which WHATWG URL parsing resolves to a different origin), control
// characters and oversized values. A second, weaker copy of this is how one of those
// gets missed.
const requested = toSameOriginRelativePath(page.url.searchParams.get('rd'))
if (requested) {
await goto(requested)
return
}
try {
const workspaces = await WorkspaceService.listUserWorkspaces()
usersWorkspaceStore.set(workspaces)
const owned = workspaces.workspaces.filter((w) => w.id !== 'admins')
if (owned.length === 1) {
switchWorkspace(owned[0].id)
await goto('/')
return
}
} catch (error) {
console.error('Could not list workspaces after onboarding:', error)
}
await goto('/user/workspaces')
}
async function continueToWorkspaces() {
@@ -97,24 +161,17 @@
console.error('Error submitting onboarding data:', error)
sendUserToast('Failed to save information: ' + (error?.body || error?.message || error), true)
} finally {
await workspaceStepReady
isSubmitting = false
// do not block users from accessing windmill even if there is an error
goto(onboardingDestination())
if (alreadyPlaced) {
leaveOnboarding()
} else {
currentStep = STEP_WORKSPACE
}
}
}
/**
* Where to go once onboarding is done. `/user/workspaces` unless the sign-in carried a
* destination — a hub project import, say — in which case that is what the user came for.
* Same-origin relative paths only, so a crafted `?rd=` cannot bounce them off-site.
*/
function onboardingDestination(): string {
// `toSameOriginRelativePath` rather than a local check: it already rejects `//host`,
// `/\\host` (which WHATWG URL parsing resolves to a different origin), control
// characters and oversized values. A second, weaker copy of this is how one of those
// gets missed.
return toSameOriginRelativePath(page.url.searchParams.get('rd')) ?? '/user/workspaces'
}
async function skip() {
isSubmitting = true
try {
@@ -124,8 +181,16 @@
} catch (error) {
console.error('Error skipping onboarding:', error)
} finally {
// do not block users from accessing windmill even if there is an error
goto(onboardingDestination())
await workspaceStepReady
isSubmitting = false
// Skipping the survey is not skipping naming the workspace: the questions are ours,
// the workspace is theirs.
skippedSurvey = true
if (alreadyPlaced) {
leaveOnboarding()
} else {
currentStep = STEP_WORKSPACE
}
}
}
</script>
@@ -231,6 +296,43 @@
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600"></div>
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
{#if !alreadyPlaced}
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600"></div>
{/if}
</div>
</div>
</div>
</CenteredModal>
{:else if currentStep === STEP_WORKSPACE}
<CenteredModal title="Create your workspace" centerVertically={false}>
<div class="w-full max-w-lg mx-auto">
<p class="mb-6 text-sm text-secondary">
Your scripts, flows and apps live here. You can rename it later in the workspace settings.
</p>
<!-- The same one-field form the workspace picker falls back to, so a user who leaves
onboarding early meets it again rather than something new. It owns the name, the
id, the advanced form and the hand-over into the workspace. -->
<SimpleCreateWorkspace onCreated={leaveOnboarding}>
{#snippet leading()}
{#if !skippedSurvey}
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: ArrowLeft }}
on:click={goToPreviousStep}
>
Previous
</Button>
{/if}
{/snippet}
</SimpleCreateWorkspace>
<div class="flex justify-center mt-4">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600"></div>
<div class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600"></div>
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
</div>
</div>
</div>
@@ -24,9 +24,19 @@
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { switchWorkspace } from '$lib/storeUtils'
import { GitFork, Settings, User, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte'
import {
GitFork,
Settings,
User,
Search,
ChevronsDownUp,
ChevronsUpDown,
LogOut
} from 'lucide-svelte'
import { isCloudHosted } from '$lib/cloud'
import { isValidLogoutRedirect, toSameOriginRelativePath } from '$lib/logoutRedirect'
import { canCreateWorkspace } from '$lib/workspaceCreation'
import SimpleCreateWorkspace from '$lib/components/workspaceSettings/SimpleCreateWorkspace.svelte'
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
import { emptyString } from '$lib/utils'
import { getUserExt } from '$lib/user'
@@ -36,6 +46,7 @@
import type { UserWorkspace } from '$lib/stores'
let invites: WorkspaceInvite[] = $state([])
let invitesLoaded = $state(false)
let list_all_as_super_admin: boolean = $state(false)
let workspaces: UserWorkspace[] | undefined = $state(undefined)
let showAllForks: boolean = $state(false)
@@ -49,7 +60,20 @@
let userSettings: UserSettings | undefined = $state()
let superadminSettings: SuperadminSettings | undefined = $state()
let rd = $derived($page.url.searchParams.get('rd'))
// Sanitized here rather than at each hand-off below: all four send an absolute `rd` to
// `window.location.href`, which unlike `goto` leaves the origin, and a fourth weaker copy
// of the check is how one of them gets missed. Absolute targets keep the allowance the
// OAuth callback uses (`isValidLogoutRedirect`: same origin, `*.windmill.dev`, the hub),
// since honouring one is why those branches exist. Anything else falls back to '/'.
let rd = $derived.by(() => {
const raw = $page.url.searchParams.get('rd')
if (!raw) return null
// Truthy for a safe relative path and for a same-origin URL; null for `//host`,
// `/\host` and control characters, which read as relative but are not.
if (toSameOriginRelativePath(raw)) return raw
if (!raw.startsWith('http')) return null
return isValidLogoutRedirect(raw) ? raw : null
})
run(() => {
if (userSettings && $page.url.hash.startsWith(USER_SETTINGS_HASH)) {
@@ -61,6 +85,7 @@
async function loadInvites() {
try {
invites = await UserService.listWorkspaceInvites()
invitesLoaded = true
} catch {}
}
@@ -102,6 +127,7 @@
let allWorkspaces = $derived.by(() => workspaces || [])
let noWorkspaces = $derived($superadmin && allWorkspaces.length == 0)
let onlyAdminsWorkspace = $derived(allWorkspaces.length === 1 && allWorkspaces[0].id === 'admins')
async function getCreateWorkspaceRequireSuperadmin() {
@@ -120,12 +146,74 @@
getCreateWorkspaceRequireSuperadmin()
}
refreshSuperadmin()
// Forced: this page hands the superadmin their instance settings and the list-all toggle,
// and stands the picker down entirely for a user who has nothing to pick — so a `false`
// left over from a logged-out load in this session (see `refreshSuperadmin`) does not just
// hide a button, it decides what the page is.
refreshSuperadmin({ force: true })
loadInvites()
loadWorkspaces()
let loading = $state(false)
// Nothing to pick and nothing to accept: the page's only action is the one button under
// the empty list, so it *is* that action. Shown as the creation form rather than a page
// asking you to choose between one thing. Held back until the invites have loaded, or a
// user with an invite waiting would see a form for a workspace they do not need.
// Both halves, the way the markup below tests it: `workspaces` is assigned from
// `$userWorkspaces` by the legacy pre-effect, and that derives to `[]` while
// `usersWorkspaceStore` is still undefined — so `workspaces !== undefined` alone is true
// from the first flush of a hard load, with an empty list behind it. Since `showCreate`
// latches, one such frame would swap a member's picker for the create form until reload.
// `$derived.by` because a plain `$derived` reading `workspaces` narrows it to `never`
// here, the same reason `allWorkspaces` is written that way above.
let workspacesLoaded = $derived.by(
() => workspaces !== undefined && $usersWorkspaceStore !== undefined
)
// Not for a superadmin: this page is also where they reach the instance settings and the
// list-all toggle, and standing the picker down takes both away — a superadmin with no
// membership of their own has business here besides creating a workspace. Waiting for the
// store to answer rather than reading `!$superadmin`, which is true while `globalWhoami`
// is still in flight.
let nothingToChoose = $derived(
workspacesLoaded &&
invitesLoaded &&
createWorkspace &&
$superadmin !== undefined &&
!$superadmin &&
!list_all_as_super_admin &&
allWorkspaces.length === 0 &&
invites.length === 0
)
/**
* Where to go once a workspace exists. `rd` can be absolute — a login flow persists the page
* URL it interrupted — and `goto` refuses those, which would strand the caller on its
* "Creating …" screen with the workspace already made. Same hand-off every other `rd` path
* on this page makes, over the value sanitized where `rd` is derived.
*/
function leaveForWorkspace() {
if (rd?.startsWith('http')) {
window.location.href = rd
return
}
void goto(rd ?? '/')
}
// Once this page has become the create form it stays it, until it navigates away. Creating
// a workspace refreshes the workspace list, which answers `nothingToChoose` with a no
// mid-creation — and the form, along with whatever it was showing, would be replaced by the
// picker for the workspace it had just made.
let showCreate = $state(false)
$effect(() => {
if (nothingToChoose) showCreate = true
// Except for a superadmin, whom `nothingToChoose` excludes — so this only ever undoes a
// latch that should not have happened: one taken on a stale `false` before the forced
// `refreshSuperadmin` above answered. Without it that superadmin would be stuck on the
// create form, instance settings and the list-all toggle gone with it, until a reload.
else if ($superadmin) showCreate = false
})
async function speakFriendAndEnterWorkspace(workspaceId: string) {
loading = true
@@ -185,208 +273,156 @@
{/if}
<CenteredModal
title="Select a workspace"
subtitle="Logged in as {$usersWorkspaceStore?.email}"
title={showCreate ? 'Create your workspace' : 'Select a workspace'}
centerVertically={false}
>
{#snippet subtitleSnippet()}
<!-- The way out belongs on the line that says who you are, not in a footer as the page's
accent action: leaving is not what anyone came here to do. Shown in both states; the
picker also carries it in the settings menu below, which the create state hides. -->
<span class="text-xs text-tertiary">
Logged in as <span class="text-secondary">{$usersWorkspaceStore?.email}</span>
·
<!-- A bare <button> for a link inside the sentence, signed off by design: <Button>
cannot sit inline in running text. Inline links take `text-accent`. -->
<button class="text-accent hover:underline" onclick={() => logout()}>Log out</button>
</span>
{/snippet}
{@const nonForkInvites = invites.filter((invite) => invite.parent_workspace_id == undefined)}
<div class="flex flex-col">
<div class="flex flex-row items-center gap-2 justify-between mb-4">
<h2 class="inline-flex gap-2 text-sm font-semibold text-emphasis flex-shrink-0">
Workspaces{#if loading}<WindmillIcon spin="fast" />{/if}
</h2>
{#if allWorkspaces.length > 1}
<div class="flex gap-2 items-center">
<div class="relative text-primary flex-1 max-w-48">
<TextInput
inputProps={{
placeholder: 'Search workspaces...'
}}
size="sm"
bind:value={workspaceSearchFilter}
class="!pr-8"
/>
<Search size={14} class="text-secondary absolute right-2 top-0 mt-2" />
</div>
{#if workspaceHasForks}
<Button
onClick={() => workspaceExpandCollapseAll?.()}
title={workspaceAllExpanded ? 'Collapse all' : 'Expand all'}
startIcon={{ icon: workspaceAllExpanded ? ChevronsDownUp : ChevronsUpDown }}
size="xs2"
variant="default"
>
{workspaceAllExpanded ? 'Collapse' : 'Expand'}
</Button>
{/if}
</div>
{/if}
</div>
{#if $superadmin}
<div class="flex justify-end mb-2">
<Toggle
bind:checked={list_all_as_super_admin}
options={{ right: 'List all workspaces as superadmin' }}
size="xs"
/>
</div>
{/if}
{#if workspaces && $usersWorkspaceStore}
{#if workspaces.length == 0}
<p class="text-xs text-secondary mt-2">
You are not a member of any workspace yet. Accept an invitation {#if createWorkspace}or
create your own{/if}
workspace.
</p>
{:else}
<WorkspaceTreeView
workspaces={allWorkspaces}
onEnterWorkspace={speakFriendAndEnterWorkspace}
onUnarchive={async (_workspaceId) => {
if (list_all_as_super_admin) {
loadWorkspacesAsAdmin()
} else {
loadWorkspaces()
}
}}
bind:searchFilter={workspaceSearchFilter}
bind:allExpanded={workspaceAllExpanded}
bind:hasForks={workspaceHasForks}
bind:this={workspaceTreeView}
/>
{/if}
<!-- The picker stands down when it has nothing to offer: no workspace to enter and no
invite to accept leaves one action on the page, so the page is that action. -->
{#if showCreate}
<SimpleCreateWorkspace onCreated={leaveForWorkspace} />
{:else}
{#each new Array(3) as _, i (i)}
<Skeleton layout={[[2], 0.5]} />
{/each}
{/if}
<div class="flex flex-row items-center gap-2 justify-between mb-4">
<h2 class="inline-flex gap-2 text-sm font-semibold text-emphasis flex-shrink-0">
Workspaces{#if loading}<WindmillIcon spin="fast" />{/if}
</h2>
{#if createWorkspace}
<div class="flex flex-row-reverse pt-4 w-full">
<AnimatedButton
animate={onlyAdminsWorkspace}
baseRadius="6px"
animationDuration="2s"
marginWidth="2px"
wrapperClasses="w-full"
>
<Button
unifiedSize="sm"
href="{base}/user/create_workspace{rd ? `?rd=${encodeURIComponent(rd)}` : ''}"
variant={onlyAdminsWorkspace || noWorkspaces ? 'accent' : 'default'}
wrapperClasses="w-full"
>+&nbsp;Create a new workspace
</Button>
</AnimatedButton>
</div>
{/if}
{#if invites.length > 0}
<div class="flex flex-row items-center justify-between mt-8">
<h2 class="text-sm font-semibold text-emphasis">Invites to join a Workspace</h2>
{#if workspaces}
<Toggle
size="xs"
bind:checked={showAllForks}
options={{ right: 'Show workspace forks' }}
/>
{/if}
</div>
<div class="mt-4"></div>
{#if nonForkInvites.length == 0}
<p class="text-xs text-secondary"> You don't have new invites at the moment. </p>
{/if}
{#each nonForkInvites as invite}
<div
class="w-full mx-auto py-1 px-2 rounded-md border border-border-light
text-xs mt-1 flex flex-row justify-between items-center"
>
<div class="grow">
<span class="font-mono font-semibold text-emphasis">{invite.workspace_id}</span>
{#if invite.is_admin}
<span class="text-xs text-primary">as an admin</span>
{:else if invite.operator}
<span class="text-xs text-primary">as an operator</span>
{#if allWorkspaces.length > 1}
<div class="flex gap-2 items-center">
<div class="relative text-primary flex-1 max-w-48">
<TextInput
inputProps={{
placeholder: 'Search workspaces...'
}}
size="sm"
bind:value={workspaceSearchFilter}
class="!pr-8"
/>
<Search size={14} class="text-secondary absolute right-2 top-0 mt-2" />
</div>
{#if workspaceHasForks}
<Button
onClick={() => workspaceExpandCollapseAll?.()}
title={workspaceAllExpanded ? 'Collapse all' : 'Expand all'}
startIcon={{ icon: workspaceAllExpanded ? ChevronsDownUp : ChevronsUpDown }}
size="xs2"
variant="default"
>
{workspaceAllExpanded ? 'Collapse' : 'Expand'}
</Button>
{/if}
</div>
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
<Button
variant="accent"
size="xs2"
href="{base}/user/accept_invite?workspace={encodeURIComponent(invite.workspace_id)}{rd
? `&rd=${encodeURIComponent(rd)}`
: ''}"
>
Accept
</Button>
{/if}
</div>
<Button
variant="subtle"
size="xs2"
onClick={async () => {
await UserService.declineInvite({
requestBody: { workspace_id: invite.workspace_id }
})
sendUserToast(`Declined invite to ${invite.workspace_id}`)
loadInvites()
}}
destructive
>
Decline
</Button>
</div>
{#if $superadmin}
<div class="flex justify-end mb-2">
<Toggle
bind:checked={list_all_as_super_admin}
options={{ right: 'List all workspaces as superadmin' }}
size="xs"
/>
</div>
{/each}
{/if}
{#if showAllForks}
{@const allWorkspacesList = workspaces || []}
{@const filteredInvites = invites.filter((invite) => invite.parent_workspace_id)}
{#if workspaces && $usersWorkspaceStore}
{#if workspaces.length == 0}
<p class="text-xs text-secondary mt-2">
You are not a member of any workspace yet. Accept an invitation {#if createWorkspace}or
create your own{/if}
workspace.
</p>
{:else}
<WorkspaceTreeView
workspaces={allWorkspaces}
onEnterWorkspace={speakFriendAndEnterWorkspace}
onUnarchive={async (_workspaceId) => {
if (list_all_as_super_admin) {
loadWorkspacesAsAdmin()
} else {
loadWorkspaces()
}
}}
bind:searchFilter={workspaceSearchFilter}
bind:allExpanded={workspaceAllExpanded}
bind:hasForks={workspaceHasForks}
bind:this={workspaceTreeView}
/>
{/if}
{:else}
{#each new Array(3) as _, i (i)}
<Skeleton layout={[[2], 0.5]} />
{/each}
{/if}
{#if createWorkspace}
<div class="flex flex-row-reverse pt-4 w-full">
<AnimatedButton
animate={onlyAdminsWorkspace}
baseRadius="6px"
animationDuration="2s"
marginWidth="2px"
wrapperClasses="w-full"
>
<Button
unifiedSize="md"
href="{base}/user/create_workspace{rd ? `?rd=${encodeURIComponent(rd)}` : ''}"
variant={onlyAdminsWorkspace || noWorkspaces ? 'accent' : 'default'}
wrapperClasses="w-full"
>+&nbsp;Create a new workspace
</Button>
</AnimatedButton>
</div>
{/if}
{#if invites.length > 0}
<div class="flex flex-row items-center justify-between mt-8">
<h2 class="text-sm font-semibold text-emphasis">Invites to join a Workspace</h2>
{#if workspaces}
<Toggle
size="xs"
bind:checked={showAllForks}
options={{ right: 'Show workspace forks' }}
/>
{/if}
</div>
<div class="mt-4"></div>
{#if filteredInvites.length == 0}
<p class="text-xs text-secondary"
>There are no invites to join the forks of any workspace you're in.</p
>
{:else}
<span class="mb-2 text-xs font-normal text-secondary"
>Forks of the workspaces you're in</span
>
{#if nonForkInvites.length == 0}
<p class="text-xs text-secondary"> You don't have new invites at the moment. </p>
{/if}
{#each filteredInvites as invite}
{@const inviteWorkspace = allWorkspacesList.find((w) => w.id === invite.workspace_id)}
{#each nonForkInvites as invite}
<div
class="w-full mx-auto py-1 px-2 rounded-md border border-border-light
text-xs mt-1 flex flex-row justify-between items-center"
text-xs mt-1 flex flex-row justify-between items-center"
>
<div class="grow">
<div class="flex items-center gap-2">
{#if inviteWorkspace?.parent_workspace_id}
<GitFork size={12} class="text-secondary flex-shrink-0" />
{/if}
<span class="font-mono font-semibold text-emphasis">{invite.workspace_id}</span>
</div>
<span class="font-mono font-semibold text-emphasis">{invite.workspace_id}</span>
{#if invite.is_admin}
<span class="text-xs text-primary">as an admin</span>
{:else if invite.operator}
<span class="text-xs text-primary">as an operator</span>
{/if}
{#if invite.parent_workspace_id}
<div class="text-secondary text-2xs mt-1">
Fork of {invite.parent_workspace_id}
</div>
{/if}
</div>
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
<Button
variant="accent"
unifiedSize="xs"
size="xs2"
href="{base}/user/accept_invite?workspace={encodeURIComponent(
invite.workspace_id
)}{rd ? `&rd=${encodeURIComponent(rd)}` : ''}"
@@ -396,8 +432,7 @@
<Button
variant="subtle"
unifiedSize="xs"
destructive
size="xs2"
onClick={async () => {
await UserService.declineInvite({
requestBody: { workspace_id: invite.workspace_id }
@@ -405,53 +440,120 @@
sendUserToast(`Declined invite to ${invite.workspace_id}`)
loadInvites()
}}
destructive
>
Decline
</Button>
</div>
</div>
{/each}
{#if showAllForks}
{@const allWorkspacesList = workspaces || []}
{@const filteredInvites = invites.filter((invite) => invite.parent_workspace_id)}
<div class="mt-4"></div>
{#if filteredInvites.length == 0}
<p class="text-xs text-secondary"
>There are no invites to join the forks of any workspace you're in.</p
>
{:else}
<span class="mb-2 text-xs font-normal text-secondary"
>Forks of the workspaces you're in</span
>
{/if}
{#each filteredInvites as invite}
{@const inviteWorkspace = allWorkspacesList.find((w) => w.id === invite.workspace_id)}
<div
class="w-full mx-auto py-1 px-2 rounded-md border border-border-light
text-xs mt-1 flex flex-row justify-between items-center"
>
<div class="grow">
<div class="flex items-center gap-2">
{#if inviteWorkspace?.parent_workspace_id}
<GitFork size={12} class="text-secondary flex-shrink-0" />
{/if}
<span class="font-mono font-semibold text-emphasis">{invite.workspace_id}</span>
</div>
{#if invite.is_admin}
<span class="text-xs text-primary">as an admin</span>
{:else if invite.operator}
<span class="text-xs text-primary">as an operator</span>
{/if}
{#if invite.parent_workspace_id}
<div class="text-secondary text-2xs mt-1">
Fork of {invite.parent_workspace_id}
</div>
{/if}
</div>
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
<Button
variant="accent"
unifiedSize="xs"
href="{base}/user/accept_invite?workspace={encodeURIComponent(
invite.workspace_id
)}{rd ? `&rd=${encodeURIComponent(rd)}` : ''}"
>
Accept
</Button>
<Button
variant="subtle"
unifiedSize="xs"
destructive
onClick={async () => {
await UserService.declineInvite({
requestBody: { workspace_id: invite.workspace_id }
})
sendUserToast(`Declined invite to ${invite.workspace_id}`)
loadInvites()
}}
>
Decline
</Button>
</div>
</div>
{/each}
{/if}
{/if}
{/if}
<div class="flex justify-between items-center mt-10 flex-wrap gap-2">
{#if $superadmin}
<Button
variant="default"
unifiedSize="md"
onClick={superadminSettings?.openDrawer}
startIcon={{ icon: Settings }}
dropdownItems={[
{
label: 'User settings',
onClick: () => userSettings?.openDrawer(),
icon: User
}
]}
>
Instance settings
</Button>
{:else}
<Button
variant="default"
unifiedSize="md"
onClick={() => userSettings?.openDrawer()}
startIcon={{ icon: Settings }}
>
User settings
</Button>
{/if}
<Button
variant="accent"
unifiedSize="md"
onClick={async () => {
logout()
}}
>
Log out
</Button>
</div>
<!-- Settings are for someone who lives here; a user with no workspace yet has one thing
to do, so this row stands down for the create state. Logging out is in this menu as
well as on the subtitle line, which is the only one of the two the create state has. -->
{#if !showCreate}
<div class="flex items-center mt-10 flex-wrap gap-2">
{#if $superadmin}
<Button
variant="default"
unifiedSize="sm"
onClick={superadminSettings?.openDrawer}
startIcon={{ icon: Settings }}
dropdownItems={[
{
label: 'User settings',
onClick: () => userSettings?.openDrawer(),
icon: User
},
{ label: 'Log out', onClick: () => logout(), icon: LogOut }
]}
>
Instance settings
</Button>
{:else}
<Button
variant="default"
unifiedSize="sm"
onClick={() => userSettings?.openDrawer()}
startIcon={{ icon: Settings }}
dropdownItems={[{ label: 'Log out', onClick: () => logout(), icon: LogOut }]}
>
User settings
</Button>
{/if}
</div>
{/if}
</div>
</CenteredModal>
<!-- <div class="center-center min-h-screen p-4">
+1 -6
View File
@@ -307,12 +307,7 @@
<Splitpanes horizontal class="max-h-screen grow min-h-0">
<Pane size={33}>
{#if flowStore.val?.value?.modules}
<FlowModuleSchemaMap
disableAi
disableTutorials
smallErrorHandler={true}
disableStaticInputs
/>
<FlowModuleSchemaMap disableAi smallErrorHandler={true} disableStaticInputs />
{:else}
<div class="text-red-400 mt-20">Missing flow modules</div>
{/if}
-598
View File
@@ -1,598 +0,0 @@
# Windmill Tutorial System Guide
This guide documents the complete tutorial infrastructure in Windmill's frontend, enabling developers to create new interactive tutorials without re-exploring the codebase.
## Table of Contents
1. [Overview](#overview)
2. [Architecture](#architecture)
3. [File Structure](#file-structure)
4. [Creating a New Tutorial](#creating-a-new-tutorial)
5. [Key Components & APIs](#key-components--apis)
6. [Progress Tracking System](#progress-tracking-system)
7. [Role-Based Access](#role-based-access)
8. [Testing & Debugging](#testing--debugging)
---
## Overview
The Windmill tutorial system provides interactive, step-by-step guides for users using the `driver.js` library. Tutorials can:
- Highlight specific UI elements with overlay popovers
- Guide users through workflows with navigation controls
- Track completion progress in the database
- Filter tutorials by user role (admin, developer, operator)
- Support multiple tutorial contexts (workspace, flow editor, app editor)
**Core Technology:** [Driver.js](https://driverjs.com/) - A lightweight JavaScript library for creating product tours
---
## Architecture
### High-Level Flow
```
Tutorial Config (config.ts)
Tutorial Registration (component creation)
Tutorial Router (WorkspaceTutorials.svelte, etc.)
URL Parameter Detection (+page.svelte)
Tutorial Component (driver.js overlay)
Progress Tracking (tutorialUtils.ts → backend)
```
### Component Hierarchy
```
TutorialRouter (manages multiple tutorials)
└── TutorialWrapper (wraps individual tutorials)
└── Tutorial (core driver.js engine)
├── TutorialControls (prev/next buttons)
├── SkipTutorials (skip options)
└── TutorialInner (loads driver.js CSS)
```
### State Management
- **Global Stores** (`stores.ts`):
- `tutorialsToDo`: Array of incomplete tutorial indexes
- `skippedAll`: Boolean flag for skipped tutorials
- `isCurrentlyInTutorial`: Boolean tracking active tutorial state
- **Progress Tracking** (`tutorialUtils.ts`):
- Uses 64-bit bitmask system (each bit = one tutorial)
- Syncs with backend `tutorial_progress` table
- Backend table: `tutorial_progress(email, progress bit(64))`
---
## File Structure
```
frontend/src/lib/
├── tutorials/
│ ├── config.ts # Central tutorial registry
│ └── roleUtils.ts # Role-based access logic
├── tutorialUtils.ts # Progress tracking utilities
├── stores.ts # Global stores (tutorialsToDo, etc.)
└── components/
├── WorkspaceTutorials.svelte # Workspace tutorial container
├── FlowTutorials.svelte # Flow editor tutorials container
├── AppTutorials.svelte # App editor tutorials container
├── RunPageTutorials.svelte # Run page tutorials container
├── tutorials/
│ ├── Tutorial.svelte # Core tutorial engine (driver.js)
│ ├── TutorialRouter.svelte # Multi-tutorial manager
│ ├── TutorialWrapper.svelte # Instance wrapper
│ ├── TutorialInner.svelte # Loads driver.js CSS
│ ├── TutorialControls.svelte # Navigation UI
│ ├── SkipTutorials.svelte # Skip options
│ ├── ignoredTutorials.ts # Local storage for ignored tutorials
│ │
│ ├── workspace/
│ │ ├── WorkspaceOnboardingTutorial.svelte
│ │ └── WorkspaceOnboardingOperatorTutorial.svelte
│ │
│ ├── app/
│ │ ├── BackgroundRunnablesTutorial.svelte
│ │ ├── ConnectionTutorial.svelte
│ │ └── ExpressionEvaluationTutorial.svelte
│ │
│ └── flow/
│ ├── FlowBuilderLiveTutorial.svelte
│ └── TroubleshootFlowTutorial.svelte
└── home/
├── TutorialButton.svelte # Tutorial card UI
└── TutorialBanner.svelte # Homepage banner
```
---
## Creating a New Tutorial
### Step 1: Register Tutorial in Config
**File:** `frontend/src/lib/tutorials/config.ts`
```typescript
export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
quickstart: {
label: 'Quickstart',
roles: ['admin', 'developer', 'operator'],
progressBar: true,
active: true,
tutorials: [
{
id: 'my-new-tutorial', // Unique identifier
icon: GraduationCap, // Lucide icon component
title: 'My New Tutorial',
description: 'Learn something new',
onClick: () => {
window.location.href = `${base}/?tutorial=my-new-tutorial`
},
index: 7, // Next available index (1-64)
active: true,
comingSoon: false,
roles: ['developer', 'admin'], // Who can access
order: 7
}
]
}
}
```
**Important:**
- Choose a unique `index` (1-64) not used by other tutorials
- The `id` must match the tutorial parameter in the URL
- Indexes are used for bitmask progress tracking
### Step 2: Create Tutorial Component
**File:** `frontend/src/lib/components/tutorials/workspace/MyNewTutorial.svelte`
```svelte
<script lang="ts">
import Tutorial from '../Tutorial.svelte'
import { updateProgress } from '$lib/tutorialUtils'
import type { DriveStep } from 'driver.js'
// Props
let { index }: { index: number } = $props()
// Tutorial instance reference
let tutorial: Tutorial
// Define tutorial steps
function getSteps(driver: any): DriveStep[] {
return [
{
// Step 0: Welcome
popover: {
title: 'Welcome!',
description: 'This tutorial will teach you...',
}
},
{
// Step 1: Highlight an element
element: '#some-element-id',
popover: {
title: 'Important Feature',
description: 'Here you can do X, Y, and Z...',
// Optional: Add image
// description: `<img src="/tutorial-image.png" /><p>Description...</p>`
}
},
{
// Step 2: Another element
element: '.some-css-class',
popover: {
title: 'Another Feature',
description: 'Click here to...',
}
},
{
// Final step: Completion
popover: {
title: 'Congratulations!',
description: 'You completed the tutorial!',
onNextClick: async () => {
// Mark tutorial as complete
await updateProgress(index)
driver.destroy()
}
}
}
]
}
// Export function to start tutorial
export function runTutorial(options?: any) {
tutorial?.runTutorial(options)
}
</script>
<Tutorial bind:this={tutorial} {index} {getSteps} />
```
### Step 3: Register in Tutorial Router
**File:** `frontend/src/lib/components/WorkspaceTutorials.svelte` (or appropriate container)
```svelte
<script lang="ts">
import TutorialRouter from './tutorials/TutorialRouter.svelte'
import WorkspaceOnboardingTutorial from './tutorials/workspace/WorkspaceOnboardingTutorial.svelte'
import MyNewTutorial from './tutorials/workspace/MyNewTutorial.svelte'
let tutorialRouter: TutorialRouter
export function runTutorialById(id: string, options?: any) {
tutorialRouter?.runTutorialById(id, options)
}
</script>
<TutorialRouter bind:this={tutorialRouter}>
<WorkspaceOnboardingTutorial index={1} />
<MyNewTutorial index={7} />
</TutorialRouter>
```
### Step 4: Add URL Parameter Handling
**File:** `frontend/src/routes/(root)/(logged)/+page.svelte` (or appropriate page)
```svelte
<script lang="ts">
import { page } from '$app/stores'
import { onMount } from 'svelte'
import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte'
let workspaceTutorials: WorkspaceTutorials
onMount(() => {
const tutorialParam = $page.url.searchParams.get('tutorial')
if (tutorialParam === 'my-new-tutorial') {
workspaceTutorials?.runTutorialById('my-new-tutorial')
}
})
</script>
<WorkspaceTutorials bind:this={workspaceTutorials} />
```
### Step 5: Test Your Tutorial
1. Login as a user with the appropriate role
2. Navigate to the tutorials page
3. Click your tutorial card
4. Verify URL changes to `/?tutorial=my-new-tutorial`
5. Verify tutorial starts automatically
6. Step through all steps
7. Verify completion marks tutorial as done
8. Check database: `SELECT * FROM tutorial_progress WHERE email = 'your@email.com'`
---
## Key Components & APIs
### Tutorial.svelte
**Core tutorial engine that wraps driver.js**
**Props:**
- `index: number` - Tutorial index for progress tracking (1-64)
- `getSteps: (driver) => DriveStep[]` - Function returning tutorial steps
**Exports:**
- `runTutorial(options?: any)` - Start the tutorial
**Features:**
- Auto-completes tutorial when last step is finished
- Renders custom controls and skip options
- Calls `updateProgress(index)` on completion
### TutorialRouter.svelte
**Manages multiple tutorial instances**
**Usage:**
```svelte
<TutorialRouter bind:this={router}>
<TutorialA index={1} />
<TutorialB index={2} />
</TutorialRouter>
```
**Exports:**
- `runTutorialById(id: string, options?: any)` - Start tutorial by ID
**Features:**
- Maintains Map of tutorial instances
- Routes calls to correct tutorial component
- Handles tutorial not found errors
### DriveStep Interface
**TypeScript interface for tutorial steps**
```typescript
interface DriveStep {
element?: string // CSS selector to highlight
popover?: {
title: string
description: string // Supports HTML
onNextClick?: (element, step, context) => void
onPrevClick?: (element, step, context) => void
}
}
```
**Tips:**
- Omit `element` for non-highlighted steps (like welcome/completion)
- Use HTML in `description` for images: `<img src="/path.png" />`
- Use callbacks for custom navigation logic
---
## Progress Tracking System
### Bitmask System
Tutorials use a 64-bit bitmask where each bit represents one tutorial's completion status:
```
Bit 0: Tutorial with index 0 (unused, reserve)
Bit 1: workspace-onboarding
Bit 2: flow-live-tutorial
Bit 3: troubleshoot-flow
Bit 4: backgroundrunnables
Bit 5: connection
Bit 6: workspace-onboarding-operator
...
Bit 63: Maximum possible tutorial
```
### Key Functions (tutorialUtils.ts)
```typescript
// Mark tutorial as complete
await updateProgress(tutorialIndex: number)
// Sync progress from backend
await syncTutorialsTodos()
// Skip all tutorials
await skipAllTodos()
// Reset all progress
await resetAllTodos()
// Skip specific tutorials
await skipTutorialsByIndexes(indexes: number[])
// Complete specific tutorial
await completeTutorialByIndex(index: number)
```
### Backend Integration
**Table:** `tutorial_progress`
```sql
CREATE TABLE tutorial_progress (
email VARCHAR PRIMARY KEY,
progress BIT(64)
);
```
**API Endpoint:** `POST /api/users/tutorial_progress`
```typescript
// Request body
{
"index": 7, // Tutorial index to mark complete
}
```
---
## Role-Based Access
### Available Roles
```typescript
type Role = 'admin' | 'developer' | 'operator'
```
### Role Hierarchy
- **Admin**: Full access, can see all tutorials
- **Developer**: Standard developer tutorials
- **Operator**: Limited to operator-specific tutorials
### Key Functions (roleUtils.ts)
```typescript
// Get current user's role
const role = getUserEffectiveRole(user)
// Check if user can access tutorial
const canAccess = hasRoleAccess(userRole, tutorialRoles)
```
### Setting Role Requirements
In `config.ts`:
```typescript
{
id: 'operator-only-tutorial',
roles: ['operator'], // Only operators see this
// ...
}
{
id: 'admin-dev-tutorial',
roles: ['admin', 'developer'], // Admins and developers see this
// ...
}
{
id: 'everyone-tutorial',
roles: ['admin', 'developer', 'operator'], // Everyone sees this
// ...
}
```
---
## Testing & Debugging
### Testing Checklist
- [ ] Tutorial appears in correct tab/category
- [ ] Tutorial only visible to correct roles
- [ ] Clicking tutorial navigates to correct URL with tutorial parameter
- [ ] Tutorial auto-starts on page load with parameter
- [ ] All steps highlight correct elements
- [ ] Navigation controls work (prev/next)
- [ ] Skip options work correctly
- [ ] Completion marks tutorial as done in database
- [ ] Banner updates to reflect completion
- [ ] Tutorial doesn't auto-start after completion
### Common Issues
**Tutorial doesn't auto-start:**
- Check URL parameter matches tutorial ID in config
- Verify `onMount()` logic in page component
- Ensure tutorial component is registered in router
**Element not highlighting:**
- Verify CSS selector is correct
- Check if element exists when tutorial runs
- Try using more specific selectors or IDs
**Progress not saving:**
- Check tutorial index is unique and correctly passed
- Verify `updateProgress()` is called on final step
- Check network tab for API call to `/api/users/tutorial_progress`
- Inspect database `tutorial_progress` table
**Wrong users see tutorial:**
- Verify `roles` array in config
- Check `getUserEffectiveRole()` returns correct role
- Ensure role filtering logic in tutorial list component
### Debugging Tools
**Browser Console:**
```javascript
// Check current tutorials to do
console.log($tutorialsToDo)
// Check if tutorial is skipped
console.log($skippedAll)
// Get user role
import { getUserEffectiveRole } from '$lib/tutorials/roleUtils'
console.log(getUserEffectiveRole($workspaceStore?.operator, $userStore))
```
**Database Queries:**
```sql
-- Check user's tutorial progress
SELECT email, progress::text FROM tutorial_progress WHERE email = 'user@example.com';
-- Reset user's progress (testing)
UPDATE tutorial_progress SET progress = B'0' WHERE email = 'user@example.com';
-- See all tutorials and their completion
SELECT
email,
(progress & (1::bit(64) << 1))::int AS workspace_onboarding,
(progress & (1::bit(64) << 2))::int AS flow_live_tutorial,
(progress & (1::bit(64) << 3))::int AS troubleshoot_flow
FROM tutorial_progress;
```
---
## Best Practices
### Tutorial Design
1. **Keep It Short**: 4-7 steps is ideal
2. **Clear Objectives**: State what users will learn upfront
3. **Highlight Key Elements**: Focus on essential features
4. **Use Images**: Visual aids help comprehension
5. **End with Encouragement**: Congratulate users on completion
### Technical Best Practices
1. **Unique Indexes**: Always use unique index numbers (1-64)
2. **Stable Selectors**: Use IDs or specific classes for element highlighting
3. **Error Handling**: Wrap `updateProgress()` in try-catch
4. **Role Testing**: Test with all relevant user roles
5. **Mobile Friendly**: Ensure tutorials work on different screen sizes
### Code Organization
1. **Group by Context**: Workspace, flow, app tutorials in separate folders
2. **Consistent Naming**: `[Feature]Tutorial.svelte` convention
3. **Reusable Steps**: Extract common step patterns to utilities
4. **Document Complex Logic**: Add comments for non-obvious step behaviors
---
## Quick Reference
### Creating a New Tutorial (Checklist)
- [ ] Step 1: Add to `config.ts` with unique ID and index
- [ ] Step 2: Create component in appropriate folder
- [ ] Step 3: Register in tutorial router (WorkspaceTutorials, etc.)
- [ ] Step 4: Add URL parameter handling in page component
- [ ] Step 5: Test with appropriate user role
- [ ] Step 6: Verify progress tracking in database
### File Paths (Quick Copy)
```
# Config
frontend/src/lib/tutorials/config.ts
# Tutorial Containers
frontend/src/lib/components/WorkspaceTutorials.svelte
frontend/src/lib/components/FlowTutorials.svelte
frontend/src/lib/components/AppTutorials.svelte
# Tutorial Components
frontend/src/lib/components/tutorials/Tutorial.svelte
frontend/src/lib/components/tutorials/TutorialRouter.svelte
frontend/src/lib/components/tutorials/workspace/[YourTutorial].svelte
# Page Integration
frontend/src/routes/(root)/(logged)/+page.svelte
# Utilities
frontend/src/lib/tutorialUtils.ts
frontend/src/lib/tutorials/roleUtils.ts
```
---
## Additional Resources
- **Driver.js Documentation**: https://driverjs.com/docs/
- **Svelte Tutorial System Examples**: See existing tutorials in `frontend/src/lib/components/tutorials/`
- **Database Schema**: See `backend/summarized_schema.txt` for `tutorial_progress` table details