diff --git a/AGENTS.md b/AGENTS.md index 2b204a65db..f8c83ec465 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill. - **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead. - **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy and `xhigh` reasoning, on `gpt-6-astra` rather than the action's `gpt-5.6-sol`; requires the `codex` CLI >= 0.153.4. -- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc` +- **Domain guides**: `.claude/skills/native-trigger/` - **Brand/UI guidelines**: `frontend/brand-guidelines.md` - **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does. - **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags. diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 8340c8d90e..8f428844ad 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -e092518ee60e33160fee9ae91a4d109566f7b0ee +81edd1382d951265ab3e9b67fc7ca7967676fd56 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8a61f61cc1..a0e05aa54a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -25793,6 +25793,30 @@ paths: schema: type: string + /w/{workspace}/hub/projects: + get: + summary: list the hub's published projects + description: | + Forwards to the configured Hub's public project catalogue and returns its + status code and raw response body. Readable by any workspace member: the + listing is not workspace-scoped, and it is proxied only because the Hub's + listing endpoint sends no CORS header. Refused with 400 when the instance + has the Hub disabled, in which case no outbound request is made. + operationId: listHubProjects + tags: + - hubPublish + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: raw Hub response body (status code is passed through from the Hub) + content: + text/plain: + schema: + type: string + "400": + description: the Hub is disabled on this instance + /w/{workspace}/hub/project: get: summary: get the hub project linked to a workspace folder diff --git a/backend/windmill-api/src/hub_publish.rs b/backend/windmill-api/src/hub_publish.rs index ccb530ce20..633ad88a34 100644 --- a/backend/windmill-api/src/hub_publish.rs +++ b/backend/windmill-api/src/hub_publish.rs @@ -6,13 +6,14 @@ use axum::{ http::{request::Parts, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, - Router, + Extension, Router, }; use serde::{Deserialize, Deserializer, Serialize}; use windmill_common::{ error::{to_anyhow, Error}, + global_settings::{load_value_from_global_settings, DISABLE_HUB_SETTING}, utils::require_admin, - HUB_BASE_URL, + DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, }; pub fn workspaced_service() -> Router { @@ -48,6 +49,7 @@ pub fn workspaced_service() -> Router { post(discard_project_update), ) .route("/project", get(get_project_by_source)) + .route("/projects", get(list_projects)) } #[derive(Deserialize)] @@ -548,6 +550,84 @@ async fn get_project_by_source(ctx: HubPublishCtx) -> Result bool { + fn host_of(url: &str) -> Option { + let parsed = url::Url::parse(url.trim()).ok()?; + if !matches!(parsed.scheme(), "http" | "https") { + return None; + } + Some( + parsed + .host_str()? + .trim_end_matches('.') + .to_ascii_lowercase(), + ) + } + match (host_of(hub), host_of(DEFAULT_HUB_BASE_URL)) { + (Some(host), Some(default_host)) => host == default_host, + _ => false, + } +} + +// The hub's project catalogue. Read by any workspace member rather than through +// `HubPublishCtx`, which requires an admin: nothing here is workspace-scoped or +// publishing-related. It exists at all because the hub's listing endpoint sends no +// CORS header, so the browser cannot read it directly the way it reads a single +// project. `accept: application/json` is what makes the hub answer with JSON. +// +// The caller's token is sent only to a hub this instance was pointed at deliberately. +// Every other route here is admin-only; this one is not, so forwarding a member's +// bearer token to `hub.windmill.dev` would put a credential replayable against this +// instance on a host outside it — for a listing that needs no credential at all. +async fn list_projects( + _authed: ApiAuthed, + Extension(db): Extension, + Tokened { token }: Tokened, +) -> Result { + // `disable_hub` turns the hub off for a closed instance, and this handler makes an + // outbound request. The frontend hides its entry points on the same setting, but that + // is presentation: an authenticated member can call this route directly, so the refusal + // has to live here. + let disabled = load_value_from_global_settings(&db, DISABLE_HUB_SETTING) + .await? + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if disabled { + return Err(Error::BadRequest( + "The hub is disabled on this instance".to_string(), + )); + } + + let hub = (**HUB_BASE_URL.load()).clone(); + let url = format!("{}/projects", hub); + let mut req = HTTP_CLIENT.get(&url).header("accept", "application/json"); + if !is_public_hub(&hub) { + req = req.bearer_auth(&token); + } + let res = req + .send() + .await + .map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?; + + let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let text = res + .text() + .await + .map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?; + + Ok((status, text)) +} + async fn submit_project( ctx: HubPublishCtx, Path((_workspace, slug)): Path<(String, ProjectSlug)>, @@ -645,3 +725,42 @@ async fn forward_to_hub( Ok((status, text)) } + +#[cfg(test)] +mod tests { + use super::is_public_hub; + + #[test] + fn public_hub_recognized_in_every_spelling() { + // The predicate decides whether a workspace member's bearer token leaves the + // instance, so both directions matter: a miss on the public hub sends the token + // to windmill.dev, and a false match withholds it from a private hub that needs it. + // Every spelling here is one `hub_base_url` can hold and `reqwest` will still send. + for hub in [ + "https://hub.windmill.dev", + "http://hub.windmill.dev/", + "HTTPS://hub.windmill.dev", + "https://HUB.WINDMILL.DEV", + "https://hub.windmill.dev:443", + "https://hub.windmill.dev.", + "https://hub.windmill.dev/some/path", + " https://hub.windmill.dev ", + ] { + assert!(is_public_hub(hub), "{hub} should be the public hub"); + } + for hub in [ + "https://hub.internal.example", + "https://hub.windmill.dev.evil.example", + "https://windmill.dev", + // The host is what the request goes to, whatever precedes the `@`. + "https://hub.windmill.dev@hub.internal.example", + // Unparseable, or not a scheme a request can be built from. Grouped with the + // private hubs because the caller then attaches the token, which is harmless here: + // `reqwest` rejects the same value before opening a connection. + "hub.windmill.dev", + "ftp://hub.windmill.dev", + ] { + assert!(!is_public_hub(hub), "{hub} should not be the public hub"); + } + } +} diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index f5ce2357ca..9b4340e065 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,10 +4,10 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 42 registered actions across seventeen features (`ai_session`, `ai_chat`, +It currently carries 48 registered actions across eighteen features (`ai_session`, `ai_chat`, `ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`, -`flow_step`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`, -`sso_groups_claim`). Nearly all of the +`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, +`usage_meter`, `sso_groups_claim`). Nearly all of the product is uninstrumented, so new user-facing work is the opportunity to change that. ## When to instrument diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ace58dbb23..324ad8c529 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1755,7 +1755,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1772,7 +1771,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1789,7 +1787,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1806,7 +1803,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1823,7 +1819,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1840,7 +1835,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1857,7 +1851,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1874,7 +1867,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1891,7 +1883,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1908,7 +1899,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1925,7 +1915,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1942,7 +1931,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1959,7 +1947,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1976,7 +1963,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5579,9 +5565,9 @@ } }, "node_modules/driver.js": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.3.6.tgz", - "integrity": "sha512-g2nNuu+tWmPpuoyk3ffpT9vKhjPz4NrJzq6mkRDZIwXCrFhrKdDJ9TX5tJOBpvCTBrBYjgRQ17XlcQB15q4gMg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz", + "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==", "license": "MIT" }, "node_modules/dts-bundle-generator": { @@ -7583,7 +7569,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -8279,7 +8265,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8300,7 +8285,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8321,7 +8305,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8342,7 +8325,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8363,7 +8345,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8384,7 +8365,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8405,7 +8385,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8426,7 +8405,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8447,7 +8425,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8468,7 +8445,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8489,7 +8465,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13195,21 +13170,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -13989,7 +13949,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/src/lib/components/AppTutorials.svelte b/frontend/src/lib/components/AppTutorials.svelte deleted file mode 100644 index 25d06e0167..0000000000 --- a/frontend/src/lib/components/AppTutorials.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index c4674d7313..1bf9e7fde3 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -1245,7 +1245,6 @@ { - const remaining = [ - getTutorialIndex('flow-live-tutorial'), - getTutorialIndex('troubleshoot-flow') - ].filter((i) => $tutorialsToDo.includes(i)).length - return remaining > 0 - ? createRawSnippet(() => ({ - render: () => - `${remaining}` - })) - : undefined - })(), - submenuItems: [ - { - displayName: 'Build a flow', - action: () => flowTutorials?.runTutorialById('flow-live-tutorial'), - icon: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) - ? Circle - : CheckCircle, - iconColor: $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) - ? undefined - : 'green' - }, - { - displayName: 'Fix a broken flow', - action: () => flowTutorials?.runTutorialById('troubleshoot-flow'), - icon: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow')) - ? Circle - : CheckCircle, - iconColor: $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow')) - ? undefined - : 'green' - }, - { - displayName: 'Reset tutorials', - action: () => resetAllTodos(), - icon: RefreshCw, - separatorTop: true - }, - { - displayName: 'Skip tutorials', - action: () => skipAllTodos(), - icon: CheckCheck - } - ] - }, { displayName: 'Test flow & record', icon: Disc, @@ -1515,14 +1438,7 @@ {#if $enterpriseLicense && !newFlow && !inSessionPane} {/if} -
- - {#if $tutorialsToDo.includes(getTutorialIndex('flow-live-tutorial')) || $tutorialsToDo.includes(getTutorialIndex('troubleshoot-flow'))} - - {/if} -
+ {#if diffEnabled && !diffInMenu} -
- -
+ {#if showCounts} +
+ +
+ {/if} diff --git a/frontend/src/lib/components/ImportProjectStep.svelte b/frontend/src/lib/components/ImportProjectStep.svelte index 04c169d41c..f609aff6a3 100644 --- a/frontend/src/lib/components/ImportProjectStep.svelte +++ b/frontend/src/lib/components/ImportProjectStep.svelte @@ -27,6 +27,27 @@ /** From the hub, for the counts — the export is only fetched during the run. */ project?: ImportProjectSummary onFolderChange: (folder: string) => void + /** + * Whether to ask which folder the project lands in. Off where the destination was + * not chosen either — importing into the workspace you are already in is one + * decision, and `f/` is the answer nobody needs to be asked for. + */ + chooseFolder?: boolean + /** + * Whether to spell out what import does to resources and triggers. It is about landing + * on top of what a workspace already holds — a resource it will not overwrite, a + * trigger it re-creates disabled — so a destination with nothing in it has nothing to + * warn about, and the setup step that follows is where the values get filled in. + */ + showNotes?: boolean + /** + * Fill the height given rather than hugging the content, with the actions pinned to the + * bottom. For a surface of a fixed size — a paged dialog, whose height is the taller + * page — where content-height buttons would float mid-panel. `sticky` as well as + * `mt-auto`: a page taller than the box scrolls, and a row that only sat at the end of + * the content would scroll out of reach with it. + */ + fillHeight?: boolean onFinish: () => void /** True once the run reveals data tables the destination has yet to configure. */ setupPending?: boolean @@ -51,6 +72,9 @@ onFolderChange, onFinish, onBack, + chooseFolder = true, + showNotes = true, + fillHeight = false, setupPending = false, setupUndecided = false, onExecution, @@ -312,12 +336,12 @@ } -
+
- {#if existingWorkspace} + {#if existingWorkspace && chooseFolder}
- - Resources are imported as empty stubs — set their values after import; one whose path is - already in the workspace is left exactly as it is and reported as already there, so a value - you have since filled in is never overwritten. Trigger kinds are - recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at creation - and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP and Azure - triggers all require Enterprise. Triggers that reference a resource depend on stubs imported - empty, so fill in the resource value before re-enabling the trigger. - + {#if showNotes} + + + Resources are imported as empty stubs — set their values after import; one whose path is + already in the workspace is left exactly as it is and reported as already there, so a value + you have since filled in is never overwritten. Trigger kinds are recreated disabled, except + GCP and Azure triggers, which manage cloud subscriptions at creation and must be re-created + manually after filling their resource. Kafka, NATS, SQS, GCP and Azure triggers all require + Enterprise. Triggers that reference a resource depend on stubs imported empty, so fill in the + resource value before re-enabling the trigger. + + {/if} -
+
{#if !execution?.done} diff --git a/frontend/src/lib/components/ImportSetupStep.svelte b/frontend/src/lib/components/ImportSetupStep.svelte index 94691f86d7..51eaf725f8 100644 --- a/frontend/src/lib/components/ImportSetupStep.svelte +++ b/frontend/src/lib/components/ImportSetupStep.svelte @@ -48,12 +48,31 @@ * own slug and `installProject` retargets them, so reading the raw paths here would * look for stubs that are not where they landed. */ folder?: string - onSkip: () => void - onFinish: () => void + /** Left with `outstanding` rows still unfilled, which the caller may want to count. */ + onSkip: (outstanding: number) => void + /** Off where the surface already names the step, e.g. a dialog whose title is it. */ + showHeading?: boolean + /** Fill the height given, actions pinned to the bottom. See ImportProjectStep. */ + fillHeight?: boolean + /** + * Finished. `checked` is false where the export could not be read: the step then has no + * idea what is outstanding, so it offers Finish rather than blocking — and a caller + * counting outcomes must not read that as a step that came out clean. + */ + onFinish: (checked: boolean) => void onBack?: () => void } - let { workspace, slug, folder, onSkip, onFinish, onBack }: Props = $props() + let { + workspace, + slug, + folder, + onSkip, + onFinish, + onBack, + showHeading = true, + fillHeight = false + }: Props = $props() type Row = { name: string @@ -710,7 +729,7 @@ }) if (!confirmed) return } - onSkip() + onSkip(outstanding) } /** @@ -736,9 +755,11 @@ } -
+
-

Finish setting up

+ {#if showHeading} +

Finish setting up

+ {/if}

@@ -780,7 +801,7 @@ {#if row.status === 'done'} {:else if row.status === 'running'} - + {:else if row.status === 'failed'} {:else if row.status === 'unknown'} @@ -982,38 +1003,16 @@

{/if} - + {#if outstanding === 0} Everything this project needs is configured. Finish, and it is ready to run. - {:else if pendingTables.length > 0} - 0 - ? 'The project will not run without this' - : 'This could not be checked'} - size="xs" - > - {#if missingTables.length > 0} - The tables {missingTables.length === 1 - ? 'this data table holds' - : 'these data tables hold'} - do not exist, and the project's apps and flows read them. Every one of those fails as soon - as it opens. - {/if} - {#if uncheckedTables.length > 0} - {#if missingTables.length > 0}

{/if} - {uncheckedTables.length === 1 ? 'One data table is' : 'Some data tables are'} set up, but - {uncheckedTables.length === 1 ? 'its' : 'their'} schema could not be read, so whether the project's - tables are there is unknown. Check again once the database is reachable. - {/if} -
- {:else} + {:else if pendingTables.length === 0} The project's apps and flows will fail wherever they read a credential that is still missing. Everything else it imported works either way, and you can fill these in from the @@ -1022,7 +1021,11 @@ {/if} {/if} -
+
{#if onBack} 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 @@