From fd35b4765843879cb2254f402c142fd7510f1916 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 9 Sep 2026 10:22:06 +0200 Subject: [PATCH] feat: create the cloud workspace in onboarding, and teach the empty home (#10959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] feat: create a personal workspace on cloud signup instead of the demo invite Co-Authored-By: Claude Opus 5 (1M context) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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 ` diff --git a/frontend/src/lib/components/ImportWizardSteps.svelte b/frontend/src/lib/components/ImportWizardSteps.svelte index 5712ede180..aeddaee5e7 100644 --- a/frontend/src/lib/components/ImportWizardSteps.svelte +++ b/frontend/src/lib/components/ImportWizardSteps.svelte @@ -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) diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index d124569929..39efa2d090 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -1061,8 +1061,7 @@
  • worker usage (worker, worker instance, vCPUs, memory)
  • 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)
  • superadmin email addresses
  • development instance status
  • @@ -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)
  • feature adoption (counts of which flow, script, trigger, worker and data table @@ -1129,8 +1130,7 @@
  • worker usage (worker, worker instance, vCPUs, memory)
  • 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)
  • development instance status
  • feature adoption (counts of which flow, script, trigger, worker and data table diff --git a/frontend/src/lib/components/RunPageTutorials.svelte b/frontend/src/lib/components/RunPageTutorials.svelte deleted file mode 100644 index e46b1c9019..0000000000 --- a/frontend/src/lib/components/RunPageTutorials.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/frontend/src/lib/components/WorkspaceTutorials.svelte b/frontend/src/lib/components/WorkspaceTutorials.svelte deleted file mode 100644 index f2f1bb63b6..0000000000 --- a/frontend/src/lib/components/WorkspaceTutorials.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 8ef4ffbf95..565ac5d5d3 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -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 diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index a57dc87622..ab043f81a1 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -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 @@ {/if}
    -
    - - {#if $tutorialsToDo.includes(getTutorialIndex('backgroundrunnables')) || $tutorialsToDo.includes(getTutorialIndex('connection'))} - - {/if} -
    - +
    {#if hasErrors} diff --git a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte b/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte deleted file mode 100644 index c268058717..0000000000 --- a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -) => { - targetTutorial = event.detail.detail - }} -/> - - { - targetTutorial = undefined - }} - on:confirmed={async () => { - window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank') - }} -> -
    - This tutorial can only be run on a new app. -
    -
    diff --git a/frontend/src/lib/components/apps/editor/component/ComponentNavigation.svelte b/frontend/src/lib/components/apps/editor/component/ComponentNavigation.svelte index 2ac045feaa..aa81b7f5cc 100644 --- a/frontend/src/lib/components/apps/editor/component/ComponentNavigation.svelte +++ b/frontend/src/lib/components/apps/editor/component/ComponentNavigation.svelte @@ -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') 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 } diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte index 214ab3e31d..a4bed4affb 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte @@ -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 @@ -
    +
    Choose a language
    diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptsPanelList.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptsPanelList.svelte index aaffb1feaf..56304ca3e3 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptsPanelList.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptsPanelList.svelte @@ -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 @@
    - - diff --git a/frontend/src/lib/components/common/modal/PagedContent.svelte b/frontend/src/lib/components/common/modal/PagedContent.svelte index 674c147c52..bfae0473f1 100644 --- a/frontend/src/lib/components/common/modal/PagedContent.svelte +++ b/frontend/src/lib/components/common/modal/PagedContent.svelte @@ -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 + } - -{#key $tutorialsToDo} - - {#snippet buttonReplacement()} -
    {:else} -
    - -{#if !disableTutorials} - -{/if} diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index 4d8adb88e9..2cf1f28d54 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -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 @@ } -
    - - - {#if $open && active} -
    +
    + {#if trigger} + {@render trigger()} + {:else} + + {/if} +
    -

    {active.description}

    - -
      - {#each active.bullets as bullet (bullet)} -
    • - - {bullet} -
    • - {/each} -
    - - +
    + {/if} + + +
    + {#snippet rowBody(option: Option, ac: (typeof accentClasses)[string])} +
    + +
    + + {option.label} + + {#if option.badge} + + {option.badge.label} + + {/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} + + {#if $wacSubOpen} +
    + {#each option.variants ?? [] as variant (variant.label)} + {@const VariantIcon = variant.icon} + + {/each} +
    + {/if} + {:else} + + {/if} + {/each} + + +
    + + {#if $importSubOpen} +
    + {#each importActions as action, i (action.label)} + + {#if onImportHubProject && i === 0} +
    + {/if} + {/each}
    {/if} - -
    - {#snippet rowBody(option: Option, ac: (typeof accentClasses)[string])} -
    - -
    - - {option.label} - - {#if option.badge} - - {option.badge.label} - - {/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} - - {#if $wacSubOpen} -
    - {#each option.variants ?? [] as variant (variant.label)} - {@const VariantIcon = variant.icon} - - {/each} -
    - {/if} - {:else} - - {/if} - {/each} - - -
    + {#if !showDoc} - {#if $importSubOpen} -
    - {#each importActions as action (action.label)} - - {/each} -
    - {/if} - - {#if !showDoc} - - {/if} -
    + {/if}
    - {/if} -
    +
    +{/if} diff --git a/frontend/src/lib/components/home/HubProjectPickerModal.svelte b/frontend/src/lib/components/home/HubProjectPickerModal.svelte new file mode 100644 index 0000000000..6a353ea79d --- /dev/null +++ b/frontend/src/lib/components/home/HubProjectPickerModal.svelte @@ -0,0 +1,48 @@ + + + + + +
    + +
    +
    diff --git a/frontend/src/lib/components/home/HubTemplatePicker.svelte b/frontend/src/lib/components/home/HubTemplatePicker.svelte new file mode 100644 index 0000000000..422de9bb55 --- /dev/null +++ b/frontend/src/lib/components/home/HubTemplatePicker.svelte @@ -0,0 +1,151 @@ + + + +
    + +

    + Working projects from + + {hubHost} + + — imported as a folder in this workspace. +

    + +
    + + + {#snippet customRow({ item }: { item: HubProjectPick })} + {@const Icon = hubAppIcon(item.iconApps[0] ?? '')} + + + + + + {/snippet} + + {#snippet empty()} +

    + {#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} +

    + {/snippet} +
    +
    +
    diff --git a/frontend/src/lib/components/home/ImportProjectModal.svelte b/frontend/src/lib/components/home/ImportProjectModal.svelte new file mode 100644 index 0000000000..d9ac59d3b3 --- /dev/null +++ b/frontend/src/lib/components/home/ImportProjectModal.svelte @@ -0,0 +1,363 @@ + + +{#snippet importPage()} +
    + {#if project} + + + {/if} + + (folder = f)} + onFinish={() => (setup.needed ? (onSetupStep = true) : finish('none'))} + onBack={onClose} + onExecution={(e) => (execution = e)} + resume={execution} + /> +
    +{/snippet} + +{#snippet setupPlaceholder()} + +
    + +
    +{/snippet} + +{#snippet setupPage()} +
    + finish('skipped', outstanding)} + onFinish={(checked) => finish(checked ? 'filled' : 'unchecked')} + onBack={execution ? () => (onSetupStep = false) : undefined} + /> +
    +{/snippet} + + + + {#if slug} + + (onSetupStep = s === 2)} + /> + + { + 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} + diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 84432112a3..95f95bf6ce 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -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 { + // 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 | 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(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} -
    + +
    { @@ -1692,9 +1804,10 @@
    {/if} - {#if !loading && !contentActive} + {#if !loading && !contentActive && !workspaceEmpty} + view, expand/collapse (tree only), sort. Nothing to select, group or order on + an empty workspace, so the whole row goes. -->
    {#if homeSelection.available && !homeSelection.active}
    {/if} - {#if filteredItems?.length == 0} + {#if filteredItems?.length == 0 && !workspaceEmpty}
    {/if}
    @@ -1820,7 +1941,28 @@ - + {#if workspaceEmpty} + + {#if emptyStateAnswered} + {#if archivedProbe?.hasArchived || canCreateHere} + + (hubPick = project)} + onShowArchived={() => (filterValues.val = { ...filterValues.val, archived: true })} + /> + {:else} + + {/if} + {/if} + {:else} + + {/if} {#if hasMoreServer && !searching} @@ -1863,7 +2005,7 @@ /> {/key} {:else} -
    +
    {#if filter === ''} {#each [...visiblePipelineFolders].sort() as folder (folder)} {/if} + + (hubPickerOpen = false)} + onPick={(project) => { + hubPickerOpen = false + hubPick = project + }} +/> + (hubPick = undefined)} {onImported} /> + + diff --git a/frontend/src/lib/components/home/TutorialBanner.svelte b/frontend/src/lib/components/home/TutorialBanner.svelte deleted file mode 100644 index ef7b30c27e..0000000000 --- a/frontend/src/lib/components/home/TutorialBanner.svelte +++ /dev/null @@ -1,178 +0,0 @@ - - -{#if !isDismissed} - -
    - - {#if hasCompletedAny} - New tutorial available! - {:else} - First time? - {/if} - - - -
    -{/if} diff --git a/frontend/src/lib/components/home/TutorialButton.svelte b/frontend/src/lib/components/home/TutorialButton.svelte deleted file mode 100644 index 6a31ae9569..0000000000 --- a/frontend/src/lib/components/home/TutorialButton.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - - - diff --git a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte new file mode 100644 index 0000000000..fc36cc334b --- /dev/null +++ b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte @@ -0,0 +1,146 @@ + + +
    + {#each rowOpacities as opacity, i (i)} + + {/each} + + +
    + {#if archivedOnly} + + + Everything in this workspace is archived. + . + + {:else} + Your scripts, flows and apps will show up here. + {/if} + {#if canCreate} + + {#if !$disableHubStore} + + + e.detail && logFeatureUsage('home', 'template_picker_open', { key: 'empty_state' })} + > + {#snippet trigger()}Start from a template{/snippet} + {#snippet content({ close })} + { + close() + onPick(project) + }} + /> + {/snippet} + + or + {/if} + + {#snippet trigger()} + + . + {/snippet} + + {/if} +
    +
    diff --git a/frontend/src/lib/components/sidebar/OperatorMenu.svelte b/frontend/src/lib/components/sidebar/OperatorMenu.svelte index 0914068607..fa59d730df 100644 --- a/frontend/src/lib/components/sidebar/OperatorMenu.svelte +++ b/frontend/src/lib/components/sidebar/OperatorMenu.svelte @@ -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 @@ Account settings + + + + Take the tour +
    diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index fbc07a775f..b7a3a363bb 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -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, diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index fb1bb9ee17..7fa161dd2d 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -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([ diff --git a/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte b/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte deleted file mode 100644 index 343deb917c..0000000000 --- a/frontend/src/lib/components/tutorials/FlowBuilderLiveTutorial.svelte +++ /dev/null @@ -1,754 +0,0 @@ - - - { - 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, 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.

    💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

    ', - onNextClick: () => { - updateProgress(index) - driver.destroy() - }, - onPrevClick: () => { - sendUserToast('Previous is not available for this step', true, [], undefined, 3000) - } - } - } - ] - - return steps - }} -/> diff --git a/frontend/src/lib/components/tutorials/OperatorTour.svelte b/frontend/src/lib/components/tutorials/OperatorTour.svelte new file mode 100644 index 0000000000..cc7f4ab4a5 --- /dev/null +++ b/frontend/src/lib/components/tutorials/OperatorTour.svelte @@ -0,0 +1,81 @@ + + + { + const steps: DriveStep[] = [ + { + popover: { + title: 'Welcome to Windmill! 🎉', + description: + "Let's take a quick tour! We'll show you the three main tools you can use: Scripts, Flows, and Apps." + } + }, + { + popover: { + title: 'Scripts - Run automated tasks', + description: + 'Script Example

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

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

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

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

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

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

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

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

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

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

    ', + onNextClick: async () => { + // The step points into the menu, so it has to be open before the popover + // lands on it — and open is also where the entry to re-run the tour is. + const menuButton = document.querySelector('[role="menuitem"]') as HTMLElement | null + menuButton?.click() + await wait(MENU_OPEN_DELAY_MS) + driver.destroy() + } + }, + element: '[role="menuitem"]' + } + ] + + return steps + }} +/> diff --git a/frontend/src/lib/components/tutorials/RunsTutorial.svelte b/frontend/src/lib/components/tutorials/RunsTutorial.svelte deleted file mode 100644 index 0b19ac5f0e..0000000000 --- a/frontend/src/lib/components/tutorials/RunsTutorial.svelte +++ /dev/null @@ -1,510 +0,0 @@ - - - { - return getTutorialSteps(driver) - }} -/> diff --git a/frontend/src/lib/components/tutorials/SkipTutorials.svelte b/frontend/src/lib/components/tutorials/SkipTutorials.svelte deleted file mode 100644 index 6fd6ba254b..0000000000 --- a/frontend/src/lib/components/tutorials/SkipTutorials.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -
    - - -
    diff --git a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte b/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte deleted file mode 100644 index 4fa9909e02..0000000000 --- a/frontend/src/lib/components/tutorials/TroubleshootFlowTutorial.svelte +++ /dev/null @@ -1,441 +0,0 @@ - - - { - 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.

    💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

    ', - side: 'top', - onNextClick: () => { - if (!checkStepComplete(7)) return - updateProgress(index) - driver.destroy() - } - } - } - ] - - return steps - }} -/> diff --git a/frontend/src/lib/components/tutorials/Tutorial.svelte b/frontend/src/lib/components/tutorials/Tutorial.svelte index fc95141f5c..d4c99482a2 100644 --- a/frontend/src/lib/components/tutorials/Tutorial.svelte +++ b/frontend/src/lib/components/tutorials/Tutorial.svelte @@ -1,155 +1,101 @@ {#if tutorial} diff --git a/frontend/src/lib/components/tutorials/TutorialControls.svelte b/frontend/src/lib/components/tutorials/TutorialControls.svelte index 826e66d151..dd15cf4151 100644 --- a/frontend/src/lib/components/tutorials/TutorialControls.svelte +++ b/frontend/src/lib/components/tutorials/TutorialControls.svelte @@ -1,51 +1,39 @@
    {#if activeIndex === 0} -
  • UI is not interactive during tutorial, press next at every step
  • -
  • You can use the arrow keys to navigate
  • +
  • UI is not interactive during the tour, press next at every step
  • +
  • You can use the arrow keys to navigate
  • {/if}
    - {#if activeIndex !== undefined && totalSteps !== undefined} -
    - Step {activeIndex + 1} of {totalSteps} -
    - {/if} +
    + Step {activeIndex + 1} of {totalSteps} +
    -
    diff --git a/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte b/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte deleted file mode 100644 index 5084299a7e..0000000000 --- a/frontend/src/lib/components/tutorials/TutorialProgressBar.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - -
    -
    -
    - Progress: {completed} of {total} {label} completed -
    -
    {progressPercentage}%
    -
    -
    -
    -
    -
    - diff --git a/frontend/src/lib/components/tutorials/TutorialRouter.svelte b/frontend/src/lib/components/tutorials/TutorialRouter.svelte deleted file mode 100644 index 80c8938eff..0000000000 --- a/frontend/src/lib/components/tutorials/TutorialRouter.svelte +++ /dev/null @@ -1,64 +0,0 @@ - - -{#each tutorials as tutorial} - -{/each} - diff --git a/frontend/src/lib/components/tutorials/TutorialWrapper.svelte b/frontend/src/lib/components/tutorials/TutorialWrapper.svelte deleted file mode 100644 index 32b6fa212f..0000000000 --- a/frontend/src/lib/components/tutorials/TutorialWrapper.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - -{#if Component} - {@const Comp = Component} - -{/if} - diff --git a/frontend/src/lib/components/tutorials/app/BackgroundRunnablesTutorial.svelte b/frontend/src/lib/components/tutorials/app/BackgroundRunnablesTutorial.svelte deleted file mode 100644 index 65fa99ed46..0000000000 --- a/frontend/src/lib/components/tutorials/app/BackgroundRunnablesTutorial.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - - { - 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 - }} -/> diff --git a/frontend/src/lib/components/tutorials/app/ConnectionTutorial.svelte b/frontend/src/lib/components/tutorials/app/ConnectionTutorial.svelte deleted file mode 100644 index 9ae02af92a..0000000000 --- a/frontend/src/lib/components/tutorials/app/ConnectionTutorial.svelte +++ /dev/null @@ -1,128 +0,0 @@ - - - [ - { - 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() - }) - } - } - } - ]} -/> diff --git a/frontend/src/lib/components/tutorials/app/ExpressionEvaluationTutorial.svelte b/frontend/src/lib/components/tutorials/app/ExpressionEvaluationTutorial.svelte deleted file mode 100644 index 1e96e8b121..0000000000 --- a/frontend/src/lib/components/tutorials/app/ExpressionEvaluationTutorial.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - [ - { - 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' - } - } - ]} -/> diff --git a/frontend/src/lib/components/tutorials/ignoredTutorials.ts b/frontend/src/lib/components/tutorials/ignoredTutorials.ts deleted file mode 100644 index 7a120b5e0c..0000000000 --- a/frontend/src/lib/components/tutorials/ignoredTutorials.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { writable } from 'svelte/store' - -export const ignoredTutorials = writable([]) diff --git a/frontend/src/lib/components/tutorials/operatorTour.ts b/frontend/src/lib/components/tutorials/operatorTour.ts new file mode 100644 index 0000000000..5c4269e84d --- /dev/null +++ b/frontend/src/lib/components/tutorials/operatorTour.ts @@ -0,0 +1,47 @@ +import { UserService } from '$lib/gen' + +/** + * The tour's slot in the `tutorial_progress` bitmask. Slot 6 is reserved for it across + * versions: an operator who has already been through the tour must not meet it again, and + * a slot that another tutorial writes would read as finished on day one. + */ +const OPERATOR_TOUR_BIT = 6 + +/** URL parameter the sidebar entry uses to ask the home page for a run. */ +export const TOUR_PARAM = 'tour' +export const TOUR_PARAM_VALUE = 'operator' + +/** Long enough for the home page's tabs to exist before the first step points at one. */ +export const TOUR_START_DELAY_MS = 500 +/** Time for the sidebar to open before the last step points into it. */ +export const MENU_OPEN_DELAY_MS = 300 + +export async function hasSeenOperatorTour(): Promise { + // A failure answers "seen": the tour interrupts the page, and interrupting someone who + // has already been through it is worse than never offering it, which the sidebar entry + // covers anyway. + try { + const progress = (await UserService.getTutorialProgress()).progress ?? 0 + return (progress & (1 << OPERATOR_TOUR_BIT)) !== 0 + } catch (error) { + console.error('Could not read tutorial progress:', error) + return true + } +} + +export async function markOperatorTourSeen(): Promise { + try { + // Read-modify-write, because the row is shared: it carries every slot's state, and a + // write of this bit alone would clear the rest. `skipped_all` rides along for the same + // reason — and the handler rejects a body without it, whatever the generated type says. + const current = await UserService.getTutorialProgress() + await UserService.updateTutorialProgress({ + requestBody: { + progress: (current.progress ?? 0) | (1 << OPERATOR_TOUR_BIT), + skipped_all: current.skipped_all ?? false + } + }) + } catch (error) { + console.error('Could not record tutorial progress:', error) + } +} diff --git a/frontend/src/lib/components/tutorials/utils.ts b/frontend/src/lib/components/tutorials/utils.ts deleted file mode 100644 index 083e9712dd..0000000000 --- a/frontend/src/lib/components/tutorials/utils.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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) -} diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingOperatorTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingOperatorTutorial.svelte deleted file mode 100644 index 4251a67170..0000000000 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingOperatorTutorial.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - - { - 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: - 'Script Example

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

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

    ', - 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: - 'Flow

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

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

    ', - 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: - 'App

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

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

    ', - 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.

    💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu.

    ', - 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 - }} -/> diff --git a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte b/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte deleted file mode 100644 index 30307463a1..0000000000 --- a/frontend/src/lib/components/tutorials/workspace/WorkspaceOnboardingTutorial.svelte +++ /dev/null @@ -1,95 +0,0 @@ - - - { - 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: - 'Programming Languages

    Open the New 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.

    ', - onNextClick: () => { - driver.moveNext() - } - }, - element: '#create-new-button' - }, - { - popover: { - title: 'Create your first flow', - description: - 'Flow

    The same New menu lets you create a flow. Flows orchestrate multiple scripts. Chain them together with branching, loops, and error handling to build complex workflows.

    ', - onNextClick: () => { - driver.moveNext() - } - }, - element: '#create-new-button' - }, - { - popover: { - title: 'Create your first app', - description: - 'App

    And from the New 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!

    💡 Want to learn more? Access more tutorials from the Tutorials page in the main menu or in the Help submenu.

    ', - 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 - }} -/> diff --git a/frontend/src/lib/components/workspaceSettings/SimpleCreateWorkspace.svelte b/frontend/src/lib/components/workspaceSettings/SimpleCreateWorkspace.svelte new file mode 100644 index 0000000000..67059c2fb8 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/SimpleCreateWorkspace.svelte @@ -0,0 +1,237 @@ + + +{#if creating} +
    + + Creating {name.trim()}… +
    +{:else if advanced} + + + {#if leading} +
    {@render leading()}
    + {/if} +{:else} +
    + Workspace name + (nameEdited = true), + onkeydown: (e) => e.key === 'Enter' && create() + }} + /> + {#if problem && name.trim()} + {problem} + {/if} + {#if policyFailed} + + This instance's settings could not be read, so a workspace cannot be created yet. + + + {/if} + +
    +
    + {@render leading?.()} + + + +
    + +
    +
    +{/if} diff --git a/frontend/src/lib/hubProject.test.ts b/frontend/src/lib/hubProject.test.ts new file mode 100644 index 0000000000..ca1891110d --- /dev/null +++ b/frontend/src/lib/hubProject.test.ts @@ -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('') + }) +}) diff --git a/frontend/src/lib/hubProject.ts b/frontend/src/lib/hubProject.ts index b58b338f7d..be108b2136 100644 --- a/frontend/src/lib/hubProject.ts +++ b/frontend/src/lib/hubProject.ts @@ -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 = { 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 /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 } | 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 { + 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 { + 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)) +} diff --git a/frontend/src/lib/importWizard/abandon.test.ts b/frontend/src/lib/importWizard/abandon.test.ts index abf0ff61b9..d240c8147d 100644 --- a/frontend/src/lib/importWizard/abandon.test.ts +++ b/frontend/src/lib/importWizard/abandon.test.ts @@ -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 — diff --git a/frontend/src/lib/importWizard/execution.svelte.ts b/frontend/src/lib/importWizard/execution.svelte.ts index 6a2c39b0b6..12952525b5 100644 --- a/frontend/src/lib/importWizard/execution.svelte.ts +++ b/frontend/src/lib/importWizard/execution.svelte.ts @@ -229,6 +229,29 @@ export class ImportExecution { */ async run(): Promise { 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 { + return this.#idle + } + #idle: Promise = Promise.resolve() + + async #runInternal(): Promise { this.#abandoned = false this.running = true runState.active = true diff --git a/frontend/src/lib/importWizard/setupStep.svelte.ts b/frontend/src/lib/importWizard/setupStep.svelte.ts new file mode 100644 index 0000000000..17f63cbd4f --- /dev/null +++ b/frontend/src/lib/importWizard/setupStep.svelte.ts @@ -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 + } + } +} diff --git a/frontend/src/lib/refreshUser.ts b/frontend/src/lib/refreshUser.ts index de20fa5d56..4c6b47e8f3 100644 --- a/frontend/src/lib/refreshUser.ts +++ b/frontend/src/lib/refreshUser.ts @@ -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 | null = null -async function _refreshSuperadmin(): Promise { - 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 { + 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, { diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index ed5bc6f025..229ecff7ad 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -68,8 +68,6 @@ export function clearWorkspaceFromStorage() { sessionStorage.removeItem('workspace') } -export const tutorialsToDo = writable([]) -export const skippedAll = writable(false) export const globalEmailInvite = writable('') export const awarenessStore = writable>(undefined) export const enterpriseLicense = writable(undefined) @@ -120,6 +118,10 @@ export const superadmin = writable(undefined) export const devopsRole = writable(undefined) export const lspTokenStore = writable(undefined) export const hubBaseUrlStore = writable(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(false) export const wsBaseUrlStore = writable(undefined) export const disableHubStore = writable(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 = derived( } ) -export const isCurrentlyInTutorial: StateStore = createState({ val: false }) - export function getFlatTableNamesFromSchema(dbSchema: DBSchema | undefined): string[] { const schema = dbSchema?.schema ?? {} const tableNames: string[] = [] diff --git a/frontend/src/lib/tutorialUtils.ts b/frontend/src/lib/tutorialUtils.ts deleted file mode 100644 index 02222138e8..0000000000 --- a/frontend/src/lib/tutorialUtils.ts +++ /dev/null @@ -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): 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, - 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 -} diff --git a/frontend/src/lib/tutorials/config.ts b/frontend/src/lib/tutorials/config.ts deleted file mode 100644 index 082dc0473d..0000000000 --- a/frontend/src/lib/tutorials/config.ts +++ /dev/null @@ -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 = { - 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 - diff --git a/frontend/src/lib/tutorials/roleUtils.ts b/frontend/src/lib/tutorials/roleUtils.ts deleted file mode 100644 index a727fca8d3..0000000000 --- a/frontend/src/lib/tutorials/roleUtils.ts +++ /dev/null @@ -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) -} - diff --git a/frontend/src/lib/utils/featureUsage.test.ts b/frontend/src/lib/utils/featureUsage.test.ts index ef60cd2b36..1857efb0c0 100644 --- a/frontend/src/lib/utils/featureUsage.test.ts +++ b/frontend/src/lib/utils/featureUsage.test.ts @@ -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 = (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') + } + }) +}) diff --git a/frontend/src/lib/utils/featureUsage.ts b/frontend/src/lib/utils/featureUsage.ts index 793d137593..31b33153cf 100644 --- a/frontend/src/lib/utils/featureUsage.ts +++ b/frontend/src/lib/utils/featureUsage.ts @@ -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) +} diff --git a/frontend/src/lib/workspaceCreation.test.ts b/frontend/src/lib/workspaceCreation.test.ts new file mode 100644 index 0000000000..975d1599b5 --- /dev/null +++ b/frontend/src/lib/workspaceCreation.test.ts @@ -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 }) + }) +}) diff --git a/frontend/src/lib/workspaceCreation.ts b/frontend/src/lib/workspaceCreation.ts index 49e4bdbea3..e4a9045f42 100644 --- a/frontend/src/lib/workspaceCreation.ts +++ b/frontend/src/lib/workspaceCreation.ts @@ -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 { + // 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 { 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 { 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 +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index e82d328c76..5aa90b80a2 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -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 @@