diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts index 5fa9891f7..f77b02992 100644 --- a/src/components/Learn/tours/registry.ts +++ b/src/components/Learn/tours/registry.ts @@ -18,7 +18,12 @@ export type TourStep = StepType & { | "navigate-to-root" | "unpack-subgraph" | "multi-select-tasks" - | "create-subgraph"; + | "create-subgraph" + | "open-secret-dialog" + | "open-settings-panel" + | "open-submit-dialog" + | "assign-secret-argument" + | "assign-secret-submit"; targetWindowId?: string; targetWindowName?: string; targetFolderName?: string; @@ -34,6 +39,7 @@ export type TourStep = StepType & { targetPortName?: string; }; ringSelectors?: string[]; + tourPanel?: "secrets-manager"; resetLibrarySearch?: boolean; ensureWindowRestored?: string; requiresTaskSelected?: string; @@ -45,6 +51,12 @@ export interface TourDefinition { displayName?: string; requiresEditor?: boolean; starterPipelineUrl?: string; + /** + * When true, a tour running without a real backend mocks the secrets backend + * in-memory so its backend-dependent steps stay hands-on (see + * tourMockBackend). With a real backend this has no effect. + */ + mockBackend?: boolean; steps: TourStep[]; } diff --git a/src/components/shared/SecretsManagement/SelectSecretDialog.tsx b/src/components/shared/SecretsManagement/SelectSecretDialog.tsx index dc4bcb2a5..db4223c34 100644 --- a/src/components/shared/SecretsManagement/SelectSecretDialog.tsx +++ b/src/components/shared/SecretsManagement/SelectSecretDialog.tsx @@ -16,8 +16,11 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { Text } from "@/components/ui/typography"; +import { useBackend } from "@/providers/BackendProvider"; +import { useTourMockBackend } from "@/providers/TourProvider/tourMockBackend"; import { AddSecretForm } from "./components/AddSecretForm"; +import { SecretsBackendUnavailable } from "./components/SecretsBackendUnavailable"; import { fetchSecretsList } from "./secretsStorage"; import { type Secret, SecretsQueryKeys } from "./types"; @@ -189,6 +192,8 @@ export function SelectSecretDialog({ onOpenChange, onSelect, }: SelectSecretDialogProps) { + const { available } = useBackend(); + const mockBackend = useTourMockBackend(); const handleSelect = (secretName: string) => { onSelect(secretName); onOpenChange(false); @@ -198,7 +203,11 @@ export function SelectSecretDialog({ {open && ( - + {available || mockBackend ? ( + + ) : ( + + )} )} diff --git a/src/components/shared/SecretsManagement/components/SecretsBackendUnavailable.tsx b/src/components/shared/SecretsManagement/components/SecretsBackendUnavailable.tsx new file mode 100644 index 000000000..de1016c31 --- /dev/null +++ b/src/components/shared/SecretsManagement/components/SecretsBackendUnavailable.tsx @@ -0,0 +1,25 @@ +import { Icon } from "@/components/ui/icon"; +import { BlockStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; + +/** + * Shown in place of the secrets list/picker when no backend is available. + * Secrets live on the Tangle backend, so without one there is nothing to list + * and a friendly explanation is clearer than the generic error fallback. + */ +export function SecretsBackendUnavailable() { + return ( + + + Backend not connected + + Secrets are stored on your Tangle backend. Connect one in Settings to + manage secrets. + + + ); +} diff --git a/src/components/shared/SecretsManagement/components/SecretsList.tsx b/src/components/shared/SecretsManagement/components/SecretsList.tsx index 8f138b204..a11991290 100644 --- a/src/components/shared/SecretsManagement/components/SecretsList.tsx +++ b/src/components/shared/SecretsManagement/components/SecretsList.tsx @@ -6,18 +6,25 @@ import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Skeleton } from "@/components/ui/skeleton"; import { Text } from "@/components/ui/typography"; +import { useBackend } from "@/providers/BackendProvider"; +import { useTourMockBackend } from "@/providers/TourProvider/tourMockBackend"; import { formatRelativeTime } from "@/utils/date"; import { withSuspenseWrapper } from "../../SuspenseWrapper"; import { fetchSecretsList } from "../secretsStorage"; -import { SecretsQueryKeys } from "../types"; +import { type Secret, SecretsQueryKeys } from "../types"; import { RemoveSecretButton } from "./RemoveSecretButton"; +import { SecretsBackendUnavailable } from "./SecretsBackendUnavailable"; interface SecretsListProps { onRemoveSuccess?: () => void; + onEditSecret?: (secret: Secret) => void; } -function SecretsListInternal({ onRemoveSuccess }: SecretsListProps) { +function SecretsListInternal({ + onRemoveSuccess, + onEditSecret, +}: SecretsListProps) { const { data: secrets } = useSuspenseQuery({ queryKey: SecretsQueryKeys.All(), queryFn: fetchSecretsList, @@ -67,16 +74,27 @@ function SecretsListInternal({ onRemoveSuccess }: SecretsListProps) { - - - + ) : ( + + + + )} @@ -96,7 +114,16 @@ function SecretsListSkeleton() { ); } -export const SecretsList = withSuspenseWrapper( +const SecretsListWithSuspense = withSuspenseWrapper( SecretsListInternal, SecretsListSkeleton, ); + +export function SecretsList(props: SecretsListProps) { + const { available } = useBackend(); + const mockBackend = useTourMockBackend(); + if (!available && !mockBackend) { + return ; + } + return ; +} diff --git a/src/components/shared/SecretsManagement/components/SecretsListView.tsx b/src/components/shared/SecretsManagement/components/SecretsListView.tsx index 9f8448407..ebe3665c4 100644 --- a/src/components/shared/SecretsManagement/components/SecretsListView.tsx +++ b/src/components/shared/SecretsManagement/components/SecretsListView.tsx @@ -9,9 +9,18 @@ import useToastNotification from "@/hooks/useToastNotification"; import { useAnalytics } from "@/providers/AnalyticsProvider"; import { tracking } from "@/utils/tracking"; +import type { Secret } from "../types"; import { SecretsList } from "./SecretsList"; -export function SecretsListView() { +interface SecretsListViewProps { + onAddSecret?: () => void; + onEditSecret?: (secret: Secret) => void; +} + +export function SecretsListView({ + onAddSecret, + onEditSecret, +}: SecretsListViewProps = {}) { const notify = useToastNotification(); const { track } = useAnalytics(); @@ -32,22 +41,36 @@ export function SecretsListView() { - + - - - + ) : ( + + + + )} ); diff --git a/src/components/shared/SecretsManagement/secretsStorage.ts b/src/components/shared/SecretsManagement/secretsStorage.ts index 60bf25f29..484283874 100644 --- a/src/components/shared/SecretsManagement/secretsStorage.ts +++ b/src/components/shared/SecretsManagement/secretsStorage.ts @@ -4,6 +4,13 @@ import { listSecretsApiSecretsGet, updateSecretApiSecretsSecretNamePut, } from "@/api/sdk.gen"; +import { + isTourMockActive, + mockAddSecret, + mockListSecrets, + mockRemoveSecret, + mockUpdateSecret, +} from "@/providers/TourProvider/tourMockBackend"; import type { Secret } from "./types"; @@ -33,6 +40,10 @@ function parseAsUtc(dateString: string): Date { } export async function fetchSecretsList() { + if (isTourMockActive()) { + return mockListSecrets(); + } + const response = await listSecretsApiSecretsGet(); if (response.response.status !== 200) { @@ -60,6 +71,11 @@ export async function updateSecret( secretId: string, secret: Partial & Pick, ) { + if (isTourMockActive()) { + mockUpdateSecret(secretId, secret.value); + return true; + } + const response = await updateSecretApiSecretsSecretNamePut({ path: { secret_name: secretId, @@ -79,6 +95,11 @@ export async function updateSecret( export async function addSecret( secret: Partial & Pick, ) { + if (isTourMockActive()) { + mockAddSecret(secret.name ?? "", secret.value); + return true; + } + const response = await createSecretApiSecretsPost({ query: { secret_name: secret.name ?? "", @@ -96,6 +117,11 @@ export async function addSecret( } export async function removeSecret(secretId: string) { + if (isTourMockActive()) { + mockRemoveSecret(secretId); + return true; + } + const response = await deleteSecretApiSecretsSecretNameDelete({ path: { secret_name: secretId, diff --git a/src/components/shared/Submitters/Tangle/TangleSubmitter.tsx b/src/components/shared/Submitters/Tangle/TangleSubmitter.tsx index afb94994b..93c523d3b 100644 --- a/src/components/shared/Submitters/Tangle/TangleSubmitter.tsx +++ b/src/components/shared/Submitters/Tangle/TangleSubmitter.tsx @@ -12,6 +12,7 @@ import useCooldownTimer from "@/hooks/useCooldownTimer"; import useToastNotification from "@/hooks/useToastNotification"; import { cn } from "@/lib/utils"; import { useBackend } from "@/providers/BackendProvider"; +import { useTourMockBackend } from "@/providers/TourProvider/tourMockBackend"; import { APP_ROUTES } from "@/routes/router"; import { updateRunAnnotation } from "@/services/pipelineRunService"; import type { PipelineRun } from "@/types/pipelineRun"; @@ -102,6 +103,10 @@ const TangleSubmitter = ({ }: TangleSubmitterProps) => { const { isAuthorized } = useAwaitAuthorization(); const { backendUrl, configured, available } = useBackend(); + // During a no-backend tour the secrets flow is mocked in-memory, so allow the + // run-arguments dialog to open (to assign a secret to an input). Real run + // submission stays gated on `available`. + const mockBackend = useTourMockBackend(); const { mutate: submit, isPending: isSubmitting } = useSubmitPipeline(); const isAutoRedirect = useFlagValue("redirect-on-new-pipeline-run"); @@ -310,7 +315,7 @@ const TangleSubmitter = ({ size="icon" data-testid="run-with-arguments-button" onClick={() => setIsArgumentsDialogOpen(true)} - disabled={!available} + disabled={!available && !mockBackend} > diff --git a/src/components/shared/Submitters/Tangle/components/SubmitTaskArgumentsDialog.tsx b/src/components/shared/Submitters/Tangle/components/SubmitTaskArgumentsDialog.tsx index ee90e8dec..9a3f183f1 100644 --- a/src/components/shared/Submitters/Tangle/components/SubmitTaskArgumentsDialog.tsx +++ b/src/components/shared/Submitters/Tangle/components/SubmitTaskArgumentsDialog.tsx @@ -36,6 +36,8 @@ import { Paragraph } from "@/components/ui/typography"; import useToastNotification from "@/hooks/useToastNotification"; import { cn } from "@/lib/utils"; import { useBackend } from "@/providers/BackendProvider"; +import { useTourMockBackend } from "@/providers/TourProvider/tourMockBackend"; +import { useTourMode } from "@/providers/TourProvider/TourModeContext"; import { fetchExecutionDetails, fetchPipelineRun, @@ -66,6 +68,10 @@ export const SubmitTaskArgumentsDialog = ({ componentSpec, }: SubmitTaskArgumentsDialogProps) => { const notify = useToastNotification(); + const tourMode = useTourMode(); + // In a no-backend tour the secret-to-input assignment is demonstrated, but + // launching a real run isn't possible, so the submit confirm stays disabled. + const mockBackend = useTourMockBackend(); const initialArgs = getArgumentsFromInputs(componentSpec); const [runNotes, setRunNotes] = useState(""); @@ -135,8 +141,16 @@ export const SubmitTaskArgumentsDialog = ({ const hasInputs = inputs.length > 0; return ( - - + + event.preventDefault(), + onEscapeKeyDown: (event) => event.preventDefault(), + } + : {})} + > Submit Run with Arguments @@ -199,7 +213,10 @@ export const SubmitTaskArgumentsDialog = ({ - diff --git a/src/providers/TourProvider/TourPopover.tsx b/src/providers/TourProvider/TourPopover.tsx index 003982311..ca19a87cf 100644 --- a/src/providers/TourProvider/TourPopover.tsx +++ b/src/providers/TourProvider/TourPopover.tsx @@ -108,6 +108,21 @@ export function computeDefaultPopoverPosition( return [props.right + margin, Math.max(props.top + margin, 64)]; } + // Tall centered modal/dialog (inset from both side edges): place the popover + // to its LEFT, aligned to the dialog's top edge (not vertically centered, as + // reactour's "left" would do). + if ( + isTallStrip && + props.left > margin && + props.right < props.windowWidth - margin + ) { + const popoverWidth = props.width || 380; + return [ + Math.max(margin, props.left - popoverWidth - margin), + Math.max(props.top, margin), + ]; + } + return "bottom"; } diff --git a/src/providers/TourProvider/TourSecretsDialog.tsx b/src/providers/TourProvider/TourSecretsDialog.tsx new file mode 100644 index 000000000..3497807fc --- /dev/null +++ b/src/providers/TourProvider/TourSecretsDialog.tsx @@ -0,0 +1,167 @@ +import { useTour } from "@reactour/tour"; +import { useEffect, useState } from "react"; + +import type { TourStep } from "@/components/Learn/tours/registry"; +import { AddSecretForm } from "@/components/shared/SecretsManagement/components/AddSecretForm"; +import { SecretsListView } from "@/components/shared/SecretsManagement/components/SecretsListView"; +import type { Secret } from "@/components/shared/SecretsManagement/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { Icon, type IconName } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { useTourMode } from "@/providers/TourProvider/TourModeContext"; + +// The Settings gear (AppMenuActions) calls this in tour mode instead of routing +// to /settings, which would unmount the tour page and tear the tour down. +let openSettingsHandler: (() => void) | null = null; + +export function openTourSettings() { + openSettingsHandler?.(); +} + +function registerOpenSettingsHandler(handler: () => void): () => void { + openSettingsHandler = handler; + return () => { + if (openSettingsHandler === handler) openSettingsHandler = null; + }; +} + +const SETTINGS_TABS: Array<{ label: string; icon: IconName; active: boolean }> = + [ + { label: "Backend", icon: "Database", active: false }, + { label: "Preferences", icon: "Settings", active: false }, + { label: "Beta Features", icon: "FlaskConical", active: false }, + { label: "Secrets", icon: "Lock", active: true }, + ]; + +type ManagerMode = + | { kind: "list" } + | { kind: "add" } + | { kind: "replace"; secret: Secret }; + +function TourSecretsManager() { + const [mode, setMode] = useState({ kind: "list" }); + + if (mode.kind !== "list") { + return ( + + + + + {mode.kind === "add" + ? "Add Secret" + : `Replace value for ${mode.secret.name}`} + + + setMode({ kind: "list" })} + onCancel={() => setMode({ kind: "list" })} + /> + + ); + } + + return ( + setMode({ kind: "add" })} + onEditSecret={(secret) => setMode({ kind: "replace", secret })} + /> + ); +} + +// A tour-safe stand-in for the Settings → Secrets page. It mirrors the real +// settings shell (sidebar with Secrets active, other tabs disabled) and renders +// the real SecretsListView, but keeps add/replace in-dialog instead of using the +// router links the real page relies on, so the tour never navigates away. +export function TourSecretsDialog() { + const tourMode = useTourMode(); + const { steps, currentStep, isOpen: tourIsOpen } = useTour(); + const [open, setOpen] = useState(false); + const [openKey, setOpenKey] = useState(0); + + useEffect(() => { + if (!tourMode) return; + return registerOpenSettingsHandler(() => { + setOpenKey((key) => key + 1); + setOpen(true); + }); + }, [tourMode]); + + const step = steps?.[currentStep] as TourStep | undefined; + const onSettingsStep = !!tourIsOpen && step?.tourPanel === "secrets-manager"; + const isExplainStep = onSettingsStep && !step?.interaction; + + useEffect(() => { + if (isExplainStep) setOpen(true); + else if (!onSettingsStep) setOpen(false); + }, [isExplainStep, onSettingsStep]); + + if (!tourMode) return null; + + return ( + + event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + > + Settings + + Manage your secrets. + + + +
+ Settings +
+ + + + {SETTINGS_TABS.map((tab) => ( + + ))} + + +
+ +
+
+
+
+
+ ); +} diff --git a/src/providers/TourProvider/tourActionLabels.test.ts b/src/providers/TourProvider/tourActionLabels.test.ts new file mode 100644 index 000000000..f49c07d0a --- /dev/null +++ b/src/providers/TourProvider/tourActionLabels.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { tourActionLabel } from "./tourActionLabels"; + +describe("tourActionLabel", () => { + it("labels assign-secret-argument with the target argument name", () => { + expect( + tourActionLabel({ + interaction: "assign-secret-argument", + targetArgumentName: "token", + }), + ).toBe("Assign a secret to the **token** argument"); + }); + + it("falls back when assign-secret-argument has no target", () => { + expect(tourActionLabel({ interaction: "assign-secret-argument" })).toBe( + "Assign a secret to the argument", + ); + }); + + it("labels assign-secret-submit with the target input name", () => { + expect( + tourActionLabel({ + interaction: "assign-secret-submit", + targetArgumentName: "API_KEY", + }), + ).toBe("Bind a secret to **API_KEY** at submit"); + }); + + it("falls back when assign-secret-submit has no target", () => { + expect(tourActionLabel({ interaction: "assign-secret-submit" })).toBe( + "Bind a secret at submit time", + ); + }); + + it("labels open-secret-dialog", () => { + expect(tourActionLabel({ interaction: "open-secret-dialog" })).toBe( + "Open the secret picker from the ⚡ menu", + ); + }); + + it("labels open-settings-panel", () => { + expect(tourActionLabel({ interaction: "open-settings-panel" })).toBe( + "Open Settings to manage your secrets", + ); + }); + + it("labels open-submit-dialog", () => { + expect(tourActionLabel({ interaction: "open-submit-dialog" })).toBe( + "Open the run arguments dialog", + ); + }); + + it("uses the generic label for unknown interactions", () => { + expect(tourActionLabel({ interaction: "not-a-real-interaction" })).toBe( + "Complete the highlighted action to continue", + ); + }); +}); diff --git a/src/providers/TourProvider/tourActionLabels.ts b/src/providers/TourProvider/tourActionLabels.ts index b34a3d8f0..0d6faacd9 100644 --- a/src/providers/TourProvider/tourActionLabels.ts +++ b/src/providers/TourProvider/tourActionLabels.ts @@ -60,6 +60,20 @@ export function tourActionLabel(step: TourActionLabelInput): string { return `Select **${step.targetMinCount ?? 2}** tasks`; case "create-subgraph": return "Create a subgraph from the selected tasks"; + case "open-secret-dialog": + return "Open the secret picker from the ⚡ menu"; + case "open-settings-panel": + return "Open Settings to manage your secrets"; + case "open-submit-dialog": + return "Open the run arguments dialog"; + case "assign-secret-argument": + return step.targetArgumentName + ? `Assign a secret to the **${step.targetArgumentName}** argument` + : "Assign a secret to the argument"; + case "assign-secret-submit": + return step.targetArgumentName + ? `Bind a secret to **${step.targetArgumentName}** at submit` + : "Bind a secret at submit time"; default: return GENERIC_LABEL; } diff --git a/src/providers/TourProvider/tourMockBackend.test.ts b/src/providers/TourProvider/tourMockBackend.test.ts new file mode 100644 index 000000000..4e6f9fb0b --- /dev/null +++ b/src/providers/TourProvider/tourMockBackend.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + addSecret, + fetchSecretsList, +} from "@/components/shared/SecretsManagement/secretsStorage"; + +import { + isTourMockActive, + mockAddSecret, + mockListSecrets, + setTourMockActive, +} from "./tourMockBackend"; + +afterEach(() => { + // Deactivating clears the in-memory store. + setTourMockActive(false); +}); + +describe("tourMockBackend store", () => { + it("adds and lists secrets while active", () => { + setTourMockActive(true); + mockAddSecret("API_KEY", "secret-value"); + + const names = mockListSecrets().map((s) => s.name); + expect(names).toEqual(["API_KEY"]); + }); + + it("clears the store when deactivated", () => { + setTourMockActive(true); + mockAddSecret("API_KEY", "secret-value"); + setTourMockActive(false); + + expect(isTourMockActive()).toBe(false); + expect(mockListSecrets()).toEqual([]); + }); +}); + +describe("secretsStorage routes to the mock when active", () => { + it("addSecret + fetchSecretsList use the in-memory store", async () => { + setTourMockActive(true); + + await addSecret({ name: "TOKEN", value: "v" }); + const listed = await fetchSecretsList(); + + expect(listed.map((s) => s.name)).toEqual(["TOKEN"]); + }); +}); diff --git a/src/providers/TourProvider/tourMockBackend.ts b/src/providers/TourProvider/tourMockBackend.ts new file mode 100644 index 000000000..9749396ff --- /dev/null +++ b/src/providers/TourProvider/tourMockBackend.ts @@ -0,0 +1,72 @@ +import { useSyncExternalStore } from "react"; + +import type { Secret } from "@/components/shared/SecretsManagement/types"; + +/** + * In-memory stand-in for the secrets backend, used only while a guided tour + * runs without a real backend (see TourMockBackendController). It lets the user + * actually perform the secret steps — add, list, pick, assign — against an + * ephemeral store that is cleared when the tour ends. Nothing is persisted or + * sent anywhere. + * + * This module is intentionally free of React-provider imports so the leaf + * `secretsStorage` util can read `isTourMockActive()` without pulling the + * component/provider tree into its import graph. + */ + +let active = false; +const secrets = new Map(); +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +export function setTourMockActive(value: boolean): void { + if (active === value) return; + active = value; + if (!value) secrets.clear(); + emit(); +} + +export function isTourMockActive(): boolean { + return active; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Reactively reports whether the tour mock backend is active. */ +export function useTourMockBackend(): boolean { + return useSyncExternalStore(subscribe, isTourMockActive, isTourMockActive); +} + +export function mockListSecrets(): Secret[] { + return Array.from(secrets.values()); +} + +export function mockAddSecret(name: string, value?: string): void { + const now = new Date(); + const existing = secrets.get(name); + secrets.set(name, { + id: name, + name, + value, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }); +} + +export function mockUpdateSecret(name: string, value?: string): void { + const existing = secrets.get(name); + if (!existing) return; + secrets.set(name, { ...existing, value, updatedAt: new Date() }); +} + +export function mockRemoveSecret(name: string): void { + secrets.delete(name); +} diff --git a/src/routes/Dashboard/Learn/Tour.tsx b/src/routes/Dashboard/Learn/Tour.tsx index da3a4715e..c774a6c9b 100644 --- a/src/routes/Dashboard/Learn/Tour.tsx +++ b/src/routes/Dashboard/Learn/Tour.tsx @@ -12,10 +12,13 @@ import { type TourDefinition, } from "@/components/Learn/tours/registry"; import useToastNotification from "@/hooks/useToastNotification"; +import { useBackend } from "@/providers/BackendProvider"; import { TourContent } from "@/providers/TourProvider/TourContent"; +import { setTourMockActive } from "@/providers/TourProvider/tourMockBackend"; import { TourModeProvider, type TourModeValue, + useTourMode, } from "@/providers/TourProvider/TourModeContext"; import { buildTourPipelineYaml, @@ -248,6 +251,7 @@ function TourPageBody({ promoteToPipeline, }} > + {resolved && ( ); } + +/** + * Activates the in-memory mock secrets backend while a tour that opts in + * (`tour.mockBackend`) runs without a real backend, so its secret steps stay + * hands-on. Clears the mock when the tour unmounts. Must render inside + * TourModeProvider. + */ +function TourMockBackendController() { + const tourMode = useTourMode(); + const { available } = useBackend(); + const shouldMock = !!tourMode?.tour.mockBackend && !available; + + useEffect(() => { + setTourMockActive(shouldMock); + }, [shouldMock]); + + useEffect(() => () => setTourMockActive(false), []); + + return null; +} + +// placeholder for empty pr diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index 070daffef..5d032fdef 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -16,6 +16,7 @@ import { ForcedSearchProvider } from "@/providers/ComponentLibraryProvider/Force import { DialogProvider } from "@/providers/DialogProvider/DialogProvider"; import { useTourMode } from "@/providers/TourProvider/TourModeContext"; import { TourSaveExploreDialog } from "@/providers/TourProvider/TourSaveExploreDialog"; +import { TourSecretsDialog } from "@/providers/TourProvider/TourSecretsDialog"; import { AiChatStoreProvider } from "@/routes/v2/shared/components/AiChat/AiChatStoreContext"; import { useDockAreaAccordion } from "@/routes/v2/shared/hooks/useDockAreaAccordion"; import { useFocusMode } from "@/routes/v2/shared/hooks/useFocusMode"; @@ -169,6 +170,7 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) { + {body} diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx index 695d24919..7e85ca83c 100644 --- a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx @@ -8,6 +8,7 @@ import type { ComponentSpec } from "@/models/componentSpec"; import { useTourProgress } from "@/providers/TourProvider/TourProgressContext"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import type { WindowStoreImpl } from "@/routes/v2/shared/windows/windowStore"; +import { isSecretArgument } from "@/utils/componentSpec"; type CountInteraction = | "add-task" @@ -526,6 +527,135 @@ export function EditorTourBridge() { }; } + if (interaction === "assign-secret-argument") { + const targetArgumentName = step?.targetArgumentName; + + const hasSecretArgument = () => { + const spec = navigation.activeSpec; + if (!spec) return false; + return spec.tasks.some((task) => + task.arguments.some( + (arg) => + (!targetArgumentName || arg.name === targetArgumentName) && + isSecretArgument(arg.value), + ), + ); + }; + + if (hasSecretArgument()) { + skip(); + return stopFollow; + } + + const dispose = reaction( + () => hasSecretArgument(), + (matches) => { + if (matches) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (interaction === "open-secret-dialog") { + const dialogSelector = '[data-testid="select-secret-dialog"]'; + const isDialogOpen = () => !!document.querySelector(dialogSelector); + + if (isDialogOpen()) { + skip(); + return stopFollow; + } + + const observer = new MutationObserver(() => { + if (isDialogOpen()) { + observer.disconnect(); + advance(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + stopFollow(); + observer.disconnect(); + }; + } + + if (interaction === "open-settings-panel") { + const panelSelector = '[data-tour="tour-settings-dialog"]'; + const isPanelOpen = () => !!document.querySelector(panelSelector); + + if (isPanelOpen()) { + skip(); + return stopFollow; + } + + const observer = new MutationObserver(() => { + if (isPanelOpen()) { + observer.disconnect(); + advance(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + stopFollow(); + observer.disconnect(); + }; + } + + if (interaction === "open-submit-dialog") { + const dialogSelector = '[data-tour="submit-arguments-dialog"]'; + const isDialogOpen = () => !!document.querySelector(dialogSelector); + + if (isDialogOpen()) { + skip(); + return stopFollow; + } + + const observer = new MutationObserver(() => { + if (isDialogOpen()) { + observer.disconnect(); + advance(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + stopFollow(); + observer.disconnect(); + }; + } + + if (interaction === "assign-secret-submit") { + const secretSelector = + '[data-tour="submit-arguments-dialog"] [data-testid="dynamic-data-argument-input"]'; + const hasSecret = () => !!document.querySelector(secretSelector); + + if (hasSecret()) { + skip(); + return stopFollow; + } + + const observer = new MutationObserver(() => { + if (hasSecret()) { + observer.disconnect(); + advance(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + stopFollow(); + observer.disconnect(); + }; + } + if (interaction === "connect-edge" && step?.targetEdge) { const target = step.targetEdge; const hasTargetEdge = () => { diff --git a/src/routes/v2/shared/components/AppMenuActions.tsx b/src/routes/v2/shared/components/AppMenuActions.tsx index 29196a678..3fe3fc8ee 100644 --- a/src/routes/v2/shared/components/AppMenuActions.tsx +++ b/src/routes/v2/shared/components/AppMenuActions.tsx @@ -7,11 +7,14 @@ import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle"; import { Icon } from "@/components/ui/icon"; import { InlineStack } from "@/components/ui/layout"; import { Link } from "@/components/ui/link"; +import { useTourMode } from "@/providers/TourProvider/TourModeContext"; +import { openTourSettings } from "@/providers/TourProvider/TourSecretsDialog"; import { DOCUMENTATION_URL } from "@/utils/constants"; import { tracking } from "@/utils/tracking"; export function AppMenuActions() { const requiresAuthorization = isAuthorizationRequired(); + const tourMode = useTourMode(); return ( - - + {tourMode ? ( + - + ) : ( + + + + + + )}