mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-10-02 19:57:59 +00:00
Send new signups the event that starts the welcome emails (SKY-17435) (#8740)
This commit is contained in:
parent
92e7636337
commit
dca0f98fe7
32 changed files with 431 additions and 3937 deletions
|
|
@ -1,215 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockPost, mockNavigate, studioState, prewarmFlagState } = vi.hoisted(
|
||||
() => ({
|
||||
mockPost: vi.fn(),
|
||||
mockNavigate: vi.fn(),
|
||||
studioState: { enabled: true },
|
||||
prewarmFlagState: { enabled: false },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/hooks/useWorkflowStudioEnabled", () => ({
|
||||
useWorkflowStudioEnabled: () => studioState.enabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useFeatureFlag", () => ({
|
||||
useFeatureFlag: () => prewarmFlagState.enabled,
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-router-dom")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/useCredentialGetter", () => ({
|
||||
useCredentialGetter: () => () => Promise.resolve("test-token"),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useUser", () => ({
|
||||
useUser: () => ({ get: () => ({ id: "user-a", email: "", name: "" }) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCurrentOrgId", () => ({
|
||||
useCurrentOrgId: () => "org-a",
|
||||
}));
|
||||
|
||||
vi.mock("@/api/AxiosClient", () => ({
|
||||
getClient: () => Promise.resolve({ post: mockPost }),
|
||||
}));
|
||||
|
||||
vi.mock("@/routes/workflows/hooks/useGlobalWorkflowsQuery", () => ({
|
||||
useGlobalWorkflowsQuery: () => ({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/util/onboarding/OnboardingTelemetry", () => ({
|
||||
OnboardingTelemetry: {
|
||||
flowCompleted: vi.fn(),
|
||||
modalCopilotClicked: vi.fn(),
|
||||
modalTemplateSelected: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { CopilotCTAStep } from "./CopilotCTAStep";
|
||||
|
||||
function setup(onBusyChange?: (busy: boolean) => void) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
const renderStep = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<CopilotCTAStep
|
||||
selectedIntent="fill_forms"
|
||||
onBack={vi.fn()}
|
||||
onSkip={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
onBusyChange={onBusyChange}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const rendered = render(renderStep());
|
||||
return {
|
||||
invalidateSpy,
|
||||
rerender: () => rendered.rerender(renderStep()),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
studioState.enabled = true;
|
||||
prewarmFlagState.enabled = false;
|
||||
});
|
||||
|
||||
describe("CopilotCTAStep", () => {
|
||||
it("prewarms once on first input without prewarming on autofocus", async () => {
|
||||
prewarmFlagState.enabled = true;
|
||||
mockPost.mockResolvedValue({ data: {} });
|
||||
setup();
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Fill" } });
|
||||
await waitFor(() =>
|
||||
expect(mockPost).toHaveBeenCalledWith("/debug-session/prewarm", {
|
||||
proxy_location: null,
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Fill out my form" } });
|
||||
expect(
|
||||
mockPost.mock.calls.filter(([path]) => path === "/debug-session/prewarm"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("prewarms an existing prompt when the feature flag resolves late", async () => {
|
||||
mockPost.mockResolvedValue({ data: {} });
|
||||
const { rerender } = setup();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "Fill out my form" },
|
||||
});
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
|
||||
prewarmFlagState.enabled = true;
|
||||
rerender();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockPost).toHaveBeenCalledWith("/debug-session/prewarm", {
|
||||
proxy_location: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates userOnboarding after the copilot handoff creates a workflow", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { workflow_permanent_id: "wpid_x" },
|
||||
});
|
||||
const { invalidateSpy } = setup();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "fill out my form" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["userOnboarding"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hands the prompt off to /edit with it in location state", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { workflow_permanent_id: "wpid_x" },
|
||||
});
|
||||
setup();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "fill out my form" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/agents/wpid_x/studio", {
|
||||
state: { copilotMessage: "fill out my form" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports busy to the parent while the handoff is in flight", async () => {
|
||||
mockPost.mockReturnValue(new Promise(() => {}));
|
||||
const onBusyChange = vi.fn();
|
||||
setup(onBusyChange);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "fill out my form" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
|
||||
|
||||
await waitFor(() => expect(onBusyChange).toHaveBeenCalledWith(true));
|
||||
});
|
||||
|
||||
it("disables Skip while the copilot handoff is in flight", async () => {
|
||||
// Never resolves: keep the handoff mutation pending so we can assert the
|
||||
// Skip control is disabled (otherwise a skip mid-create still navigates).
|
||||
mockPost.mockReturnValue(new Promise(() => {}));
|
||||
setup();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "fill out my form" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const skip = screen.getByRole("button", { name: "Skip" });
|
||||
expect((skip as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { stringify as convertToYAML } from "yaml";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/util/utils";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { OnboardingTelemetry } from "@/util/onboarding/OnboardingTelemetry";
|
||||
import { useGlobalWorkflowsQuery } from "@/routes/workflows/hooks/useGlobalWorkflowsQuery";
|
||||
import { useCreateWorkflowMutation } from "@/routes/workflows/hooks/useCreateWorkflowMutation";
|
||||
import { convert } from "@/routes/workflows/editor/workflowEditorUtils";
|
||||
import { useWorkflowStudioEnabled } from "@/hooks/useWorkflowStudioEnabled";
|
||||
import { workflowEditorPath } from "@/routes/workflows/studioNavigation";
|
||||
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
|
||||
import type { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes";
|
||||
import { useBrowserSessionPrewarm } from "@/routes/tasks/create/useBrowserSessionPrewarm";
|
||||
import {
|
||||
getTemplatesForIntent,
|
||||
getTemplateIcon,
|
||||
getSetupTime,
|
||||
} from "./templateUtils";
|
||||
|
||||
const SURFACE = "discover" as const;
|
||||
|
||||
const INTENT_PLACEHOLDERS: Record<string, string> = {
|
||||
fill_forms: "Describe the form you want to fill out...",
|
||||
extract_data: "What data do you want to extract and from where?",
|
||||
monitor_website: "What website changes do you want to monitor?",
|
||||
something_else: "Describe what you want to automate...",
|
||||
};
|
||||
|
||||
const HANDOFF_TITLE_MAX_LEN = 80;
|
||||
|
||||
function deriveTitle(prompt: string): string {
|
||||
const collapsed = prompt.replace(/\s+/g, " ").trim();
|
||||
if (!collapsed) return "New Workflow";
|
||||
if (collapsed.length <= HANDOFF_TITLE_MAX_LEN) return collapsed;
|
||||
return `${collapsed.slice(0, HANDOFF_TITLE_MAX_LEN - 1).trimEnd()}...`;
|
||||
}
|
||||
|
||||
type CopilotCTAStepProps = {
|
||||
selectedIntent: string;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
onDismiss: () => void;
|
||||
/** Reports in-flight creations so the parent can ignore its dialog-level
|
||||
* close path (Escape / X), which cannot see this step's mutations. */
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
};
|
||||
|
||||
function CopilotCTAStep({
|
||||
selectedIntent,
|
||||
onBack,
|
||||
onSkip,
|
||||
onDismiss,
|
||||
onBusyChange,
|
||||
}: Readonly<CopilotCTAStepProps>) {
|
||||
const navigate = useNavigate();
|
||||
const studioEnabled = useWorkflowStudioEnabled();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [promptText, setPromptText] = useState("");
|
||||
const prewarmBrowserSession = useBrowserSessionPrewarm(null);
|
||||
useEffect(() => {
|
||||
prewarmBrowserSession(promptText);
|
||||
}, [prewarmBrowserSession, promptText]);
|
||||
const [showFallback, setShowFallback] = useState(false);
|
||||
const submitRef = useRef(false);
|
||||
|
||||
const { data: globalTemplates = [], isLoading: templatesLoading } =
|
||||
useGlobalWorkflowsQuery();
|
||||
const createWorkflowMutation = useCreateWorkflowMutation();
|
||||
|
||||
const handoffMutation = useMutation({
|
||||
mutationFn: async (prompt: string) => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const request: WorkflowCreateYAMLRequest = {
|
||||
title: deriveTitle(prompt),
|
||||
description: "",
|
||||
ai_fallback: true,
|
||||
code_version: 2,
|
||||
run_with: "agent",
|
||||
workflow_definition: {
|
||||
version: 2,
|
||||
blocks: [],
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const yaml = convertToYAML(request);
|
||||
const result = await client.post<WorkflowApiResponse>(
|
||||
"/workflows",
|
||||
yaml,
|
||||
{ headers: { "Content-Type": "text/plain" } },
|
||||
);
|
||||
return { workflow: result.data, prompt };
|
||||
},
|
||||
onSuccess: ({ workflow, prompt }) => {
|
||||
OnboardingTelemetry.flowCompleted(SURFACE);
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folders"] });
|
||||
// Let the provider observe the backend first_save_at milestone so the
|
||||
// first_workflow_created funnel event fires for copilot-first users.
|
||||
queryClient.invalidateQueries({ queryKey: ["userOnboarding"] });
|
||||
onDismiss();
|
||||
navigate(
|
||||
workflowEditorPath(workflow.workflow_permanent_id, studioEnabled),
|
||||
{
|
||||
state: { copilotMessage: prompt },
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
setShowFallback(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
submitRef.current = false;
|
||||
},
|
||||
});
|
||||
|
||||
const busy = handoffMutation.isPending || createWorkflowMutation.isPending;
|
||||
useEffect(() => {
|
||||
onBusyChange?.(busy);
|
||||
}, [busy, onBusyChange]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onBusyChange?.(false);
|
||||
};
|
||||
}, [onBusyChange]);
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmed = promptText.trim();
|
||||
if (!trimmed || submitRef.current || handoffMutation.isPending) return;
|
||||
submitRef.current = true;
|
||||
OnboardingTelemetry.modalCopilotClicked(SURFACE, selectedIntent, trimmed);
|
||||
handoffMutation.mutate(trimmed);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTemplateSelect(template: WorkflowApiResponse) {
|
||||
if (createWorkflowMutation.isPending) return;
|
||||
OnboardingTelemetry.modalTemplateSelected(
|
||||
SURFACE,
|
||||
template.workflow_permanent_id,
|
||||
selectedIntent,
|
||||
);
|
||||
const cloned = convert(
|
||||
{ ...template, title: `${template.title} (copy)` },
|
||||
{ asNewWorkflow: true },
|
||||
);
|
||||
// flow_completed fires from useCreateWorkflowMutation (it owns the navigation
|
||||
// that unmounts this modal); the navigation also dismisses the modal.
|
||||
createWorkflowMutation.mutate({ ...cloned, _via: "onboarding_template" });
|
||||
}
|
||||
|
||||
const filteredTemplates =
|
||||
globalTemplates.length > 0
|
||||
? getTemplatesForIntent(globalTemplates, selectedIntent)
|
||||
: [];
|
||||
|
||||
if (showFallback) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Or pick a template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a pre-built workflow and customize it in the editor.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3 py-2">
|
||||
{templatesLoading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 rounded-lg" />
|
||||
))
|
||||
) : filteredTemplates.length > 0 ? (
|
||||
filteredTemplates.map((template) => {
|
||||
const Icon = getTemplateIcon(template);
|
||||
return (
|
||||
<button
|
||||
key={template.workflow_permanent_id}
|
||||
type="button"
|
||||
disabled={createWorkflowMutation.isPending}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 rounded-lg border border-border p-4 text-left transition-colors",
|
||||
"hover:border-primary hover:bg-primary/5",
|
||||
createWorkflowMutation.isPending &&
|
||||
"pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
<div className="min-w-0 self-stretch">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{template.title}
|
||||
</p>
|
||||
{template.description && (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
~{getSetupTime(template)} setup
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="col-span-2 py-8 text-center text-sm text-muted-foreground">
|
||||
No templates available yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowFallback(false)}
|
||||
disabled={createWorkflowMutation.isPending}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSkip}
|
||||
disabled={createWorkflowMutation.isPending}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
Describe what you want to automate
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tell us in plain language and our AI copilot will build a workflow for
|
||||
you.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Textarea
|
||||
value={promptText}
|
||||
onChange={(e) => setPromptText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
INTENT_PLACEHOLDERS[selectedIntent] ??
|
||||
INTENT_PLACEHOLDERS.something_else
|
||||
}
|
||||
className="min-h-[100px] resize-none"
|
||||
disabled={handoffMutation.isPending}
|
||||
autoFocus
|
||||
/>
|
||||
{handoffMutation.isPending && (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Setting up your workflow...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
disabled={handoffMutation.isPending}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSkip}
|
||||
disabled={handoffMutation.isPending}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!promptText.trim() || handoffMutation.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{handoffMutation.isPending ? "Creating..." : "Create with AI"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { CopilotCTAStep };
|
||||
export type { CopilotCTAStepProps };
|
||||
|
|
@ -1,743 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import { StrictMode, useState, type ComponentProps } from "react";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
ConfirmedPatch,
|
||||
ConfirmedWriteResult,
|
||||
LegacyOnboardingStatePatch,
|
||||
OnboardingState,
|
||||
OnboardingStateResponse,
|
||||
QuestionnaireStateV1,
|
||||
} from "@/store/onboarding/types";
|
||||
import { OnboardingContext } from "@/store/onboarding/useOnboardingState";
|
||||
import { GetStartedModal } from "./GetStartedModal";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
userId: "user-a",
|
||||
orgId: null as string | null,
|
||||
realDetails: false,
|
||||
confirmed: vi.fn<(patch: ConfirmedPatch) => Promise<ConfirmedWriteResult>>(),
|
||||
createWorkflow: vi.fn(),
|
||||
legacyUpdate: vi.fn<(patch: LegacyOnboardingStatePatch) => void>(),
|
||||
telemetry: {
|
||||
registerVariant: vi.fn(),
|
||||
flowStarted: vi.fn(),
|
||||
modalOpened: vi.fn(),
|
||||
modalSkipped: vi.fn(),
|
||||
questionnaireShown: vi.fn<(input: unknown) => boolean>(() => true),
|
||||
questionnaireCompleted: vi.fn(),
|
||||
questionnaireSkipped: vi.fn(),
|
||||
questionnaireUpdated: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagVariantKey: () => "template-first",
|
||||
}));
|
||||
vi.mock("@/hooks/useUser", () => ({
|
||||
useUser: () => ({
|
||||
get: () => ({
|
||||
id: mocks.userId,
|
||||
email: "",
|
||||
name: "",
|
||||
createdAt: new Date("2026-08-28T00:00:00Z"),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/store/ActiveOrgContext", () => ({
|
||||
useActiveOrgId: () => mocks.orgId ?? undefined,
|
||||
}));
|
||||
vi.mock("@/routes/workflows/hooks/useGlobalWorkflowsQuery", () => ({
|
||||
useGlobalWorkflowsQuery: () => ({ data: [], isLoading: false }),
|
||||
}));
|
||||
vi.mock("@/routes/workflows/hooks/useCreateWorkflowMutation", () => ({
|
||||
useCreateWorkflowMutation: () => ({
|
||||
mutate: mocks.createWorkflow,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/util/onboarding/OnboardingTelemetry", () => ({
|
||||
OnboardingTelemetry: mocks.telemetry,
|
||||
}));
|
||||
vi.mock("./CopilotCTAStep", () => ({
|
||||
CopilotCTAStep: () => null,
|
||||
}));
|
||||
vi.mock("./QuestionnaireDetailsStep", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("./QuestionnaireDetailsStep")
|
||||
>("./QuestionnaireDetailsStep");
|
||||
return {
|
||||
QuestionnaireDetailsStep: (
|
||||
props: ComponentProps<typeof actual.QuestionnaireDetailsStep>,
|
||||
) => {
|
||||
if (mocks.realDetails)
|
||||
return <actual.QuestionnaireDetailsStep {...props} />;
|
||||
const {
|
||||
completionAction,
|
||||
expectedRevision,
|
||||
externalError,
|
||||
isPending,
|
||||
onAction,
|
||||
onBack,
|
||||
} = props;
|
||||
const answerPatch = {
|
||||
version: 1 as const,
|
||||
mutation_id: `mutation-${completionAction}-${expectedRevision}`,
|
||||
expected_revision: expectedRevision,
|
||||
action: completionAction,
|
||||
role: "developer" as const,
|
||||
company_context: "startup" as const,
|
||||
scale_intent: "exploring" as const,
|
||||
referral_source: "search" as const,
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<span>{`details-${completionAction}-${expectedRevision}`}</span>
|
||||
{externalError ? <div role="alert">{externalError}</div> : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => void onAction(answerPatch)}
|
||||
>
|
||||
details-submit
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
details-back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const baseState: OnboardingState = {
|
||||
tour_completed_at: null,
|
||||
modal_dismissed_at: null,
|
||||
first_save_at: null,
|
||||
first_run_at: null,
|
||||
ab_variant: "template-first",
|
||||
user_intent: null,
|
||||
questionnaire: null,
|
||||
questionnaire_prompted_at: null,
|
||||
seen_canvas: null,
|
||||
seen_node_adder: null,
|
||||
seen_sidebar: null,
|
||||
seen_save_run: null,
|
||||
};
|
||||
|
||||
function questionnaire(
|
||||
overrides: Partial<QuestionnaireStateV1> = {},
|
||||
): QuestionnaireStateV1 {
|
||||
return {
|
||||
version: 1,
|
||||
response_id: "response",
|
||||
revision: 1,
|
||||
last_mutation_id: "mutation-complete-0",
|
||||
status: "completed",
|
||||
role: "developer",
|
||||
company_context: "startup",
|
||||
scale_intent: "exploring",
|
||||
referral_source: "search",
|
||||
completed_at: "2026-01-01T00:00:00Z",
|
||||
skipped_at: null,
|
||||
deferred_at: null,
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
defer_prompt_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function response(
|
||||
overrides: Partial<OnboardingState> = {},
|
||||
): OnboardingStateResponse {
|
||||
return {
|
||||
onboarding_state: { ...baseState, ...overrides },
|
||||
project_owner_supported: true,
|
||||
launch_date_at_signup: "2026-01-01T00:00:00Z",
|
||||
recovery_guidance_assignment: null,
|
||||
};
|
||||
}
|
||||
|
||||
const promptedAt = "2026-08-27T00:00:00Z";
|
||||
const reservedResponse = (overrides: Partial<OnboardingState> = {}) => ({
|
||||
...response({ ...overrides, questionnaire_prompted_at: promptedAt }),
|
||||
questionnaire_prompt_result: {
|
||||
status: "reserved" as const,
|
||||
prompted_at: promptedAt,
|
||||
},
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function TestModal({
|
||||
initialState = baseState,
|
||||
editorState,
|
||||
}: {
|
||||
initialState?: OnboardingState;
|
||||
editorState?: Partial<
|
||||
Pick<
|
||||
OnboardingState,
|
||||
| "modal_dismissed_at"
|
||||
| "first_save_at"
|
||||
| "questionnaire_prompted_at"
|
||||
| "questionnaire"
|
||||
>
|
||||
>;
|
||||
}) {
|
||||
const [state, setState] = useState(initialState);
|
||||
function updateState(patch: LegacyOnboardingStatePatch) {
|
||||
mocks.legacyUpdate(patch);
|
||||
setState((current) => ({
|
||||
...current,
|
||||
...patch,
|
||||
questionnaire: current.questionnaire,
|
||||
}));
|
||||
}
|
||||
async function updateStateConfirmed(patch: ConfirmedPatch) {
|
||||
const next = await mocks.confirmed(patch);
|
||||
if ("onboarding_state" in next) {
|
||||
setState(next.onboarding_state);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<OnboardingContext.Provider
|
||||
value={{
|
||||
state: editorState ? { ...state, ...editorState } : state,
|
||||
isLoading: false,
|
||||
updateState,
|
||||
updateStateConfirmed,
|
||||
isNewUser: true,
|
||||
abVariant: "template-first",
|
||||
recoveryGuidanceAssignment: null,
|
||||
}}
|
||||
>
|
||||
<GetStartedModal />
|
||||
</OnboardingContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.confirmed.mockResolvedValue(response({ user_intent: "fill_forms" }));
|
||||
mocks.telemetry.questionnaireShown.mockReturnValue(true);
|
||||
mocks.userId = "user-a";
|
||||
mocks.orgId = null;
|
||||
mocks.realDetails = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mocks.confirmed.mockReset();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("GetStartedModal", () => {
|
||||
it.each([undefined, false])(
|
||||
"keeps owner capture hidden without backend support: %s",
|
||||
async (supported) => {
|
||||
mocks.realDetails = true;
|
||||
mocks.confirmed.mockImplementation(async (patch) =>
|
||||
patch.questionnaire_prompt
|
||||
? reservedResponse()
|
||||
: {
|
||||
...response({ user_intent: "fill_forms" }),
|
||||
organization_id: "o_test",
|
||||
project_owner_supported: supported,
|
||||
},
|
||||
);
|
||||
render(<TestModal />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Fill out forms/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await screen.findByRole("button", { name: "Complete and continue" });
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: /Who else owns this automation project/,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(4);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Complete and continue", "Skip", "Close"])(
|
||||
"only saves the draft on explicit completion: %s",
|
||||
async (action) => {
|
||||
mocks.realDetails = true;
|
||||
mocks.confirmed.mockImplementation(async (patch) => {
|
||||
if (patch.questionnaire_prompt)
|
||||
return { ...reservedResponse(), organization_id: "o_test" };
|
||||
if (!patch.questionnaire)
|
||||
return {
|
||||
...response({ user_intent: "fill_forms" }),
|
||||
organization_id: "o_test",
|
||||
};
|
||||
const q = patch.questionnaire;
|
||||
return {
|
||||
...response({
|
||||
user_intent: "fill_forms",
|
||||
questionnaire: questionnaire({
|
||||
last_mutation_id: q.mutation_id,
|
||||
status: q.action === "skip" ? "skipped" : "completed",
|
||||
}),
|
||||
}),
|
||||
organization_id: "o_test",
|
||||
};
|
||||
});
|
||||
render(<TestModal />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Fill out forms/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: /Who else owns this automation project/,
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Work email"), {
|
||||
target: { value: "owner@example.com" },
|
||||
});
|
||||
screen.getAllByRole("combobox").forEach((control) => {
|
||||
fireEvent.click(control);
|
||||
fireEvent.click(screen.getAllByRole("option")[0]!);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: action }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
const write = mocks.confirmed.mock.calls.find(
|
||||
([patch]) => patch.questionnaire,
|
||||
)?.[0].questionnaire;
|
||||
if (action === "Complete and continue") {
|
||||
expect(write).toHaveProperty("project_owner", {
|
||||
action: "set",
|
||||
expected_organization_id: "o_test",
|
||||
professional_email: "owner@example.com",
|
||||
});
|
||||
} else {
|
||||
expect(write).toEqual({
|
||||
version: 1,
|
||||
action: "skip",
|
||||
mutation_id: expect.any(String),
|
||||
expected_revision: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("drops the real optional draft on an organization switch", async () => {
|
||||
mocks.realDetails = true;
|
||||
mocks.orgId = "org_a";
|
||||
mocks.confirmed
|
||||
.mockResolvedValueOnce({ ...reservedResponse(), organization_id: "o_a" })
|
||||
.mockResolvedValueOnce({
|
||||
...response({ user_intent: "fill_forms" }),
|
||||
organization_id: "o_a",
|
||||
});
|
||||
const view = render(<TestModal />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Fill out forms/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: /Who else owns this automation project/,
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Name"), {
|
||||
target: { value: "Synthetic Owner" },
|
||||
});
|
||||
mocks.confirmed
|
||||
.mockResolvedValueOnce({ ...reservedResponse(), organization_id: "o_b" })
|
||||
.mockResolvedValueOnce({
|
||||
...response({ user_intent: "fill_forms" }),
|
||||
organization_id: "o_b",
|
||||
});
|
||||
mocks.orgId = "org_b";
|
||||
view.rerender(<TestModal />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Fill out forms/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: /Who else owns this automation project/,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
|
||||
expect(
|
||||
mocks.confirmed.mock.calls.some(([patch]) => patch.questionnaire),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reserves once, completes, stamps dismissal, and closes", async () => {
|
||||
const reservation = deferred<ConfirmedWriteResult>();
|
||||
const completion = deferred<OnboardingStateResponse>();
|
||||
mocks.confirmed
|
||||
.mockReturnValueOnce(reservation.promise)
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
user_intent: "fill_forms",
|
||||
questionnaire_prompted_at: promptedAt,
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(completion.promise);
|
||||
const view = render(
|
||||
<StrictMode>
|
||||
<TestModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
|
||||
view.rerender(
|
||||
<StrictMode>
|
||||
<TestModal />
|
||||
</StrictMode>,
|
||||
);
|
||||
expect(mocks.confirmed).toHaveBeenCalledWith({
|
||||
questionnaire_prompt: { version: 1, action: "reserve" },
|
||||
});
|
||||
await act(async () => reservation.resolve(reservedResponse()));
|
||||
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ primaryIntent: null, promptReason: "initial" }),
|
||||
);
|
||||
expect(screen.getByText("STEP 1 OF 2")).toBeTruthy();
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Fill out forms/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
fireEvent.click(await screen.findByText("details-submit"));
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledTimes(3));
|
||||
expect(mocks.telemetry.questionnaireCompleted).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(mocks.confirmed).toHaveBeenCalledTimes(3);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
await act(async () =>
|
||||
completion.resolve(
|
||||
response({
|
||||
user_intent: "fill_forms",
|
||||
questionnaire_prompted_at: promptedAt,
|
||||
questionnaire: questionnaire(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(mocks.telemetry.questionnaireCompleted).toHaveBeenCalledOnce();
|
||||
expect(mocks.confirmed).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.legacyUpdate).toHaveBeenCalledWith({
|
||||
modal_dismissed_at: expect.any(String),
|
||||
});
|
||||
expect(mocks.telemetry.modalSkipped).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("closes when Intent Continue first sees a foreign response", async () => {
|
||||
mocks.confirmed
|
||||
.mockResolvedValueOnce(reservedResponse())
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
user_intent: "fill_forms",
|
||||
questionnaire_prompted_at: promptedAt,
|
||||
questionnaire: questionnaire({ response_id: "foreign-response" }),
|
||||
}),
|
||||
);
|
||||
render(<TestModal />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Fill out forms/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(mocks.confirmed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reserves and opens the questionnaire for an initially dismissed editor", async () => {
|
||||
const dismissedAt = "2026-01-01T00:00:00Z";
|
||||
mocks.confirmed.mockResolvedValueOnce(
|
||||
reservedResponse({ modal_dismissed_at: dismissedAt }),
|
||||
);
|
||||
render(
|
||||
<TestModal
|
||||
initialState={{ ...baseState, modal_dismissed_at: dismissedAt }}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
expect(mocks.confirmed).toHaveBeenCalledOnce();
|
||||
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("opens the reserved questionnaire when sessionStorage.getItem throws", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("storage unavailable");
|
||||
});
|
||||
mocks.confirmed.mockResolvedValueOnce(reservedResponse());
|
||||
render(<TestModal />);
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("opens the reserved questionnaire when sessionStorage.setItem throws", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockReturnValue(null);
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("storage unavailable");
|
||||
});
|
||||
mocks.confirmed.mockResolvedValueOnce(reservedResponse());
|
||||
render(<TestModal />);
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("never reopens a legacy deferred response", async () => {
|
||||
const deferredState = questionnaire({
|
||||
status: "deferred",
|
||||
completed_at: null,
|
||||
deferred_at: "2026-01-01T00:00:00Z",
|
||||
defer_prompt_count: 1,
|
||||
});
|
||||
const props = {
|
||||
initialState: {
|
||||
...baseState,
|
||||
user_intent: "fill_forms",
|
||||
questionnaire: deferredState,
|
||||
},
|
||||
};
|
||||
render(<TestModal {...props} />);
|
||||
await act(async () => undefined);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(mocks.confirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps initial details open and announces a failed dialog close", async () => {
|
||||
const closeWrite = deferred<ConfirmedWriteResult>();
|
||||
mocks.confirmed
|
||||
.mockResolvedValueOnce(reservedResponse())
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
user_intent: "fill_forms",
|
||||
questionnaire_prompted_at: promptedAt,
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(closeWrite.promise);
|
||||
render(<TestModal />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Fill out forms/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(await screen.findByText("details-complete-0")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledTimes(3));
|
||||
expect(screen.getByText("details-complete-0")).toBeTruthy();
|
||||
await act(async () => closeWrite.reject(new Error("offline")));
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(
|
||||
"We couldn't save your choice. Try again.",
|
||||
);
|
||||
expect(screen.getByText("details-complete-0")).toBeTruthy();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("persists initial Skip before telemetry and closing", async () => {
|
||||
const write = deferred<OnboardingStateResponse>();
|
||||
mocks.confirmed
|
||||
.mockResolvedValueOnce(reservedResponse())
|
||||
.mockReturnValueOnce(write.promise);
|
||||
render(<TestModal />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Skip" }));
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledTimes(2));
|
||||
const skipPatch =
|
||||
mocks.confirmed.mock.calls[mocks.confirmed.mock.calls.length - 1]?.[0]
|
||||
.questionnaire;
|
||||
expect(skipPatch).toEqual(
|
||||
expect.objectContaining({ expected_revision: 0, action: "skip" }),
|
||||
);
|
||||
if (!skipPatch) throw new Error("missing Skip patch");
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(mocks.telemetry.questionnaireSkipped).not.toHaveBeenCalled();
|
||||
await act(async () =>
|
||||
write.resolve(
|
||||
response({
|
||||
questionnaire_prompted_at: promptedAt,
|
||||
questionnaire: questionnaire({
|
||||
status: "skipped",
|
||||
completed_at: null,
|
||||
skipped_at: promptedAt,
|
||||
last_mutation_id: skipPatch.mutation_id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(mocks.telemetry.questionnaireSkipped).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ responseId: "response", revision: 1 }),
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a reservation result after unmount", async () => {
|
||||
const write = deferred<ConfirmedWriteResult>();
|
||||
mocks.confirmed.mockReturnValueOnce(write.promise);
|
||||
const view = render(<TestModal />);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
view.unmount();
|
||||
await act(async () => write.resolve(reservedResponse()));
|
||||
|
||||
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets reservation ownership when the Clerk user changes", async () => {
|
||||
const firstUserReservation = deferred<ConfirmedWriteResult>();
|
||||
mocks.confirmed
|
||||
.mockReturnValueOnce(firstUserReservation.promise)
|
||||
.mockResolvedValueOnce(reservedResponse());
|
||||
const view = render(<TestModal />);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
|
||||
|
||||
mocks.userId = "user-b";
|
||||
view.rerender(<TestModal />);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledTimes(2));
|
||||
expect(
|
||||
await screen.findByText("What do you want to automate?"),
|
||||
).toBeTruthy();
|
||||
|
||||
await act(async () => firstUserReservation.resolve(reservedResponse()));
|
||||
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("closes an ineligible fallback after another tab reserves", async () => {
|
||||
const write = deferred<ConfirmedWriteResult>();
|
||||
mocks.confirmed.mockReturnValueOnce(write.promise);
|
||||
const view = render(<TestModal />);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
|
||||
view.rerender(
|
||||
<TestModal editorState={{ questionnaire_prompted_at: promptedAt }} />,
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
write.resolve({
|
||||
...response(),
|
||||
questionnaire_prompt_result: { status: "ineligible" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByText("Pick a template to start")).toBeNull();
|
||||
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["after a refresh", { questionnaire_prompted_at: promptedAt }],
|
||||
["when another tab reserved first", {}],
|
||||
[
|
||||
"after a legacy modal dismissal",
|
||||
{
|
||||
questionnaire_prompted_at: promptedAt,
|
||||
modal_dismissed_at: "2026-08-26T00:00:00Z",
|
||||
},
|
||||
],
|
||||
])("resumes an unanswered questionnaire %s", async (_, initial) => {
|
||||
mocks.confirmed.mockResolvedValueOnce({
|
||||
...response({ ...initial, questionnaire_prompted_at: promptedAt }),
|
||||
questionnaire_prompt_result: { status: "already_prompted" },
|
||||
});
|
||||
render(<TestModal initialState={{ ...baseState, ...initial }} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("What do you want to automate?"),
|
||||
).toBeTruthy();
|
||||
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ promptReason: "resume" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays closed when another tab answered before the resume reserve", async () => {
|
||||
mocks.confirmed.mockResolvedValueOnce({
|
||||
...response({
|
||||
questionnaire_prompted_at: promptedAt,
|
||||
questionnaire: questionnaire(),
|
||||
}),
|
||||
questionnaire_prompt_result: { status: "already_prompted" },
|
||||
});
|
||||
render(
|
||||
<TestModal
|
||||
initialState={{ ...baseState, questionnaire_prompted_at: promptedAt }}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes a resumed questionnaire when Skip finds it answered in another tab", async () => {
|
||||
mocks.confirmed
|
||||
.mockResolvedValueOnce({
|
||||
...response({ questionnaire_prompted_at: promptedAt }),
|
||||
questionnaire_prompt_result: { status: "already_prompted" },
|
||||
})
|
||||
.mockResolvedValueOnce({ code: "questionnaire_revision_conflict" });
|
||||
render(
|
||||
<TestModal
|
||||
initialState={{ ...baseState, questionnaire_prompted_at: promptedAt }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Skip" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(
|
||||
screen.queryByText("We couldn't save your choice. Try again."),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a skipped questionnaire closed", async () => {
|
||||
render(
|
||||
<TestModal
|
||||
initialState={{
|
||||
...baseState,
|
||||
questionnaire_prompted_at: promptedAt,
|
||||
questionnaire: questionnaire({
|
||||
status: "skipped",
|
||||
completed_at: null,
|
||||
skipped_at: promptedAt,
|
||||
}),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await act(async () => undefined);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(mocks.confirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["modal_dismissed_at", "first_save_at"] as const)(
|
||||
"ignores a stale reservation when %s changes while pending",
|
||||
async (field) => {
|
||||
const write = deferred<ConfirmedWriteResult>();
|
||||
mocks.confirmed.mockReturnValueOnce(write.promise);
|
||||
const view = render(<TestModal />);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
|
||||
view.rerender(
|
||||
<TestModal editorState={{ [field]: "2026-01-01T00:00:00Z" }} />,
|
||||
);
|
||||
await act(async () => write.resolve(reservedResponse()));
|
||||
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
ConfirmedWriteResult,
|
||||
OnboardingState,
|
||||
QuestionnaireStateV1,
|
||||
} from "@/store/onboarding/types";
|
||||
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
|
||||
|
||||
const {
|
||||
mockUpdateState,
|
||||
mockUpdateStateConfirmed,
|
||||
mockMutate,
|
||||
mockTelemetry,
|
||||
mutationState,
|
||||
flagState,
|
||||
stateOverrides,
|
||||
clerkUser,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUpdateState: vi.fn(),
|
||||
mockUpdateStateConfirmed: vi.fn(),
|
||||
mockMutate: vi.fn(),
|
||||
mockTelemetry: {
|
||||
registerVariant: vi.fn(),
|
||||
flowStarted: vi.fn(),
|
||||
modalOpened: vi.fn(),
|
||||
abVariantAssigned: vi.fn(),
|
||||
modalSkipped: vi.fn(),
|
||||
modalTemplateSelected: vi.fn(),
|
||||
questionnaireShown: vi.fn(() => true),
|
||||
},
|
||||
mutationState: { isPending: false },
|
||||
flagState: {
|
||||
variant: "template-first" as string | boolean | undefined,
|
||||
},
|
||||
stateOverrides: { current: {} as Partial<OnboardingState> },
|
||||
clerkUser: {
|
||||
current: { createdAt: new Date("2026-08-28T00:00:00Z") } as {
|
||||
createdAt: Date | null;
|
||||
} | null,
|
||||
},
|
||||
}));
|
||||
|
||||
const baseState: OnboardingState = {
|
||||
tour_completed_at: null,
|
||||
modal_dismissed_at: null,
|
||||
first_save_at: null,
|
||||
first_run_at: null,
|
||||
ab_variant: "template-first",
|
||||
user_intent: null,
|
||||
questionnaire_prompted_at: null,
|
||||
seen_canvas: null,
|
||||
seen_node_adder: null,
|
||||
seen_sidebar: null,
|
||||
seen_save_run: null,
|
||||
};
|
||||
|
||||
const completedQuestionnaire = {
|
||||
version: 1,
|
||||
response_id: "response",
|
||||
revision: 1,
|
||||
last_mutation_id: "mutation",
|
||||
status: "completed",
|
||||
role: "developer",
|
||||
company_context: "startup",
|
||||
scale_intent: "exploring",
|
||||
referral_source: "search",
|
||||
completed_at: "2026-01-01T00:00:00Z",
|
||||
skipped_at: null,
|
||||
deferred_at: null,
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
defer_prompt_count: 0,
|
||||
} satisfies QuestionnaireStateV1;
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagVariantKey: () => flagState.variant,
|
||||
}));
|
||||
vi.mock("@/hooks/useUser", () => ({
|
||||
useUser: () => ({
|
||||
get: () =>
|
||||
clerkUser.current && {
|
||||
id: "user-a",
|
||||
email: "",
|
||||
name: "",
|
||||
createdAt: clerkUser.current.createdAt ?? undefined,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/useFeatureFlag", () => ({
|
||||
useFeatureFlag: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/store/onboarding/useOnboardingState", () => ({
|
||||
useOnboardingState: () => ({
|
||||
state: {
|
||||
...baseState,
|
||||
...stateOverrides.current,
|
||||
ab_variant: flagState.variant,
|
||||
},
|
||||
isNewUser: true,
|
||||
updateState: mockUpdateState,
|
||||
updateStateConfirmed: mockUpdateStateConfirmed,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./CopilotCTAStep", async () => {
|
||||
const { useEffect } = await import("react");
|
||||
return {
|
||||
CopilotCTAStep: (props: {
|
||||
onSkip: () => void;
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
props.onBusyChange?.(true);
|
||||
}, [props]);
|
||||
return (
|
||||
<>
|
||||
<div>copilot-header</div>
|
||||
<div>copilot-body</div>
|
||||
<div>
|
||||
<button type="button" onClick={props.onSkip}>
|
||||
child-skip
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/routes/workflows/hooks/useGlobalWorkflowsQuery", () => ({
|
||||
useGlobalWorkflowsQuery: () => ({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/routes/workflows/hooks/useCreateWorkflowMutation", () => ({
|
||||
useCreateWorkflowMutation: () => ({
|
||||
mutate: mockMutate,
|
||||
isPending: mutationState.isPending,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/util/onboarding/OnboardingTelemetry", () => ({
|
||||
OnboardingTelemetry: mockTelemetry,
|
||||
}));
|
||||
|
||||
import {
|
||||
DECIDING_PLACEHOLDER_DELAY_MS,
|
||||
GetStartedModal,
|
||||
} from "./GetStartedModal";
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateStateConfirmed.mockResolvedValue({
|
||||
onboarding_state: { ...baseState, ...stateOverrides.current },
|
||||
launch_date_at_signup: "2026-08-27T00:00:00Z",
|
||||
recovery_guidance_assignment: null,
|
||||
questionnaire_prompt_result: { status: "flag_disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mutationState.isPending = false;
|
||||
flagState.variant = "template-first";
|
||||
stateOverrides.current = {};
|
||||
clerkUser.current = { createdAt: new Date("2026-08-28T00:00:00Z") };
|
||||
});
|
||||
|
||||
async function renderTemplatesStep() {
|
||||
render(<GetStartedModal />, { wrapper: MemoryRouter });
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /Fill out forms/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
}
|
||||
|
||||
describe("GetStartedModal progress", () => {
|
||||
it("uses server-owned reservation state instead of a frontend questionnaire flag", async () => {
|
||||
render(<GetStartedModal />, { wrapper: MemoryRouter });
|
||||
expect(await screen.findByText("STEP 1 OF 2")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
|
||||
expect(screen.queryByText("STEP 1 OF 3")).toBeNull();
|
||||
expect(vi.mocked(useFeatureFlag)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, "ineligible"],
|
||||
[false, "flag_disabled"],
|
||||
] as const)(
|
||||
"renders no editor content when the editor flag is %s",
|
||||
async (variant, status) => {
|
||||
flagState.variant = variant;
|
||||
mockUpdateStateConfirmed.mockResolvedValueOnce({
|
||||
onboarding_state: baseState,
|
||||
launch_date_at_signup: "2026-08-27T00:00:00Z",
|
||||
recovery_guidance_assignment: null,
|
||||
questionnaire_prompt_result: { status },
|
||||
});
|
||||
render(<GetStartedModal />, { wrapper: MemoryRouter });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateStateConfirmed).toHaveBeenCalledOnce(),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.queryByText("What do you want to automate?")).toBeNull();
|
||||
expect(screen.queryByText("Pick a template to start")).toBeNull();
|
||||
expect(screen.queryByText("copilot-header")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps editor-only final steps and existing responses closed", async () => {
|
||||
stateOverrides.current = {
|
||||
user_intent: "fill_forms",
|
||||
questionnaire: null,
|
||||
};
|
||||
const { unmount } = render(<GetStartedModal />, {
|
||||
wrapper: MemoryRouter,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("STEP 2 OF 2")).toBeTruthy();
|
||||
expect(screen.getByText("FINAL STEP")).toBeTruthy();
|
||||
expect(screen.queryByText("OPTIONAL")).toBeNull();
|
||||
unmount();
|
||||
mockUpdateStateConfirmed.mockClear();
|
||||
|
||||
stateOverrides.current = {
|
||||
user_intent: "fill_forms",
|
||||
questionnaire: completedQuestionnaire,
|
||||
};
|
||||
render(<GetStartedModal />, { wrapper: MemoryRouter });
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(mockUpdateStateConfirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the editor second step as final", async () => {
|
||||
await renderTemplatesStep();
|
||||
expect(screen.getByText("STEP 2 OF 2")).toBeTruthy();
|
||||
expect(screen.getByText("FINAL STEP")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GetStartedModal pre-cutoff users", () => {
|
||||
it("shows nothing and never calls the reserve endpoint for a pre-cutoff user with a prior save", async () => {
|
||||
clerkUser.current = { createdAt: new Date("2026-08-01T00:00:00Z") };
|
||||
stateOverrides.current = { first_save_at: "2026-08-20T00:00:00Z" };
|
||||
render(<GetStartedModal />, { wrapper: MemoryRouter });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(mockUpdateStateConfirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a pre-cutoff new user straight to the editor without a reserve call", async () => {
|
||||
clerkUser.current = { createdAt: new Date("2026-08-01T00:00:00Z") };
|
||||
render(<GetStartedModal />, { wrapper: MemoryRouter });
|
||||
|
||||
expect(
|
||||
await screen.findByText("What do you want to automate?"),
|
||||
).toBeTruthy();
|
||||
expect(mockUpdateStateConfirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GetStartedModal deciding placeholder", () => {
|
||||
it("closes without ever showing the placeholder when there is nothing to show", async () => {
|
||||
flagState.variant = undefined;
|
||||
let resolveReservation!: (value: ConfirmedWriteResult) => void;
|
||||
mockUpdateStateConfirmed.mockReturnValueOnce(
|
||||
new Promise<ConfirmedWriteResult>((resolve) => {
|
||||
resolveReservation = resolve;
|
||||
}),
|
||||
);
|
||||
render(<GetStartedModal />, { wrapper: MemoryRouter });
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(screen.getByText("Checking your onboarding setup.")).toBeTruthy();
|
||||
expect(dialog.className).toContain("opacity-0");
|
||||
expect((dialog.previousElementSibling as HTMLElement).className).toContain(
|
||||
"opacity-0",
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
resolveReservation({
|
||||
onboarding_state: baseState,
|
||||
launch_date_at_signup: "2026-08-27T00:00:00Z",
|
||||
recovery_guidance_assignment: null,
|
||||
questionnaire_prompt_result: { status: "ineligible" },
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("reveals the placeholder only after the grace delay while still deciding", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mockUpdateStateConfirmed.mockReturnValueOnce(
|
||||
new Promise<ConfirmedWriteResult>(() => {}),
|
||||
);
|
||||
render(<GetStartedModal />, { wrapper: MemoryRouter });
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.className).toContain("opacity-0");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DECIDING_PLACEHOLDER_DELAY_MS);
|
||||
});
|
||||
expect(dialog.className).not.toContain("opacity-0");
|
||||
expect(
|
||||
(dialog.previousElementSibling as HTMLElement).className,
|
||||
).not.toContain("opacity-0");
|
||||
expect(screen.getByText("Checking your onboarding setup.")).toBeTruthy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GetStartedModal skip-while-creating guard", () => {
|
||||
it("blocks Skip while a template workflow creation is in flight", async () => {
|
||||
mutationState.isPending = true;
|
||||
await renderTemplatesStep();
|
||||
const skip = screen.getByRole("button", { name: "Skip" });
|
||||
expect((skip as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(skip);
|
||||
expect(mockTelemetry.modalSkipped).not.toHaveBeenCalled();
|
||||
expect(mockUpdateState).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modal_dismissed_at: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses on Skip when no creation is pending", async () => {
|
||||
mutationState.isPending = false;
|
||||
await renderTemplatesStep();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
|
||||
expect(mockTelemetry.modalSkipped).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modal_dismissed_at: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores skip and Escape-close while the copilot step reports busy", async () => {
|
||||
flagState.variant = "copilot-first";
|
||||
await renderTemplatesStep();
|
||||
const chrome = screen.getByTestId("copilot-chrome");
|
||||
expect(chrome.children).toHaveLength(3);
|
||||
expect(screen.getByText("STEP 2 OF 2")).toBeTruthy();
|
||||
expect(screen.getByText("FINAL STEP")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "child-skip" }));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(mockTelemetry.modalSkipped).not.toHaveBeenCalled();
|
||||
expect(mockUpdateState).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modal_dismissed_at: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,815 +0,0 @@
|
|||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useFeatureFlagVariantKey } from "posthog-js/react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useOnboardingState } from "@/store/onboarding/useOnboardingState";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useActiveOrgId } from "@/store/ActiveOrgContext";
|
||||
import {
|
||||
isQuestionnaireUserIntentV1,
|
||||
type QuestionnaireAnswersV1,
|
||||
type QuestionnairePatchV1,
|
||||
type QuestionnaireStateV1,
|
||||
} from "@/store/onboarding/types";
|
||||
import { OnboardingTelemetry } from "@/util/onboarding/OnboardingTelemetry";
|
||||
import { ClickIcon } from "@/components/icons/ClickIcon";
|
||||
import { ExtractIcon } from "@/components/icons/ExtractIcon";
|
||||
import { SearchIcon } from "@/components/icons/SearchIcon";
|
||||
import { CompassIcon } from "@/components/icons/CompassIcon";
|
||||
import { cn } from "@/util/utils";
|
||||
import { useGlobalWorkflowsQuery } from "@/routes/workflows/hooks/useGlobalWorkflowsQuery";
|
||||
import { useCreateWorkflowMutation } from "@/routes/workflows/hooks/useCreateWorkflowMutation";
|
||||
import { convert } from "@/routes/workflows/editor/workflowEditorUtils";
|
||||
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
|
||||
import {
|
||||
getTemplatesForIntent,
|
||||
getTemplateIcon,
|
||||
getSetupTime,
|
||||
} from "./templateUtils";
|
||||
import { CopilotCTAStep } from "./CopilotCTAStep";
|
||||
import { QuestionnaireDetailsStep } from "./QuestionnaireDetailsStep";
|
||||
import { OnboardingOptionRow } from "./OnboardingOptionRow";
|
||||
import { OnboardingStepProgress } from "./OnboardingStepProgress";
|
||||
import { OnboardingStepShell } from "./OnboardingStepShell";
|
||||
import {
|
||||
type ABVariant,
|
||||
DEFAULT_VARIANT,
|
||||
VARIANTS,
|
||||
isABVariant,
|
||||
EXPERIMENT,
|
||||
} from "@/util/onboarding/experimentConfig";
|
||||
|
||||
const SURFACE = "discover" as const;
|
||||
|
||||
export const DECIDING_PLACEHOLDER_DELAY_MS = 500;
|
||||
|
||||
// Mirrors the backend questionnaire signup cutoff: reservation can only answer
|
||||
// ineligible for accounts created before it, so those users skip the reserve
|
||||
// call entirely. The server stays authoritative for everyone else.
|
||||
export const QUESTIONNAIRE_SIGNUP_CUTOFF_MS = Date.UTC(2026, 7, 27);
|
||||
|
||||
type EditorStep = "intent" | "templates";
|
||||
type DiscoverOnboardingOwner =
|
||||
| { kind: "deciding" }
|
||||
| { kind: "questionnaire"; step: "intent" }
|
||||
| { kind: "questionnaire"; step: "details" }
|
||||
| { kind: "editor" }
|
||||
| { kind: "closed" };
|
||||
|
||||
type IntentOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
};
|
||||
|
||||
const intentOptions: IntentOption[] = [
|
||||
{
|
||||
id: "fill_forms",
|
||||
label: "Fill out forms",
|
||||
description: "Automate form submissions across websites",
|
||||
icon: ClickIcon,
|
||||
},
|
||||
{
|
||||
id: "extract_data",
|
||||
label: "Extract data from websites",
|
||||
description: "Scrape and collect data at scale",
|
||||
icon: ExtractIcon,
|
||||
},
|
||||
{
|
||||
id: "monitor_website",
|
||||
label: "Monitor a website for changes",
|
||||
description: "Track updates and get notified",
|
||||
icon: SearchIcon,
|
||||
},
|
||||
{
|
||||
id: "something_else",
|
||||
label: "Something else",
|
||||
description: "Other browser automation tasks",
|
||||
icon: CompassIcon,
|
||||
},
|
||||
];
|
||||
|
||||
function resolveVariant(
|
||||
stateVariant: string | null | undefined,
|
||||
flagVariant: string | boolean | undefined,
|
||||
): ABVariant {
|
||||
if (isABVariant(stateVariant)) {
|
||||
return stateVariant;
|
||||
}
|
||||
return flagVariant === VARIANTS.COPILOT_FIRST
|
||||
? VARIANTS.COPILOT_FIRST
|
||||
: DEFAULT_VARIANT;
|
||||
}
|
||||
|
||||
function answersFromQuestionnaire(
|
||||
questionnaire: QuestionnaireStateV1 | null,
|
||||
): QuestionnaireAnswersV1 | null {
|
||||
if (!questionnaire) return null;
|
||||
const {
|
||||
role,
|
||||
company_context,
|
||||
scale_intent,
|
||||
referral_source,
|
||||
referral_source_detail,
|
||||
} = questionnaire;
|
||||
return role && company_context && scale_intent && referral_source
|
||||
? {
|
||||
role,
|
||||
company_context,
|
||||
scale_intent,
|
||||
referral_source,
|
||||
referral_source_detail,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function GetStartedModalForUser() {
|
||||
const { state, isLoading, isNewUser, updateState, updateStateConfirmed } =
|
||||
useOnboardingState();
|
||||
const user = useUser().get();
|
||||
const flagVariant = useFeatureFlagVariantKey(EXPERIMENT.flagKey);
|
||||
const [owner, setOwner] = useState<DiscoverOnboardingOwner>({
|
||||
kind: "deciding",
|
||||
});
|
||||
const questionnaireOwner = owner.kind === "questionnaire";
|
||||
const [step, setStep] = useState<EditorStep>("intent");
|
||||
const [selectedIntent, setSelectedIntent] = useState<string | null>(null);
|
||||
const [questionnaire, setQuestionnaire] =
|
||||
useState<QuestionnaireStateV1 | null>(null);
|
||||
const [questionnaireOrganizationId, setQuestionnaireOrganizationId] =
|
||||
useState<string | null>(null);
|
||||
const [intentPending, setIntentPending] = useState(false);
|
||||
const [decidingPlaceholderVisible, setDecidingPlaceholderVisible] =
|
||||
useState(false);
|
||||
const [questionnairePending, setQuestionnairePending] = useState(false);
|
||||
const [intentError, setIntentError] = useState<string | null>(null);
|
||||
const [copilotStepBusy, setCopilotStepBusy] = useState(false);
|
||||
const openedRef = useRef(false);
|
||||
const variantAssignedRef = useRef(false);
|
||||
const confirmedEventsRef = useRef(new Set<string>());
|
||||
const openVisitRef = useRef(0);
|
||||
const isOpenRef = useRef(false);
|
||||
const reservationStartedRef = useRef(false);
|
||||
const reservationEditorStateRef = useRef<
|
||||
readonly [string | null, string | null, string | null] | null
|
||||
>(null);
|
||||
const ownerGenerationRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const latestStateRef = useRef(state);
|
||||
const questionnaireObservedDuringReservationRef = useRef(false);
|
||||
latestStateRef.current = state;
|
||||
const initialSkipMutationIdRef = useRef(crypto.randomUUID());
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const variant = resolveVariant(state?.ab_variant, flagVariant);
|
||||
|
||||
const { data: globalTemplates = [], isLoading: templatesLoading } =
|
||||
useGlobalWorkflowsQuery();
|
||||
const createWorkflowMutation = useCreateWorkflowMutation();
|
||||
|
||||
const onboardingEnabled = isABVariant(flagVariant);
|
||||
const editorOpen =
|
||||
owner.kind === "editor" &&
|
||||
onboardingEnabled &&
|
||||
state !== null &&
|
||||
state.modal_dismissed_at === null &&
|
||||
state.first_save_at === null &&
|
||||
state.questionnaire_prompted_at == null &&
|
||||
state.questionnaire == null &&
|
||||
(isOpenRef.current || isNewUser);
|
||||
const reservationCandidate =
|
||||
owner.kind === "deciding" &&
|
||||
!isLoading &&
|
||||
state !== null &&
|
||||
state.questionnaire == null;
|
||||
const questionnaireLocallyIneligible =
|
||||
user?.createdAt == null ||
|
||||
user.createdAt.getTime() < QUESTIONNAIRE_SIGNUP_CUTOFF_MS;
|
||||
const decidingOpen = reservationCandidate;
|
||||
const ownedOpen = questionnaireOwner || editorOpen;
|
||||
const isOpen = decidingOpen || ownedOpen;
|
||||
// The deciding dialog must block interaction but stay invisible at first, so
|
||||
// a reservation that resolves to "nothing to show" never flashes the
|
||||
// placeholder; it is revealed only if the check outlasts the grace delay.
|
||||
const decidingHidden =
|
||||
owner.kind === "deciding" && !decidingPlaceholderVisible;
|
||||
useEffect(() => {
|
||||
if (!decidingOpen) return;
|
||||
const timer = window.setTimeout(
|
||||
() => setDecidingPlaceholderVisible(true),
|
||||
DECIDING_PLACEHOLDER_DELAY_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [decidingOpen]);
|
||||
useLayoutEffect(() => {
|
||||
if (ownedOpen && !isOpenRef.current) openVisitRef.current += 1;
|
||||
isOpenRef.current = ownedOpen;
|
||||
}, [ownedOpen]);
|
||||
useLayoutEffect(() => {
|
||||
if (!state) return;
|
||||
const editorState = [
|
||||
state.modal_dismissed_at,
|
||||
state.first_save_at,
|
||||
state.user_intent,
|
||||
] as const;
|
||||
if (
|
||||
owner.kind === "deciding" &&
|
||||
reservationStartedRef.current &&
|
||||
(state.questionnaire_prompted_at || state.questionnaire)
|
||||
) {
|
||||
questionnaireObservedDuringReservationRef.current = true;
|
||||
}
|
||||
const reservationEditorState = reservationEditorStateRef.current;
|
||||
if (
|
||||
owner.kind === "deciding" &&
|
||||
reservationStartedRef.current &&
|
||||
reservationEditorState &&
|
||||
(editorState[0] !== reservationEditorState[0] ||
|
||||
editorState[1] !== reservationEditorState[1])
|
||||
) {
|
||||
ownerGenerationRef.current += 1;
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
if (owner.kind !== "deciding" || isLoading) {
|
||||
return;
|
||||
}
|
||||
if (reservationStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!reservationCandidate) {
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
if (questionnaireLocallyIneligible) {
|
||||
if (
|
||||
state.modal_dismissed_at !== null ||
|
||||
state.first_save_at !== null ||
|
||||
state.questionnaire_prompted_at != null
|
||||
) {
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
if (state.user_intent) {
|
||||
setSelectedIntent(state.user_intent);
|
||||
setStep("templates");
|
||||
}
|
||||
setOwner({ kind: "editor" });
|
||||
return;
|
||||
}
|
||||
reservationStartedRef.current = true;
|
||||
reservationEditorStateRef.current = editorState;
|
||||
const generation = ++ownerGenerationRef.current;
|
||||
void updateStateConfirmed({
|
||||
questionnaire_prompt: { version: 1, action: "reserve" },
|
||||
})
|
||||
.then((response) => {
|
||||
if (!mountedRef.current || generation !== ownerGenerationRef.current)
|
||||
return;
|
||||
if ("code" in response) {
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
const result = response.questionnaire_prompt_result;
|
||||
if (
|
||||
result?.status === "reserved" &&
|
||||
typeof result.prompted_at === "string" &&
|
||||
result.prompted_at.length > 0
|
||||
) {
|
||||
OnboardingTelemetry.questionnaireShown({
|
||||
primaryIntent: null,
|
||||
promptReason: "initial",
|
||||
organizationId: response.organization_id ?? null,
|
||||
});
|
||||
setOwner({ kind: "questionnaire", step: "intent" });
|
||||
} else if (
|
||||
result?.status === "flag_disabled" ||
|
||||
result?.status === "ineligible"
|
||||
) {
|
||||
const latestState = latestStateRef.current;
|
||||
if (
|
||||
questionnaireObservedDuringReservationRef.current ||
|
||||
!latestState ||
|
||||
latestState.questionnaire_prompted_at ||
|
||||
latestState.questionnaire ||
|
||||
latestState.modal_dismissed_at !== null ||
|
||||
latestState.first_save_at !== null
|
||||
) {
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
const fallbackIntent =
|
||||
latestState.user_intent ??
|
||||
reservationEditorStateRef.current?.[2] ??
|
||||
null;
|
||||
if (fallbackIntent) {
|
||||
setSelectedIntent(fallbackIntent);
|
||||
setStep("templates");
|
||||
}
|
||||
setOwner({ kind: "editor" });
|
||||
} else if (
|
||||
result?.status === "already_prompted" &&
|
||||
response.onboarding_state.questionnaire == null
|
||||
) {
|
||||
// A reservation that was never answered (refresh, closed tab, or another tab reserving first).
|
||||
OnboardingTelemetry.questionnaireShown({
|
||||
primaryIntent: null,
|
||||
promptReason: "resume",
|
||||
organizationId: response.organization_id ?? null,
|
||||
});
|
||||
setOwner({ kind: "questionnaire", step: "intent" });
|
||||
} else {
|
||||
setOwner({ kind: "closed" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current && generation === ownerGenerationRef.current) {
|
||||
setOwner({ kind: "closed" });
|
||||
}
|
||||
});
|
||||
}, [
|
||||
isLoading,
|
||||
owner.kind,
|
||||
questionnaireLocallyIneligible,
|
||||
reservationCandidate,
|
||||
state,
|
||||
updateStateConfirmed,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && owner.kind !== "deciding" && !openedRef.current) {
|
||||
openedRef.current = true;
|
||||
if (owner.kind === "editor") {
|
||||
OnboardingTelemetry.registerVariant(variant);
|
||||
}
|
||||
OnboardingTelemetry.flowStarted(SURFACE);
|
||||
OnboardingTelemetry.modalOpened(SURFACE);
|
||||
}
|
||||
}, [isOpen, owner.kind, variant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
owner.kind !== "editor" ||
|
||||
!state ||
|
||||
state.ab_variant !== null ||
|
||||
variantAssignedRef.current
|
||||
)
|
||||
return;
|
||||
// only persist a variant once the flag resolves to a real arm; an unloaded/disabled flag (pre-load, 0% rollout, rollback) must not bias the split
|
||||
if (!isABVariant(flagVariant)) return;
|
||||
variantAssignedRef.current = true;
|
||||
updateState({ ab_variant: variant });
|
||||
OnboardingTelemetry.abVariantAssigned(SURFACE, variant);
|
||||
}, [owner.kind, state, variant, flagVariant, updateState]);
|
||||
|
||||
function handleSelectIntent(intentId: string) {
|
||||
if (intentPending) return;
|
||||
setSelectedIntent(intentId);
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
if (!selectedIntent || intentPending) {
|
||||
return;
|
||||
}
|
||||
setIntentError(null);
|
||||
if (owner.kind === "editor") {
|
||||
updateState({ user_intent: selectedIntent });
|
||||
setStep("templates");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
owner.kind !== "questionnaire" ||
|
||||
!isQuestionnaireUserIntentV1(selectedIntent)
|
||||
) {
|
||||
setIntentError("We couldn't save your choice. Try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const visitId = openVisitRef.current;
|
||||
setIntentPending(true);
|
||||
try {
|
||||
const response = await updateStateConfirmed({
|
||||
user_intent: selectedIntent,
|
||||
});
|
||||
if ("code" in response) {
|
||||
throw new Error(`Intent confirmation failed: ${response.code}`);
|
||||
}
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
!isOpenRef.current ||
|
||||
visitId !== openVisitRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextState = response.onboarding_state;
|
||||
setQuestionnaireOrganizationId(
|
||||
response.project_owner_supported === true
|
||||
? (response.organization_id ?? null)
|
||||
: null,
|
||||
);
|
||||
if (!isQuestionnaireUserIntentV1(nextState.user_intent)) {
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
const confirmedQuestionnaire = nextState.questionnaire ?? null;
|
||||
if (confirmedQuestionnaire !== null) {
|
||||
if (questionnaire?.response_id !== confirmedQuestionnaire.response_id) {
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
setQuestionnaire(confirmedQuestionnaire);
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
setOwner({ kind: "questionnaire", step: "details" });
|
||||
} catch {
|
||||
if (isOpenRef.current && visitId === openVisitRef.current) {
|
||||
setIntentError("We couldn't save your choice. Try again.");
|
||||
}
|
||||
} finally {
|
||||
setIntentPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (owner.kind === "questionnaire") {
|
||||
setOwner({ kind: "questionnaire", step: "intent" });
|
||||
return;
|
||||
}
|
||||
setStep("intent");
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
if (owner.kind === "deciding" || intentPending || questionnairePending)
|
||||
return;
|
||||
if (owner.kind === "questionnaire") {
|
||||
if (questionnaire !== null) {
|
||||
OnboardingTelemetry.modalSkipped(SURFACE);
|
||||
updateState({ modal_dismissed_at: new Date().toISOString() });
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
setIntentError(null);
|
||||
void handleQuestionnaireAction({
|
||||
version: 1,
|
||||
mutation_id: initialSkipMutationIdRef.current,
|
||||
expected_revision: 0,
|
||||
action: "skip",
|
||||
}).catch(() =>
|
||||
setIntentError("We couldn't save your choice. Try again."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (createWorkflowMutation.isPending || copilotStepBusy) {
|
||||
return;
|
||||
}
|
||||
OnboardingTelemetry.modalSkipped(SURFACE);
|
||||
updateState({ modal_dismissed_at: new Date().toISOString() });
|
||||
}
|
||||
|
||||
function emitQuestionnaireEvent(
|
||||
patch: QuestionnairePatchV1,
|
||||
previousQuestionnaire: QuestionnaireStateV1 | null,
|
||||
nextQuestionnaire: QuestionnaireStateV1,
|
||||
primaryIntent: string | null,
|
||||
organizationId: string | null,
|
||||
) {
|
||||
if (nextQuestionnaire.last_mutation_id !== patch.mutation_id) return;
|
||||
const eventName = `onboarding_questionnaire_${
|
||||
patch.action === "skip"
|
||||
? "skipped"
|
||||
: patch.action === "complete"
|
||||
? "completed"
|
||||
: "updated"
|
||||
}`;
|
||||
const eventKey = `${organizationId}:${nextQuestionnaire.response_id}:${nextQuestionnaire.revision}:${eventName}`;
|
||||
if (confirmedEventsRef.current.has(eventKey)) return;
|
||||
if (patch.action === "skip") {
|
||||
if (nextQuestionnaire.status !== "skipped") return;
|
||||
confirmedEventsRef.current.add(eventKey);
|
||||
OnboardingTelemetry.questionnaireSkipped({
|
||||
responseId: nextQuestionnaire.response_id,
|
||||
revision: nextQuestionnaire.revision,
|
||||
disposition: "skip",
|
||||
statusAfter: "skipped",
|
||||
organizationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isQuestionnaireUserIntentV1(primaryIntent)) return;
|
||||
const answers = answersFromQuestionnaire(nextQuestionnaire);
|
||||
if (nextQuestionnaire.status !== "completed" || !answers) return;
|
||||
|
||||
if (patch.action === "complete") {
|
||||
confirmedEventsRef.current.add(eventKey);
|
||||
OnboardingTelemetry.questionnaireCompleted({
|
||||
responseId: nextQuestionnaire.response_id,
|
||||
revision: nextQuestionnaire.revision,
|
||||
primaryIntent,
|
||||
answers,
|
||||
organizationId,
|
||||
projectOwner: nextQuestionnaire.project_owner,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!previousQuestionnaire) return;
|
||||
confirmedEventsRef.current.add(eventKey);
|
||||
OnboardingTelemetry.questionnaireUpdated({
|
||||
responseId: nextQuestionnaire.response_id,
|
||||
revision: nextQuestionnaire.revision,
|
||||
primaryIntent,
|
||||
previousStatus: previousQuestionnaire.status,
|
||||
answers,
|
||||
organizationId,
|
||||
projectOwner: nextQuestionnaire.project_owner,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleQuestionnaireAction(patch: QuestionnairePatchV1) {
|
||||
if (!questionnaireOwner) {
|
||||
throw new Error("Questionnaire visit is closed");
|
||||
}
|
||||
const visitId = openVisitRef.current;
|
||||
const previousQuestionnaire = questionnaire;
|
||||
setIntentError(null);
|
||||
setQuestionnairePending(true);
|
||||
try {
|
||||
const response = await updateStateConfirmed({ questionnaire: patch });
|
||||
if ("code" in response) {
|
||||
// A skip at revision 0 conflicts only when another tab already answered or skipped.
|
||||
if (
|
||||
patch.action === "skip" &&
|
||||
patch.expected_revision === 0 &&
|
||||
response.code === "questionnaire_revision_conflict"
|
||||
) {
|
||||
setOwner({ kind: "closed" });
|
||||
return;
|
||||
}
|
||||
if (response.code === "project_owner_invalid")
|
||||
throw new Error(response.code);
|
||||
throw new Error(`Questionnaire confirmation failed: ${response.code}`);
|
||||
}
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
!isOpenRef.current ||
|
||||
visitId !== openVisitRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextQuestionnaire = response.onboarding_state.questionnaire;
|
||||
if (!nextQuestionnaire) {
|
||||
throw new Error("Questionnaire confirmation was missing");
|
||||
}
|
||||
emitQuestionnaireEvent(
|
||||
patch,
|
||||
previousQuestionnaire,
|
||||
nextQuestionnaire,
|
||||
response.onboarding_state.user_intent,
|
||||
response.organization_id ?? null,
|
||||
);
|
||||
setQuestionnaire(nextQuestionnaire);
|
||||
if (patch.action === "complete") {
|
||||
updateState({ modal_dismissed_at: new Date().toISOString() });
|
||||
}
|
||||
setOwner({ kind: "closed" });
|
||||
} finally {
|
||||
setQuestionnairePending(false);
|
||||
}
|
||||
}
|
||||
function handleTemplateSelect(template: WorkflowApiResponse) {
|
||||
if (createWorkflowMutation.isPending) return;
|
||||
OnboardingTelemetry.modalTemplateSelected(
|
||||
SURFACE,
|
||||
template.workflow_permanent_id,
|
||||
selectedIntent!,
|
||||
);
|
||||
const cloned = convert(
|
||||
{ ...template, title: `${template.title} (copy)` },
|
||||
{ asNewWorkflow: true },
|
||||
);
|
||||
// Completion telemetry fires from useCreateWorkflowMutation (it owns the
|
||||
// navigation that unmounts this modal); first_save_at prevents re-display,
|
||||
// so no dismiss write is needed here.
|
||||
createWorkflowMutation.mutate({ ...cloned, _via: "onboarding_template" });
|
||||
}
|
||||
|
||||
const filteredTemplates =
|
||||
selectedIntent && globalTemplates.length > 0
|
||||
? getTemplatesForIntent(globalTemplates, selectedIntent)
|
||||
: [];
|
||||
const stepCount = 2;
|
||||
const view =
|
||||
owner.kind === "questionnaire"
|
||||
? owner.step
|
||||
: owner.kind === "editor" && editorOpen
|
||||
? step
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => handleSkip()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"z-[2147480003] grid max-h-[calc(100dvh-2rem)] max-w-xl grid-rows-[minmax(0,1fr)] overflow-hidden overscroll-contain [&>button]:right-2 [&>button]:top-2 [&>button]:inline-flex [&>button]:size-11 [&>button]:touch-manipulation [&>button]:items-center [&>button]:justify-center [&>button]:motion-reduce:transition-none",
|
||||
decidingHidden && "opacity-0",
|
||||
)}
|
||||
overlayClassName={decidingHidden ? "opacity-0" : undefined}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
{view === null ? (
|
||||
owner.kind === "deciding" && decidingOpen ? (
|
||||
<DialogHeader className="pr-12">
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Getting started
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Checking your onboarding setup.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
) : null
|
||||
) : view === "intent" ? (
|
||||
<OnboardingStepShell stepIndex={1} stepCount={stepCount}>
|
||||
<DialogHeader className="pr-12">
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
What do you want to automate?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the option that best describes your goal. You can always
|
||||
change this later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto overscroll-contain pr-1">
|
||||
{intentOptions.map((option) => (
|
||||
<OnboardingOptionRow
|
||||
key={option.id}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={selectedIntent === option.id}
|
||||
disabled={intentPending}
|
||||
onClick={() => handleSelectIntent(option.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{intentError ? (
|
||||
<Alert role="status" aria-live="polite" variant="destructive">
|
||||
<AlertDescription>{intentError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<DialogFooter className="sticky bottom-0 z-10 -mx-6 -mb-6 flex-col gap-2 border-t border-border bg-background px-6 pb-[max(1rem,env(safe-area-inset-bottom))] pt-4 sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
|
||||
onClick={handleSkip}
|
||||
disabled={intentPending}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
|
||||
disabled={!selectedIntent || intentPending}
|
||||
onClick={() => void handleContinue()}
|
||||
>
|
||||
{intentPending ? (
|
||||
<ReloadIcon
|
||||
aria-hidden
|
||||
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</OnboardingStepShell>
|
||||
) : owner.kind === "questionnaire" && owner.step === "details" ? (
|
||||
<QuestionnaireDetailsStep
|
||||
completionAction={questionnaire ? "update" : "complete"}
|
||||
expectedRevision={questionnaire?.revision ?? 0}
|
||||
organizationId={questionnaireOrganizationId}
|
||||
initialAnswers={answersFromQuestionnaire(questionnaire)}
|
||||
externalError={intentError}
|
||||
isPending={questionnairePending}
|
||||
onAction={handleQuestionnaireAction}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
) : variant === VARIANTS.COPILOT_FIRST ? (
|
||||
<div className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-4">
|
||||
<OnboardingStepProgress
|
||||
stepIndex={stepCount}
|
||||
stepCount={stepCount}
|
||||
chip="final"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-4 [&>div:first-child]:pr-12 [&>div:nth-child(2)]:min-h-0 [&>div:nth-child(2)]:overflow-y-auto [&>div:nth-child(2)]:overscroll-contain [&>div:nth-child(2)_.animate-spin]:motion-reduce:animate-none",
|
||||
"[&>div:nth-child(2)>button]:min-h-[3.5rem] [&>div:nth-child(2)>button]:touch-manipulation [&>div:nth-child(2)>button]:flex-row [&>div:nth-child(2)>button]:flex-wrap [&>div:nth-child(2)>button]:items-center [&>div:nth-child(2)>button]:py-3 [&>div:nth-child(2)>button]:motion-reduce:transition-none [&>div:nth-child(2)>div]:h-[3.5rem] [&>div:nth-child(2)>p]:col-span-1 [&>div:nth-child(2)]:grid-cols-1",
|
||||
"[&>div:nth-child(2)>button>div]:flex-1 [&>div:nth-child(2)>button>div]:basis-[calc(100%-2rem)] [&>div:nth-child(2)>button>div]:sm:basis-0 [&>div:nth-child(2)>button>svg]:h-5 [&>div:nth-child(2)>button>svg]:w-5 [&>div:nth-child(2)>button>svg]:shrink-0",
|
||||
"[&>div:nth-child(2)>button>span]:basis-full [&>div:nth-child(2)>button>span]:pl-8 [&>div:nth-child(2)>button>span]:tabular-nums [&>div:nth-child(2)>button>span]:sm:ml-auto [&>div:nth-child(2)>button>span]:sm:basis-auto [&>div:nth-child(2)>button>span]:sm:pl-0 [&>div:nth-child(2)>button>span]:sm:text-right",
|
||||
"[&>div:last-child]:sticky [&>div:last-child]:bottom-0 [&>div:last-child]:z-10 [&>div:last-child]:-mx-6 [&>div:last-child]:-mb-6 [&>div:last-child]:border-t [&>div:last-child]:border-border [&>div:last-child]:bg-background [&>div:last-child]:px-6 [&>div:last-child]:pb-[max(1rem,env(safe-area-inset-bottom))] [&>div:last-child]:pt-4",
|
||||
"[&>div:last-child_button]:min-h-11 [&>div:last-child_button]:w-full [&>div:last-child_button]:min-w-11 [&>div:last-child_button]:touch-manipulation [&>div:last-child_button]:motion-reduce:transition-none [&>div:last-child_button]:sm:w-auto",
|
||||
)}
|
||||
data-testid="copilot-chrome"
|
||||
>
|
||||
<CopilotCTAStep
|
||||
selectedIntent={selectedIntent!}
|
||||
onBack={handleBack}
|
||||
onSkip={handleSkip}
|
||||
onDismiss={() =>
|
||||
updateState({ modal_dismissed_at: new Date().toISOString() })
|
||||
}
|
||||
onBusyChange={setCopilotStepBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<OnboardingStepShell
|
||||
stepIndex={stepCount}
|
||||
stepCount={stepCount}
|
||||
chip="final"
|
||||
>
|
||||
<DialogHeader className="pr-12">
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Pick a template to start
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a pre-built workflow and customize it in the editor.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto overscroll-contain pr-1">
|
||||
{templatesLoading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[3.5rem] rounded-lg" />
|
||||
))
|
||||
) : filteredTemplates.length > 0 ? (
|
||||
filteredTemplates.map((template) => (
|
||||
<OnboardingOptionRow
|
||||
key={template.workflow_permanent_id}
|
||||
icon={getTemplateIcon(template)}
|
||||
label={template.title}
|
||||
description={template.description ?? ""}
|
||||
meta={`~${getSetupTime(template)} setup`}
|
||||
disabled={createWorkflowMutation.isPending}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No templates available yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="sticky bottom-0 z-10 -mx-6 -mb-6 flex-col gap-2 border-t border-border bg-background px-6 pb-[max(1rem,env(safe-area-inset-bottom))] pt-4 sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
|
||||
onClick={handleBack}
|
||||
disabled={createWorkflowMutation.isPending}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
|
||||
onClick={handleSkip}
|
||||
disabled={createWorkflowMutation.isPending}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</OnboardingStepShell>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function GetStartedModal() {
|
||||
const userId = useUser().get()?.id;
|
||||
const orgId = useActiveOrgId();
|
||||
return (
|
||||
<GetStartedModalForUser
|
||||
key={JSON.stringify([userId ?? null, orgId ?? null])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { GetStartedModal };
|
||||
|
|
@ -99,6 +99,26 @@ const rowDefinitions: Record<TrackKey, RowDefinition> = {
|
|||
links: [{ label: "Get an API key", to: "/settings#api-keys" }],
|
||||
howItWorks: DOCS_API_QUICKSTART_URL,
|
||||
},
|
||||
agent_edited: {
|
||||
title: "Edit an agent in the editor",
|
||||
why: "Change a step and save it to make the agent yours",
|
||||
links: [{ label: "Open your agents", to: "/agents" }],
|
||||
},
|
||||
questionnaire_completed: {
|
||||
title: "Answer the welcome questions",
|
||||
why: "Tell us what you want to automate",
|
||||
links: [{ label: "Answer them", to: "/welcome" }],
|
||||
},
|
||||
first_successful_run: {
|
||||
title: "Run your first agent",
|
||||
why: "Describe a task and let an agent do it",
|
||||
links: [{ label: "Start on Home", to: "/discover?focus=prompt" }],
|
||||
},
|
||||
run_feedback_given: {
|
||||
title: "Rate a run",
|
||||
why: "A thumbs up or down helps agents get better",
|
||||
links: [{ label: "Open runs", to: "/runs" }],
|
||||
},
|
||||
mcp_installed: {
|
||||
title: "Use Skyvern from Claude, Cursor, or ChatGPT",
|
||||
why: "Install the MCP server and run agents from your AI tools",
|
||||
|
|
@ -139,6 +159,8 @@ const productionOrder: readonly TrackKey[] = [
|
|||
"first_scheduled_run",
|
||||
"first_api_run",
|
||||
"mcp_installed",
|
||||
"agent_edited",
|
||||
"run_feedback_given",
|
||||
"credential_saved",
|
||||
];
|
||||
const intentFirstRow: Record<string, TrackKey> = {
|
||||
|
|
@ -435,6 +457,11 @@ function TrackRow({
|
|||
: "Upcoming step: "}
|
||||
</span>
|
||||
{definition.title}
|
||||
{item.reward_credits && !isDone && !isLocked ? (
|
||||
<span className="ml-2 whitespace-nowrap rounded-full bg-badge-success px-2 py-0.5 align-middle text-[11px] font-medium tabular-nums text-foreground">
|
||||
+{item.reward_credits.toLocaleString()} credits
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { Component, type ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
onError: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type State = { hasError: boolean };
|
||||
|
||||
class OnboardingErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(): void {
|
||||
this.props.onError();
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
return this.state.hasError ? null : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export { OnboardingErrorBoundary };
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import type { ComponentType, ReactNode } from "react";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
type Props = {
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
description?: string;
|
||||
meta?: ReactNode;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function OnboardingOptionRow(props: Readonly<Props>) {
|
||||
const {
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
meta,
|
||||
selected,
|
||||
disabled,
|
||||
onClick,
|
||||
} = props;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex min-h-[3.5rem] w-full touch-manipulation flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
|
||||
selected
|
||||
? "border-2 border-primary bg-primary/5 px-[15px] py-[11px]"
|
||||
: "border-border hover:border-primary/60 hover:bg-muted/50",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
<span aria-hidden="true" className="shrink-0">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 basis-[calc(100%-2rem)] sm:basis-0">
|
||||
<p className="truncate text-sm font-medium">{label}</p>
|
||||
{description ? (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{meta ? (
|
||||
<span className="basis-full pl-8 text-xs tabular-nums text-muted-foreground sm:ml-auto sm:basis-auto sm:pl-0 sm:text-right">
|
||||
{meta}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export { OnboardingOptionRow };
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { OnboardingStepProgress } from "./OnboardingStepProgress";
|
||||
|
||||
type Props = {
|
||||
stepIndex: number;
|
||||
stepCount: number;
|
||||
chip?: "optional" | "final";
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function OnboardingStepShell(props: Readonly<Props>) {
|
||||
const { stepIndex, stepCount, chip, children } = props;
|
||||
return (
|
||||
<div className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)_auto_auto] gap-4">
|
||||
<OnboardingStepProgress
|
||||
stepIndex={stepIndex}
|
||||
stepCount={stepCount}
|
||||
chip={chip}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { OnboardingStepShell };
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import type {
|
||||
QuestionnaireAnswersV1,
|
||||
QuestionnairePatchV1,
|
||||
} from "@/store/onboarding/types";
|
||||
import { QuestionnaireDetailsStep } from "./QuestionnaireDetailsStep";
|
||||
|
||||
const ANSWERS: QuestionnaireAnswersV1 = {
|
||||
role: "developer",
|
||||
company_context: "personal_or_individual",
|
||||
scale_intent: "exploring",
|
||||
referral_source: "search",
|
||||
};
|
||||
const LABELS = [
|
||||
"What best describes your role?",
|
||||
"What kind of organization are you part of?",
|
||||
"How do you plan to use Skyvern?",
|
||||
"How did you hear about Skyvern?",
|
||||
];
|
||||
|
||||
type Action = (patch: QuestionnairePatchV1) => Promise<void>;
|
||||
|
||||
function renderDetails({
|
||||
action = "complete",
|
||||
revision = 0,
|
||||
initialAnswers = null,
|
||||
externalError = null,
|
||||
isPending = false,
|
||||
onAction = vi.fn<Action>().mockResolvedValue(),
|
||||
onBack = vi.fn(),
|
||||
}: {
|
||||
action?: "complete" | "update";
|
||||
revision?: number;
|
||||
initialAnswers?: QuestionnaireAnswersV1 | null;
|
||||
externalError?: string | null;
|
||||
isPending?: boolean;
|
||||
onAction?: Mock<Action>;
|
||||
onBack?: () => void;
|
||||
} = {}) {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<QuestionnaireDetailsStep
|
||||
completionAction={action}
|
||||
expectedRevision={revision}
|
||||
organizationId="o_test"
|
||||
initialAnswers={initialAnswers}
|
||||
externalError={externalError}
|
||||
isPending={isPending}
|
||||
onAction={onAction}
|
||||
onBack={onBack}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
return { onAction, onBack };
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
screen.getAllByRole("combobox").forEach((control) => {
|
||||
fireEvent.click(control);
|
||||
fireEvent.click(screen.getAllByRole("option")[0]!);
|
||||
});
|
||||
}
|
||||
|
||||
function submitted(onAction: Mock<Action>): QuestionnairePatchV1 {
|
||||
const patch = onAction.mock.calls[onAction.mock.calls.length - 1]?.[0];
|
||||
if (!patch) throw new Error("Expected questionnaire submission");
|
||||
return patch;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("QuestionnaireDetailsStep", () => {
|
||||
it.each([
|
||||
["Name", " Example Owner ", { name: "Example Owner" }],
|
||||
[
|
||||
"Work email",
|
||||
"owner@example.com",
|
||||
{ professional_email: "owner@example.com" },
|
||||
],
|
||||
["Role", "Project lead", { role: "Project lead" }],
|
||||
] as const)(
|
||||
"saves %s independently with confirmed organization",
|
||||
async (label, value, fields) => {
|
||||
const { onAction } = renderDetails({ initialAnswers: ANSWERS });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: /Who else owns this automation project/,
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Complete and continue" }),
|
||||
);
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledOnce());
|
||||
expect(submitted(onAction)).toEqual(
|
||||
expect.objectContaining({
|
||||
...ANSWERS,
|
||||
project_owner: {
|
||||
action: "set",
|
||||
expected_organization_id: "o_test",
|
||||
...fields,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Skip", "Discard owner details and continue"])(
|
||||
"%s discards invalid drafts without extra requests or validation",
|
||||
async (action) => {
|
||||
const { onAction } = renderDetails({ initialAnswers: ANSWERS });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: /Who else owns this automation project/,
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Name"), {
|
||||
target: { value: "Example Owner" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Work email"), {
|
||||
target: { value: "invalid" },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Complete and continue" }),
|
||||
);
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByLabelText("Work email").getAttribute("aria-invalid"),
|
||||
).toBe("true");
|
||||
fireEvent.click(screen.getByRole("button", { name: action }));
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledOnce());
|
||||
expect(submitted(onAction)).toEqual({
|
||||
version: 1,
|
||||
mutation_id: expect.any(String),
|
||||
expected_revision: 0,
|
||||
action: action === "Skip" ? "skip" : "complete",
|
||||
...(action === "Skip" ? {} : ANSWERS),
|
||||
});
|
||||
expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
|
||||
expect(screen.getByLabelText("Work email")).toHaveProperty("value", "");
|
||||
},
|
||||
);
|
||||
|
||||
it("omits an all-blank owner and reuses a mutation only for the same report", async () => {
|
||||
const onAction = vi.fn<Action>().mockRejectedValue(new Error("offline"));
|
||||
renderDetails({ initialAnswers: ANSWERS, onAction });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: /Who else owns this automation project/,
|
||||
}),
|
||||
);
|
||||
const name = screen.getByLabelText("Name");
|
||||
const submit = screen.getByRole("button", {
|
||||
name: "Complete and continue",
|
||||
});
|
||||
fireEvent.change(name, { target: { value: " " } });
|
||||
fireEvent.click(submit);
|
||||
await screen.findByRole("alert");
|
||||
expect(submitted(onAction)).not.toHaveProperty("project_owner");
|
||||
fireEvent.change(name, { target: { value: "Example Owner" } });
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(submit).toHaveProperty("disabled", false));
|
||||
const patch = submitted(onAction);
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(3));
|
||||
expect(submitted(onAction)).toEqual(patch);
|
||||
await waitFor(() => expect(submit).toHaveProperty("disabled", false));
|
||||
fireEvent.change(name, { target: { value: "Another Owner" } });
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(4));
|
||||
expect(submitted(onAction).mutation_id).not.toBe(patch.mutation_id);
|
||||
});
|
||||
|
||||
it("renders the optional details form as the final step", () => {
|
||||
const { onBack } = renderDetails();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Tell us about your setup" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("STEP 2 OF 2")).toBeTruthy();
|
||||
expect(screen.getByText("FINAL STEP")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Optional\. Your answers help us recommend/),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/About 30 seconds/)).toBeNull();
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(4);
|
||||
LABELS.forEach((label) =>
|
||||
expect(screen.getByLabelText(label)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(onBack).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps an incomplete Complete action focusable and explains why it cannot submit", () => {
|
||||
const { onAction } = renderDetails();
|
||||
const submit = screen.getByRole("button", {
|
||||
name: "Complete and continue",
|
||||
});
|
||||
const hint = screen.getByText(
|
||||
"Answer the four setup questions to complete and continue.",
|
||||
);
|
||||
|
||||
expect(submit).toHaveProperty("disabled", false);
|
||||
expect(submit.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(submit.getAttribute("aria-describedby")).toBe(hint.id);
|
||||
expect(submit.classList.contains("cursor-not-allowed")).toBe(true);
|
||||
expect(submit.classList.contains("opacity-50")).toBe(true);
|
||||
submit.focus();
|
||||
expect(document.activeElement).toBe(submit);
|
||||
fireEvent.click(submit);
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("natively disables Complete while a submission is pending", () => {
|
||||
renderDetails({ isPending: true });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Complete and continue" }),
|
||||
).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("announces a parent close error without leaving details", () => {
|
||||
renderDetails({
|
||||
externalError: "We couldn't save your choice. Try again.",
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toBe(
|
||||
"We couldn't save your choice. Try again.",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Tell us about your setup" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("prefers a new close error over a stale submit error", async () => {
|
||||
const onAction = vi
|
||||
.fn<(patch: QuestionnairePatchV1) => Promise<void>>()
|
||||
.mockRejectedValue(new Error("offline"));
|
||||
const view = render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<QuestionnaireDetailsStep
|
||||
completionAction="complete"
|
||||
expectedRevision={0}
|
||||
initialAnswers={null}
|
||||
externalError={null}
|
||||
isPending={false}
|
||||
onAction={onAction}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
selectAll();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Complete and continue" }),
|
||||
);
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(
|
||||
"We couldn't save your details. Try again.",
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<QuestionnaireDetailsStep
|
||||
completionAction="complete"
|
||||
expectedRevision={0}
|
||||
initialAnswers={null}
|
||||
externalError="We couldn't save your choice. Try again."
|
||||
isPending={false}
|
||||
onAction={onAction}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toBe(
|
||||
"We couldn't save your choice. Try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["complete", 0, null],
|
||||
["update", 3, ANSWERS],
|
||||
] as const)(
|
||||
"submits %s with all answers and the exact revision",
|
||||
async (action, revision, initialAnswers) => {
|
||||
const onAction = vi
|
||||
.fn<(patch: QuestionnairePatchV1) => Promise<void>>()
|
||||
.mockResolvedValue();
|
||||
renderDetails({ action, revision, initialAnswers, onAction });
|
||||
const submit = screen.getByRole("button", {
|
||||
name: "Complete and continue",
|
||||
});
|
||||
if (!initialAnswers) {
|
||||
selectAll();
|
||||
}
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledOnce());
|
||||
expect(submitted(onAction)).toEqual({
|
||||
version: 1,
|
||||
mutation_id: expect.any(String),
|
||||
action,
|
||||
expected_revision: revision,
|
||||
...ANSWERS,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("submits answer-free Skip without a defer control", async () => {
|
||||
const onAction = vi
|
||||
.fn<(patch: QuestionnairePatchV1) => Promise<void>>()
|
||||
.mockResolvedValue();
|
||||
renderDetails({ onAction });
|
||||
expect(screen.queryByRole("button", { name: "Maybe later" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledOnce());
|
||||
expect(submitted(onAction)).toEqual({
|
||||
version: 1,
|
||||
mutation_id: expect.any(String),
|
||||
expected_revision: 0,
|
||||
action: "skip",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps selections and mutation identity for an exact retry only", async () => {
|
||||
const onAction = vi
|
||||
.fn<(patch: QuestionnairePatchV1) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValue(undefined);
|
||||
renderDetails({ onAction });
|
||||
selectAll();
|
||||
const submit = screen.getByRole("button", {
|
||||
name: "Complete and continue",
|
||||
});
|
||||
fireEvent.click(submit);
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(
|
||||
"We couldn't save your details. Try again.",
|
||||
);
|
||||
const firstMutation = submitted(onAction).mutation_id;
|
||||
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(2));
|
||||
expect(submitted(onAction).mutation_id).toBe(firstMutation);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("combobox")[0]!);
|
||||
fireEvent.click(screen.getAllByRole("option")[1]!);
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(3));
|
||||
expect(submitted(onAction).mutation_id).not.toBe(firstMutation);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,594 +0,0 @@
|
|||
import { ChevronDownIcon, ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/util/utils";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { OnboardingStepShell } from "./OnboardingStepShell";
|
||||
import type {
|
||||
QuestionnaireAnswersV1,
|
||||
QuestionnaireCompanyContextV1,
|
||||
QuestionnairePatchV1,
|
||||
QuestionnaireReferralSourceDetailV1,
|
||||
QuestionnaireReferralSourceV1,
|
||||
QuestionnaireRoleV1,
|
||||
QuestionnaireScaleIntentV1,
|
||||
ProjectOwnerFieldsV1,
|
||||
} from "@/store/onboarding/types";
|
||||
|
||||
type Option<Value extends string> = {
|
||||
value: Value;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Selections = {
|
||||
[Key in keyof QuestionnaireAnswersV1]: QuestionnaireAnswersV1[Key] | "";
|
||||
};
|
||||
|
||||
type Props = {
|
||||
completionAction: "complete" | "update";
|
||||
expectedRevision: number;
|
||||
organizationId?: string | null;
|
||||
initialAnswers?: QuestionnaireAnswersV1 | null;
|
||||
externalError?: string | null;
|
||||
isPending: boolean;
|
||||
onAction: (patch: QuestionnairePatchV1) => Promise<void>;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
type SelectFieldProps<Value extends string> = {
|
||||
id: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: Value | "";
|
||||
choices: readonly Option<Value>[];
|
||||
disabled: boolean;
|
||||
onValueChange: (value: Value) => void;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
function optionsFromLabels<Value extends string>(
|
||||
labels: Record<Value, string>,
|
||||
): readonly Option<Value>[] {
|
||||
return (Object.keys(labels) as Value[]).map((value) => ({
|
||||
value,
|
||||
label: labels[value],
|
||||
}));
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<QuestionnaireRoleV1, string> = {
|
||||
developer: "Developer",
|
||||
technical_operator: "Technical operator",
|
||||
business_operator: "Business operator",
|
||||
product_manager: "Product manager",
|
||||
founder_or_executive: "Founder or executive",
|
||||
other: "Other",
|
||||
prefer_not_to_say: "Prefer not to say",
|
||||
};
|
||||
const ROLE_OPTIONS = optionsFromLabels(ROLE_LABELS);
|
||||
|
||||
const COMPANY_CONTEXT_LABELS: Record<QuestionnaireCompanyContextV1, string> = {
|
||||
personal_or_individual: "Personal or individual",
|
||||
startup: "Startup",
|
||||
agency_or_services: "Agency or services",
|
||||
established_company: "Established company",
|
||||
education_or_research: "Education or research",
|
||||
other: "Other",
|
||||
prefer_not_to_say: "Prefer not to say",
|
||||
};
|
||||
const COMPANY_CONTEXT_OPTIONS = optionsFromLabels(COMPANY_CONTEXT_LABELS);
|
||||
|
||||
const SCALE_INTENT_LABELS: Record<QuestionnaireScaleIntentV1, string> = {
|
||||
exploring: "Exploring",
|
||||
single_workflow: "A single workflow",
|
||||
recurring_individual: "Recurring individual workflows",
|
||||
team_or_multi_workflow: "Team or multiple workflows",
|
||||
production_high_volume: "Production or high volume",
|
||||
unsure: "Unsure",
|
||||
};
|
||||
const SCALE_INTENT_OPTIONS = optionsFromLabels(SCALE_INTENT_LABELS);
|
||||
|
||||
const REFERRAL_SOURCE_LABELS: Record<QuestionnaireReferralSourceV1, string> = {
|
||||
search: "Search",
|
||||
social: "Social media",
|
||||
ai_assistant: "AI assistant",
|
||||
friend_or_colleague: "Friend or colleague",
|
||||
video: "Video",
|
||||
blog_or_article: "Blog or article",
|
||||
event_or_community: "Event or community",
|
||||
podcast: "Podcast",
|
||||
other: "Other",
|
||||
prefer_not_to_say: "Prefer not to say",
|
||||
};
|
||||
const REFERRAL_SOURCE_OPTIONS = optionsFromLabels(REFERRAL_SOURCE_LABELS);
|
||||
|
||||
const REFERRAL_SOURCE_DETAIL_OPTIONS: Partial<
|
||||
Record<
|
||||
QuestionnaireReferralSourceV1,
|
||||
readonly Option<QuestionnaireReferralSourceDetailV1>[]
|
||||
>
|
||||
> = {
|
||||
ai_assistant: optionsFromLabels({
|
||||
chatgpt: "ChatGPT",
|
||||
claude: "Claude",
|
||||
perplexity: "Perplexity",
|
||||
gemini: "Gemini",
|
||||
other_ai_assistant: "Other AI assistant",
|
||||
}),
|
||||
social: optionsFromLabels({
|
||||
reddit: "Reddit",
|
||||
x: "X (Twitter)",
|
||||
linkedin: "LinkedIn",
|
||||
youtube: "YouTube",
|
||||
other_social: "Other social network",
|
||||
}),
|
||||
};
|
||||
|
||||
function selectedAnswers(
|
||||
selections: Selections,
|
||||
): QuestionnaireAnswersV1 | null {
|
||||
const {
|
||||
role,
|
||||
company_context,
|
||||
scale_intent,
|
||||
referral_source,
|
||||
referral_source_detail,
|
||||
} = selections;
|
||||
return role && company_context && scale_intent && referral_source
|
||||
? {
|
||||
role,
|
||||
company_context,
|
||||
scale_intent,
|
||||
referral_source,
|
||||
...(REFERRAL_SOURCE_DETAIL_OPTIONS[referral_source]
|
||||
? { referral_source_detail: referral_source_detail || null }
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function QuestionnaireSelect<Value extends string>({
|
||||
id,
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
choices,
|
||||
disabled,
|
||||
onValueChange,
|
||||
onClear,
|
||||
}: Readonly<SelectFieldProps<Value>>) {
|
||||
function selectValue(nextValue: string) {
|
||||
const choice = choices.find((candidate) => candidate.value === nextValue);
|
||||
if (choice) {
|
||||
onValueChange(choice.value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2",
|
||||
onClear && "min-h-11",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor={id} className="text-sm leading-snug">
|
||||
{label}
|
||||
</Label>
|
||||
{onClear && value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Clear ${label}`}
|
||||
className="min-h-11 min-w-11 touch-manipulation motion-reduce:transition-none"
|
||||
disabled={disabled}
|
||||
onClick={onClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={value} onValueChange={selectValue} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
className="min-h-11 w-full touch-manipulation bg-background text-foreground motion-reduce:transition-none"
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[2147480004]">
|
||||
{choices.map((choice) => (
|
||||
<SelectItem
|
||||
key={choice.value}
|
||||
value={choice.value}
|
||||
className="min-h-11 touch-manipulation motion-reduce:transition-none"
|
||||
>
|
||||
{choice.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionnaireDetailsStep({
|
||||
completionAction,
|
||||
expectedRevision,
|
||||
organizationId,
|
||||
initialAnswers,
|
||||
externalError,
|
||||
isPending,
|
||||
onAction,
|
||||
onBack,
|
||||
}: Readonly<Props>) {
|
||||
const fieldPrefix = useId();
|
||||
const incompleteHintId = `${fieldPrefix}-incomplete-hint`;
|
||||
const [selections, setSelections] = useState<Selections>(() => ({
|
||||
role: initialAnswers?.role ?? "",
|
||||
company_context: initialAnswers?.company_context ?? "",
|
||||
scale_intent: initialAnswers?.scale_intent ?? "",
|
||||
referral_source: initialAnswers?.referral_source ?? "",
|
||||
referral_source_detail: initialAnswers?.referral_source_detail ?? "",
|
||||
}));
|
||||
const [pendingAction, setPendingAction] = useState<
|
||||
QuestionnairePatchV1["action"] | null
|
||||
>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ownerExpanded, setOwnerExpanded] = useState(false);
|
||||
const [ownerDraft, setOwnerDraft] = useState({
|
||||
name: "",
|
||||
professional_email: "",
|
||||
role: "",
|
||||
});
|
||||
const [ownerError, setOwnerError] = useState<string | null>(null);
|
||||
const displayedError = externalError ?? error;
|
||||
const pending = isPending || pendingAction !== null;
|
||||
const mutationIdentityRef = useRef<{
|
||||
payload: string;
|
||||
mutationId: string;
|
||||
} | null>(null);
|
||||
const answers = selectedAnswers(selections);
|
||||
const detailOptions = selections.referral_source
|
||||
? REFERRAL_SOURCE_DETAIL_OPTIONS[selections.referral_source]
|
||||
: undefined;
|
||||
|
||||
function setSelection<Key extends keyof QuestionnaireAnswersV1>(
|
||||
key: Key,
|
||||
value: QuestionnaireAnswersV1[Key],
|
||||
) {
|
||||
setSelections((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function mutationIdFor(payload: string) {
|
||||
const mutationId =
|
||||
mutationIdentityRef.current?.payload === payload
|
||||
? mutationIdentityRef.current.mutationId
|
||||
: crypto.randomUUID();
|
||||
mutationIdentityRef.current = { payload, mutationId };
|
||||
return mutationId;
|
||||
}
|
||||
|
||||
function discardOwner() {
|
||||
setOwnerDraft({ name: "", professional_email: "", role: "" });
|
||||
setOwnerError(null);
|
||||
}
|
||||
|
||||
async function submit(
|
||||
action: QuestionnairePatchV1["action"],
|
||||
discard = false,
|
||||
) {
|
||||
if (pending) return;
|
||||
const owner: ProjectOwnerFieldsV1 = {};
|
||||
if (action === "skip" || discard) {
|
||||
discardOwner();
|
||||
} else {
|
||||
for (const key of ["name", "professional_email", "role"] as const) {
|
||||
const value = ownerDraft[key].trim();
|
||||
if (value) owner[key] = value;
|
||||
}
|
||||
if (
|
||||
owner.professional_email &&
|
||||
!z.string().max(254).email().safeParse(owner.professional_email).success
|
||||
) {
|
||||
setOwnerError("Enter a valid work email or discard the owner details.");
|
||||
setOwnerExpanded(true);
|
||||
return;
|
||||
}
|
||||
if (Object.keys(owner).length > 0 && !organizationId) {
|
||||
setOwnerError(
|
||||
"We couldn't confirm your organization. Discard the owner details to continue.",
|
||||
);
|
||||
setOwnerExpanded(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload =
|
||||
action === "skip"
|
||||
? { version: 1 as const, expected_revision: expectedRevision, action }
|
||||
: answers && {
|
||||
version: 1 as const,
|
||||
expected_revision: expectedRevision,
|
||||
action,
|
||||
...answers,
|
||||
...(Object.keys(owner).length > 0 && organizationId
|
||||
? {
|
||||
project_owner: {
|
||||
action: "set" as const,
|
||||
expected_organization_id: organizationId,
|
||||
...owner,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (!payload) return;
|
||||
const patch = {
|
||||
...payload,
|
||||
mutation_id: mutationIdFor(JSON.stringify(payload)),
|
||||
} as QuestionnairePatchV1;
|
||||
|
||||
setPendingAction(action);
|
||||
setError(null);
|
||||
await onAction(patch);
|
||||
mutationIdentityRef.current = null;
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && cause.message === "project_owner_invalid") {
|
||||
setOwnerError("Check the owner details or discard them to continue.");
|
||||
setOwnerExpanded(true);
|
||||
} else {
|
||||
setError("We couldn't save your details. Try again.");
|
||||
}
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingStepShell stepIndex={2} stepCount={2} chip="final">
|
||||
<DialogHeader className="pr-12">
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Tell us about your setup
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Optional. Your answers help us recommend templates and guidance. They
|
||||
never limit what you can build.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid min-h-0 gap-4 overflow-y-auto overscroll-contain pb-6 pr-1 pt-1">
|
||||
<QuestionnaireSelect
|
||||
id={`${fieldPrefix}-role`}
|
||||
label="What best describes your role?"
|
||||
placeholder="Choose a role"
|
||||
value={selections.role}
|
||||
choices={ROLE_OPTIONS}
|
||||
disabled={pending}
|
||||
onValueChange={(value) => setSelection("role", value)}
|
||||
/>
|
||||
<QuestionnaireSelect
|
||||
id={`${fieldPrefix}-company-context`}
|
||||
label="What kind of organization are you part of?"
|
||||
placeholder="Choose an organization type"
|
||||
value={selections.company_context}
|
||||
choices={COMPANY_CONTEXT_OPTIONS}
|
||||
disabled={pending}
|
||||
onValueChange={(value) => setSelection("company_context", value)}
|
||||
/>
|
||||
<QuestionnaireSelect
|
||||
id={`${fieldPrefix}-scale-intent`}
|
||||
label="How do you plan to use Skyvern?"
|
||||
placeholder="Choose a usage pattern"
|
||||
value={selections.scale_intent}
|
||||
choices={SCALE_INTENT_OPTIONS}
|
||||
disabled={pending}
|
||||
onValueChange={(value) => setSelection("scale_intent", value)}
|
||||
/>
|
||||
<QuestionnaireSelect
|
||||
id={`${fieldPrefix}-referral-source`}
|
||||
label="How did you hear about Skyvern?"
|
||||
placeholder="Choose a source"
|
||||
value={selections.referral_source}
|
||||
choices={REFERRAL_SOURCE_OPTIONS}
|
||||
disabled={pending}
|
||||
onValueChange={(value) =>
|
||||
setSelections((current) => ({
|
||||
...current,
|
||||
referral_source: value,
|
||||
referral_source_detail: REFERRAL_SOURCE_DETAIL_OPTIONS[
|
||||
value
|
||||
]?.some(
|
||||
(option) => option.value === current.referral_source_detail,
|
||||
)
|
||||
? current.referral_source_detail
|
||||
: "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{detailOptions && (
|
||||
<QuestionnaireSelect
|
||||
id={`${fieldPrefix}-referral-source-detail`}
|
||||
label="Which one?"
|
||||
placeholder="Select one (optional)"
|
||||
value={selections.referral_source_detail ?? ""}
|
||||
choices={detailOptions}
|
||||
disabled={pending}
|
||||
onValueChange={(value) =>
|
||||
setSelection("referral_source_detail", value)
|
||||
}
|
||||
onClear={() =>
|
||||
setSelections((current) => ({
|
||||
...current,
|
||||
referral_source_detail: "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{organizationId ? (
|
||||
<div className="ph-no-capture grid gap-3 border-t border-border pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto min-h-11 justify-start whitespace-normal px-0 text-left"
|
||||
aria-expanded={ownerExpanded}
|
||||
aria-controls={`${fieldPrefix}-owner`}
|
||||
disabled={pending}
|
||||
onClick={() => setOwnerExpanded(!ownerExpanded)}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mr-2 size-4 shrink-0",
|
||||
ownerExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
Who else owns this automation project? (Optional)
|
||||
</Button>
|
||||
{ownerExpanded ? (
|
||||
<div id={`${fieldPrefix}-owner`} className="grid gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Share a project owner or technical sponsor to help us
|
||||
understand your project. This does not send an invitation or
|
||||
message.
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
["name", "Name", 120],
|
||||
["professional_email", "Work email", 254],
|
||||
["role", "Role", 120],
|
||||
] as const
|
||||
).map(([key, label, maxLength]) => (
|
||||
<div key={key} className="grid gap-2">
|
||||
<Label htmlFor={`${fieldPrefix}-owner-${key}`}>
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${fieldPrefix}-owner-${key}`}
|
||||
type={key === "professional_email" ? "email" : "text"}
|
||||
autoComplete="off"
|
||||
maxLength={maxLength}
|
||||
value={ownerDraft[key]}
|
||||
disabled={pending}
|
||||
className="min-h-11"
|
||||
aria-invalid={
|
||||
(key === "professional_email" && !!ownerError) ||
|
||||
undefined
|
||||
}
|
||||
aria-describedby={
|
||||
ownerError ? `${fieldPrefix}-owner-error` : undefined
|
||||
}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setOwnerDraft((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
}));
|
||||
setOwnerError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{ownerError ? (
|
||||
<div className="grid gap-2">
|
||||
<p
|
||||
id={`${fieldPrefix}-owner-error`}
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{ownerError}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => void submit(completionAction, true)}
|
||||
>
|
||||
Discard owner details and continue
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{displayedError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{displayedError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!answers ? (
|
||||
<p id={incompleteHintId} className="text-sm text-muted-foreground">
|
||||
Answer the four setup questions to complete and continue.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter className="sticky bottom-0 z-10 -mx-6 -mb-6 flex-col gap-2 border-t border-border bg-background px-6 pb-[max(1rem,env(safe-area-inset-bottom))] pt-4 sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
|
||||
disabled={pending}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{completionAction === "complete" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
|
||||
disabled={pending}
|
||||
onClick={() => void submit("skip")}
|
||||
>
|
||||
{pendingAction === "skip" ? (
|
||||
<ReloadIcon
|
||||
aria-hidden
|
||||
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
Skip
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto",
|
||||
!answers && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
aria-describedby={!answers ? incompleteHintId : undefined}
|
||||
aria-disabled={!answers || undefined}
|
||||
disabled={pending}
|
||||
onClick={() => void submit(completionAction)}
|
||||
>
|
||||
{pendingAction === completionAction ? (
|
||||
<ReloadIcon
|
||||
aria-hidden
|
||||
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
Complete and continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</OnboardingStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
export { QuestionnaireDetailsStep };
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { ClickIcon } from "@/components/icons/ClickIcon";
|
||||
import { ExtractIcon } from "@/components/icons/ExtractIcon";
|
||||
import { SearchIcon } from "@/components/icons/SearchIcon";
|
||||
import { GovernmentIcon } from "@/components/icons/GovernmentIcon";
|
||||
import { BagIcon } from "@/components/icons/BagIcon";
|
||||
import { ReceiptIcon } from "@/components/icons/ReceiptIcon";
|
||||
import { DocumentIcon } from "@/components/icons/DocumentIcon";
|
||||
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
|
||||
|
||||
const INTENT_KEYWORDS: Record<string, RegExp> = {
|
||||
fill_forms:
|
||||
/form|application|submit|apply|registration|contact|intake|questionnaire|filing/i,
|
||||
extract_data:
|
||||
/extract|scrape|data|lookup|search|download|invoice|collect|report/i,
|
||||
monitor_website:
|
||||
/monitor|track|watch|check|alert|notification|change|verify/i,
|
||||
};
|
||||
|
||||
function getTemplatesForIntent(
|
||||
templates: WorkflowApiResponse[],
|
||||
intent: string,
|
||||
): WorkflowApiResponse[] {
|
||||
const pattern = INTENT_KEYWORDS[intent];
|
||||
if (!pattern) return templates.slice(0, 4);
|
||||
|
||||
const matches = templates.filter((t) => {
|
||||
const text = `${t.title} ${t.description}`;
|
||||
return pattern.test(text);
|
||||
});
|
||||
|
||||
if (matches.length >= 4) return matches.slice(0, 4);
|
||||
|
||||
const matchedIds = new Set(matches.map((m) => m.workflow_permanent_id));
|
||||
const remaining = templates.filter(
|
||||
(t) => !matchedIds.has(t.workflow_permanent_id),
|
||||
);
|
||||
return [...matches, ...remaining].slice(0, 4);
|
||||
}
|
||||
|
||||
function getTemplateIcon(
|
||||
template: WorkflowApiResponse,
|
||||
): React.FC<{ className?: string }> {
|
||||
const text = `${template.title} ${template.description}`.toLowerCase();
|
||||
if (/form|application|submit|registration|contact/.test(text))
|
||||
return ClickIcon;
|
||||
if (/invoice|receipt|billing|payment/.test(text)) return ReceiptIcon;
|
||||
if (/extract|scrape|data|download/.test(text)) return ExtractIcon;
|
||||
if (/monitor|track|watch|check|alert/.test(text)) return SearchIcon;
|
||||
if (/government|entity|ein|sam|irs|federal/.test(text)) return GovernmentIcon;
|
||||
if (/job|career|hiring|recruit|employ/.test(text)) return BagIcon;
|
||||
return DocumentIcon;
|
||||
}
|
||||
|
||||
function getSetupTime(template: WorkflowApiResponse): string {
|
||||
const blocks = template.workflow_definition.blocks.length;
|
||||
if (blocks <= 3) return "2 min";
|
||||
if (blocks <= 7) return "5 min";
|
||||
return "10 min";
|
||||
}
|
||||
|
||||
export {
|
||||
INTENT_KEYWORDS,
|
||||
getTemplatesForIntent,
|
||||
getTemplateIcon,
|
||||
getSetupTime,
|
||||
};
|
||||
|
|
@ -19,7 +19,6 @@ import type {
|
|||
ConfirmedPatch,
|
||||
ConfirmedWriteResult,
|
||||
OnboardingState,
|
||||
QuestionnairePatchV1,
|
||||
QuestionnaireStateV1,
|
||||
} from "@/store/onboarding/types";
|
||||
import { OnboardingContext } from "@/store/onboarding/useOnboardingState";
|
||||
|
|
@ -31,15 +30,13 @@ const mocks = vi.hoisted(() => ({
|
|||
createPending: false,
|
||||
createWorkflow: vi.fn(),
|
||||
createWorkflowOptions: undefined as { onCreated?: () => void } | undefined,
|
||||
focusAndPrefillExample: vi.fn<(key: string) => void>(),
|
||||
focusAndPrefillExample:
|
||||
vi.fn<(key: string | null, fallback: string) => void>(),
|
||||
homeViewed: vi.fn(),
|
||||
telemetry: {
|
||||
registerVariant: vi.fn(),
|
||||
flowStarted: vi.fn(),
|
||||
modalOpened: vi.fn(),
|
||||
questionnaireShown: vi.fn<(input: unknown) => boolean>(() => true),
|
||||
questionnaireCompleted: vi.fn(),
|
||||
modalRenderError: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -86,15 +83,17 @@ vi.mock("@/routes/tasks/create/PromptBox", async () => {
|
|||
const React = await vi.importActual<typeof import("react")>("react");
|
||||
return {
|
||||
PromptBox: React.forwardRef<
|
||||
{ focusAndPrefillExample: (key: string) => void },
|
||||
{
|
||||
focusAndPrefillExample: (key: string | null, fallback: string) => void;
|
||||
},
|
||||
{ secondaryAction?: React.ReactNode }
|
||||
>(function PromptBoxMock({ secondaryAction }, ref) {
|
||||
const [value, setValue] = React.useState("");
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focusAndPrefillExample: (key) => {
|
||||
mocks.focusAndPrefillExample(key);
|
||||
setValue((current) => (current.trim() ? current : key));
|
||||
focusAndPrefillExample: (key, fallback) => {
|
||||
mocks.focusAndPrefillExample(key, fallback);
|
||||
setValue((current) => (current.trim() ? current : (key ?? fallback)));
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
},
|
||||
}));
|
||||
|
|
@ -118,33 +117,6 @@ vi.mock("./WorkflowTemplates", () => ({
|
|||
<div data-testid="discover-templates">templates</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/onboarding/QuestionnaireDetailsStep", () => ({
|
||||
QuestionnaireDetailsStep: ({
|
||||
expectedRevision,
|
||||
onAction,
|
||||
}: {
|
||||
expectedRevision: number;
|
||||
onAction: (patch: QuestionnairePatchV1) => Promise<void>;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void onAction({
|
||||
version: 1,
|
||||
mutation_id: `complete-${expectedRevision}`,
|
||||
expected_revision: expectedRevision,
|
||||
action: "complete",
|
||||
role: "developer",
|
||||
company_context: "startup",
|
||||
scale_intent: "exploring",
|
||||
referral_source: "search",
|
||||
})
|
||||
}
|
||||
>
|
||||
details-submit
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/util/onboarding/OnboardingTelemetry", () => ({
|
||||
OnboardingTelemetry: mocks.telemetry,
|
||||
}));
|
||||
|
|
@ -189,14 +161,6 @@ const questionnaire = (completed = false): QuestionnaireStateV1 => ({
|
|||
defer_prompt_count: completed ? 0 : 1,
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
return <span data-testid="location">{useLocation().search}</span>;
|
||||
}
|
||||
|
|
@ -324,6 +288,7 @@ describe("DiscoverPage focus param", () => {
|
|||
expect(mocks.focusAndPrefillExample).toHaveBeenCalledOnce(),
|
||||
);
|
||||
expect(mocks.focusAndPrefillExample).toHaveBeenCalledWith(
|
||||
null,
|
||||
"contact_us_forms",
|
||||
);
|
||||
expect((prompt as HTMLTextAreaElement).value).toBe("contact_us_forms");
|
||||
|
|
@ -356,6 +321,7 @@ describe("DiscoverPage focus param", () => {
|
|||
expect(mocks.focusAndPrefillExample).toHaveBeenCalledOnce(),
|
||||
);
|
||||
expect(mocks.focusAndPrefillExample).toHaveBeenCalledWith(
|
||||
null,
|
||||
"contact_us_forms",
|
||||
);
|
||||
expect(screen.getByTestId("location").textContent).toBe("");
|
||||
|
|
@ -459,7 +425,7 @@ describe("DiscoverPage onboarding mount", () => {
|
|||
expect(mocks.homeViewed).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves content order and mounts over seeded template data", async () => {
|
||||
it("preserves content order and mounts over seeded template data", () => {
|
||||
renderDiscover(baseState);
|
||||
const content =
|
||||
screen.getByTestId("discover-templates").parentElement?.parentElement;
|
||||
|
|
@ -469,7 +435,6 @@ describe("DiscoverPage onboarding mount", () => {
|
|||
expect(screen.queryByText("Build your first agent")).toBeNull();
|
||||
expect(screen.queryByText(/Keep going/)).toBeNull();
|
||||
expect(screen.queryByText("Resume getting started")).toBeNull();
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts one attributed blank-agent attempt", () => {
|
||||
|
|
@ -504,147 +469,10 @@ describe("DiscoverPage onboarding mount", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("keeps Discover actions behind the modal while reservation is pending", async () => {
|
||||
const reservation = deferred<ConfirmedWriteResult>();
|
||||
mocks.confirmed.mockReturnValueOnce(reservation.promise);
|
||||
const view = renderDiscover(baseState);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Getting started" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Checking your onboarding setup.")).toBeTruthy();
|
||||
expect(mocks.telemetry.modalOpened).not.toHaveBeenCalled();
|
||||
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
|
||||
const blankCanvas = screen.getByRole("button", {
|
||||
name: /start with a blank canvas/i,
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
blankCanvas.focus();
|
||||
await waitFor(() =>
|
||||
expect(dialog.contains(document.activeElement)).toBe(true),
|
||||
);
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
|
||||
expect(mocks.createWorkflow).not.toHaveBeenCalled();
|
||||
|
||||
view.unmount();
|
||||
await act(async () =>
|
||||
reservation.resolve({
|
||||
onboarding_state: {
|
||||
...baseState,
|
||||
questionnaire_prompted_at: "2026-08-27T00:00:00Z",
|
||||
},
|
||||
launch_date_at_signup: "2026-01-01T00:00:00Z",
|
||||
recovery_guidance_assignment: null,
|
||||
questionnaire_prompt_result: {
|
||||
status: "reserved",
|
||||
prompted_at: "2026-08-27T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shields an eligible older-org user with a prior save", async () => {
|
||||
const reservation = deferred<ConfirmedWriteResult>();
|
||||
const firstSaveAt = "2026-08-20T00:00:00Z";
|
||||
mocks.confirmed.mockReturnValueOnce(reservation.promise);
|
||||
renderDiscover({ ...baseState, first_save_at: firstSaveAt }, false, false);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const blankCanvas = screen.getByRole("button", {
|
||||
name: /start with a blank canvas/i,
|
||||
hidden: true,
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Getting started" }),
|
||||
).toBeTruthy();
|
||||
|
||||
blankCanvas.focus();
|
||||
await waitFor(() =>
|
||||
expect(dialog.contains(document.activeElement)).toBe(true),
|
||||
);
|
||||
expect(mocks.createWorkflow).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () =>
|
||||
reservation.resolve({
|
||||
onboarding_state: {
|
||||
...baseState,
|
||||
first_save_at: firstSaveAt,
|
||||
questionnaire_prompted_at: "2026-08-27T00:00:00Z",
|
||||
},
|
||||
launch_date_at_signup: "2026-01-01T00:00:00Z",
|
||||
recovery_guidance_assignment: null,
|
||||
questionnaire_prompt_result: {
|
||||
status: "reserved",
|
||||
prompted_at: "2026-08-27T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "What do you want to automate?",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders without onboarding UI when no provider exists", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DiscoverPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
it("never opens a dialog or reserves the questionnaire for an eligible new user", async () => {
|
||||
renderDiscover(baseState);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(mocks.telemetry.modalRenderError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes stored intent into the editor fallback", async () => {
|
||||
renderDiscover({ ...baseState, user_intent: "fill_forms" });
|
||||
expect(await screen.findByText("Pick a template to start")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps an established user without a pending questionnaire closed", async () => {
|
||||
renderDiscover(
|
||||
{ ...baseState, user_intent: "fill_forms", questionnaire: null },
|
||||
false,
|
||||
false,
|
||||
);
|
||||
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.queryByText("Getting started")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a completed questionnaire closed", () => {
|
||||
renderDiscover({
|
||||
...baseState,
|
||||
user_intent: "fill_forms",
|
||||
questionnaire_prompted_at: "2026-08-27T00:00:00Z",
|
||||
questionnaire: questionnaire(true),
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(mocks.confirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never reopens a legacy deferred response", () => {
|
||||
const deferredState = {
|
||||
...baseState,
|
||||
user_intent: "fill_forms",
|
||||
questionnaire: questionnaire(),
|
||||
};
|
||||
mocks.confirmed.mockResolvedValue({
|
||||
onboarding_state: {
|
||||
...deferredState,
|
||||
questionnaire: questionnaire(true),
|
||||
},
|
||||
launch_date_at_signup: "2026-01-01T00:00:00Z",
|
||||
recovery_guidance_assignment: null,
|
||||
});
|
||||
|
||||
renderDiscover(deferredState, true, false);
|
||||
expect(screen.queryByText("Pick a template to start")).toBeNull();
|
||||
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
|
||||
expect(mocks.confirmed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { HomeTelemetry } from "@/util/homeTelemetry";
|
||||
import { GetStartedModal } from "@/components/onboarding/GetStartedModal";
|
||||
import { OnboardingErrorBoundary } from "@/components/onboarding/OnboardingErrorBoundary";
|
||||
import { OnboardingTelemetry } from "@/util/onboarding/OnboardingTelemetry";
|
||||
import { useOnboardingStateOptional } from "@/store/onboarding/useOnboardingState";
|
||||
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
|
||||
import {
|
||||
|
|
@ -13,7 +10,7 @@ import {
|
|||
import { WorkflowTemplates } from "./WorkflowTemplates";
|
||||
import { useCreateWorkflowMutation } from "../workflows/hooks/useCreateWorkflowMutation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { FilePlusIcon, ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { defaultWorkflowRequest } from "../workflows/defaultWorkflowRequest";
|
||||
|
||||
|
|
@ -23,6 +20,8 @@ function getIntentExampleKey(
|
|||
switch (intent) {
|
||||
case "fill_forms":
|
||||
return "contact_us_forms";
|
||||
case "job_applications":
|
||||
return "job_application";
|
||||
case "extract_data":
|
||||
return "extractIntegrationsFromGong";
|
||||
case "monitor_website":
|
||||
|
|
@ -84,6 +83,16 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
|
|||
// `/discover?focus=prompt` is the sidebar card's first-agent link: focus + prefill once, then drop the param.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const focusPrompt = searchParams.get("focus") === "prompt";
|
||||
const requestedExample = searchParams.get("example");
|
||||
// Free text arrives in router state rather than the URL, so it never lands in history or logs.
|
||||
const locationState: unknown = useLocation().state;
|
||||
const prefillPrompt =
|
||||
locationState &&
|
||||
typeof locationState === "object" &&
|
||||
"prefillPrompt" in locationState &&
|
||||
typeof locationState.prefillPrompt === "string"
|
||||
? locationState.prefillPrompt
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (!focusPrompt) {
|
||||
handledFocus.current = false;
|
||||
|
|
@ -94,14 +103,20 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
|
|||
const promptBox = promptBoxRef.current;
|
||||
if (!promptBox) return;
|
||||
handledFocus.current = true;
|
||||
promptBox.focusAndPrefillExample(
|
||||
getIntentExampleKey(onboarding?.state?.user_intent),
|
||||
);
|
||||
if (prefillPrompt) {
|
||||
promptBox.focusAndPrefillPrompt(prefillPrompt);
|
||||
} else {
|
||||
promptBox.focusAndPrefillExample(
|
||||
requestedExample,
|
||||
getIntentExampleKey(onboarding?.state?.user_intent),
|
||||
);
|
||||
}
|
||||
}
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.delete("focus");
|
||||
next.delete("example");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
|
|
@ -110,17 +125,11 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
|
|||
focusPrompt,
|
||||
onboarding?.isLoading,
|
||||
onboarding?.state?.user_intent,
|
||||
prefillPrompt,
|
||||
requestedExample,
|
||||
setSearchParams,
|
||||
]);
|
||||
|
||||
const onboardingModal = onboarding ? (
|
||||
<OnboardingErrorBoundary
|
||||
onError={() => OnboardingTelemetry.modalRenderError("discover")}
|
||||
>
|
||||
<GetStartedModal />
|
||||
</OnboardingErrorBoundary>
|
||||
) : null;
|
||||
|
||||
if (revamp) {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-9rem)] flex-col justify-center">
|
||||
|
|
@ -153,7 +162,6 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
|
|||
Skip — start from a blank agent
|
||||
</Button>
|
||||
</div>
|
||||
{onboardingModal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -192,7 +200,6 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
|
|||
<div className="mx-auto w-full max-w-[60rem] pb-8">
|
||||
<WorkflowTemplates />
|
||||
</div>
|
||||
{onboardingModal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { expect, it } from "vitest";
|
|||
import {
|
||||
countedTrackItems,
|
||||
parseOnboardingTrack,
|
||||
REWARD_TRACK_KEYS,
|
||||
TRACK_KEYS,
|
||||
} from "./useOnboardingTrack";
|
||||
|
||||
|
|
@ -27,6 +28,30 @@ it("accepts a well-formed track", () => {
|
|||
expect(parseOnboardingTrack(validTrack())?.completed_count).toBe(1);
|
||||
});
|
||||
|
||||
it("accepts the activation-rewards layout with its credit totals", () => {
|
||||
const parsed = parseOnboardingTrack({
|
||||
...validTrack(),
|
||||
items: REWARD_TRACK_KEYS.map((key, index) => ({
|
||||
key,
|
||||
completed_at: index === 0 ? "2026-09-01T00:00:00Z" : null,
|
||||
verification:
|
||||
key === "github_starred" || key === "discord_joined"
|
||||
? "self"
|
||||
: "server",
|
||||
reward_credits: 100,
|
||||
reward_state: index === 0 ? "granted" : "unearned",
|
||||
})),
|
||||
reward_credits_earned: 100,
|
||||
reward_credits_cap: 1000,
|
||||
});
|
||||
expect(parsed?.items[0]).toMatchObject({
|
||||
key: "questionnaire_completed",
|
||||
reward_state: "granted",
|
||||
});
|
||||
expect(parsed?.reward_credits_earned).toBe(100);
|
||||
expect(parsed?.reward_credits_cap).toBe(1000);
|
||||
});
|
||||
|
||||
it("accepts only the server-verified second-agent ninth row", () => {
|
||||
const track = validTrack();
|
||||
const ninth = {
|
||||
|
|
|
|||
|
|
@ -22,13 +22,34 @@ const TRACK_KEYS = [
|
|||
"discord_joined",
|
||||
"social_followed",
|
||||
] as const;
|
||||
// The layout served once activation rewards v2 is live; the parser accepts either
|
||||
// so a backend deploy ahead of the frontend doesn't blank the track.
|
||||
const REWARD_TRACK_KEYS = [
|
||||
"questionnaire_completed",
|
||||
"first_successful_run",
|
||||
"run_feedback_given",
|
||||
"first_scheduled_run",
|
||||
"credential_saved",
|
||||
"agent_edited",
|
||||
"github_starred",
|
||||
"discord_joined",
|
||||
] as const;
|
||||
const SECOND_AGENT_KEY = "second_agent_run" as const;
|
||||
type TrackKey = (typeof TRACK_KEYS)[number] | typeof SECOND_AGENT_KEY;
|
||||
type TrackKey =
|
||||
| (typeof TRACK_KEYS)[number]
|
||||
| (typeof REWARD_TRACK_KEYS)[number]
|
||||
| typeof SECOND_AGENT_KEY;
|
||||
const SELF_ATTESTED_KEYS: ReadonlySet<TrackKey> = new Set([
|
||||
"github_starred",
|
||||
"discord_joined",
|
||||
"social_followed",
|
||||
]);
|
||||
type TrackRewardState =
|
||||
| "unearned"
|
||||
| "pending"
|
||||
| "blocked"
|
||||
| "granted"
|
||||
| "reversed";
|
||||
// Rows that count toward N/M. The teammate row stays out until its
|
||||
// destination exists; community rows are never counted.
|
||||
const COUNTED_KEYS: readonly TrackKey[] = [
|
||||
|
|
@ -36,6 +57,8 @@ const COUNTED_KEYS: readonly TrackKey[] = [
|
|||
"first_scheduled_run",
|
||||
"first_api_run",
|
||||
"mcp_installed",
|
||||
"agent_edited",
|
||||
"run_feedback_given",
|
||||
"credential_saved",
|
||||
];
|
||||
type TrackState = "ineligible" | "active" | "dismissed" | "completed";
|
||||
|
|
@ -43,6 +66,9 @@ type OnboardingTrackItemV1 = {
|
|||
key: TrackKey;
|
||||
completed_at: string | null;
|
||||
verification: "server" | "self";
|
||||
// Credits the milestone grants; null while reward grants are disabled.
|
||||
reward_credits?: number | null;
|
||||
reward_state?: TrackRewardState | null;
|
||||
};
|
||||
type TrackMutation =
|
||||
| { action: "dismiss" | "restore" }
|
||||
|
|
@ -54,8 +80,30 @@ type OnboardingTrackV1 = {
|
|||
completed_count: number;
|
||||
total_count: 8 | 9;
|
||||
items: OnboardingTrackItemV1[];
|
||||
// Credits for answering the onboarding questionnaire; null while reward grants are disabled.
|
||||
questionnaire_reward_credits?: number | null;
|
||||
reward_credits_earned?: number | null;
|
||||
reward_credits_cap?: number | null;
|
||||
};
|
||||
|
||||
function isTrackRewardState(value: unknown): value is TrackRewardState {
|
||||
return (
|
||||
value === "unearned" ||
|
||||
value === "pending" ||
|
||||
value === "blocked" ||
|
||||
value === "granted" ||
|
||||
value === "reversed"
|
||||
);
|
||||
}
|
||||
|
||||
function wholeCredits(value: unknown, allowZero = false): number | null {
|
||||
return typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
(allowZero ? value >= 0 : value > 0)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function isTrackState(value: unknown): value is TrackState {
|
||||
return (
|
||||
value === "ineligible" ||
|
||||
|
|
@ -76,12 +124,17 @@ function parseOnboardingTrack(value: unknown): OnboardingTrackV1 | null {
|
|||
total_count !== items.length
|
||||
)
|
||||
return null;
|
||||
const first: unknown = items[0];
|
||||
const layout: readonly TrackKey[] =
|
||||
isRecord(first) && first.key === REWARD_TRACK_KEYS[0]
|
||||
? REWARD_TRACK_KEYS
|
||||
: TRACK_KEYS;
|
||||
const parsed: OnboardingTrackItemV1[] = [];
|
||||
for (const [index, item] of items.entries()) {
|
||||
const key =
|
||||
index < TRACK_KEYS.length
|
||||
? TRACK_KEYS[index]
|
||||
: index === TRACK_KEYS.length
|
||||
index < layout.length
|
||||
? layout[index]
|
||||
: index === layout.length
|
||||
? SECOND_AGENT_KEY
|
||||
: undefined;
|
||||
if (key === undefined || !isRecord(item) || item.key !== key) return null;
|
||||
|
|
@ -89,7 +142,15 @@ function parseOnboardingTrack(value: unknown): OnboardingTrackV1 | null {
|
|||
if (!isTimestampOrNull(completedAt)) return null;
|
||||
const verification = SELF_ATTESTED_KEYS.has(key) ? "self" : "server";
|
||||
if (item.verification !== verification) return null;
|
||||
parsed.push({ key, completed_at: completedAt, verification });
|
||||
parsed.push({
|
||||
key,
|
||||
completed_at: completedAt,
|
||||
verification,
|
||||
reward_credits: wholeCredits(item.reward_credits),
|
||||
reward_state: isTrackRewardState(item.reward_state)
|
||||
? item.reward_state
|
||||
: null,
|
||||
});
|
||||
}
|
||||
const derivedCount = parsed.filter((row) => row.completed_at !== null).length;
|
||||
if (completed_count !== derivedCount) return null;
|
||||
|
|
@ -101,17 +162,39 @@ function parseOnboardingTrack(value: unknown): OnboardingTrackV1 | null {
|
|||
completed_count: derivedCount,
|
||||
total_count: items.length,
|
||||
items: parsed,
|
||||
questionnaire_reward_credits: wholeCredits(
|
||||
value.questionnaire_reward_credits,
|
||||
),
|
||||
reward_credits_earned: wholeCredits(value.reward_credits_earned, true),
|
||||
reward_credits_cap: wholeCredits(value.reward_credits_cap),
|
||||
};
|
||||
}
|
||||
|
||||
function useOnboardingTrack() {
|
||||
type UseOnboardingTrackOptions = {
|
||||
/**
|
||||
* For surfaces outside the track experiment: also load for users without a
|
||||
* Clerk organization, and return the track for both arms.
|
||||
*/
|
||||
outsideExperiment?: boolean;
|
||||
/** When false, never fetch; the track reads as null. */
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function useOnboardingTrack({
|
||||
outsideExperiment = false,
|
||||
enabled: callerEnabled = true,
|
||||
}: UseOnboardingTrackOptions = {}) {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const activeOrgId = useActiveOrgId();
|
||||
const activeUserId = useUser().get()?.id;
|
||||
const queryClient = useQueryClient();
|
||||
const flag = useFeatureFlag(ONBOARDING_TRACK_FLAG);
|
||||
const trackFlag = useFeatureFlag(ONBOARDING_TRACK_FLAG);
|
||||
// Surfaces outside the experiment gate themselves, so only the holdout's own UI needs the flag.
|
||||
const enabled =
|
||||
flag === true && activeOrgId !== undefined && activeUserId !== undefined;
|
||||
callerEnabled &&
|
||||
(trackFlag === true || outsideExperiment) &&
|
||||
(activeOrgId !== undefined || outsideExperiment) &&
|
||||
activeUserId !== undefined;
|
||||
const queryKey = getOrgScopedQueryKey(
|
||||
["onboarding-track", activeUserId],
|
||||
getActiveOrgQueryKeyScope(activeOrgId),
|
||||
|
|
@ -148,9 +231,9 @@ function useOnboardingTrack() {
|
|||
});
|
||||
const track = enabled && !isError ? (data ?? null) : null;
|
||||
// An undefined flag, org, or user is still resolving; only an explicit
|
||||
// `false` flag disables the track.
|
||||
// `false` flag or `enabled: false` disables the track.
|
||||
const status: "disabled" | "loading" | "error" | "ready" = !enabled
|
||||
? flag === false
|
||||
? !callerEnabled || (trackFlag === false && !outsideExperiment)
|
||||
? "disabled"
|
||||
: "loading"
|
||||
: isError
|
||||
|
|
@ -159,7 +242,11 @@ function useOnboardingTrack() {
|
|||
? "loading"
|
||||
: "ready";
|
||||
return {
|
||||
track: track?.arm === "treatment" ? track : null,
|
||||
// The track experiment's own UI shows only where its flag is on, not for journey-only orgs.
|
||||
track:
|
||||
(trackFlag === true && track?.arm === "treatment") || outsideExperiment
|
||||
? track
|
||||
: null,
|
||||
status,
|
||||
isPending,
|
||||
refetch,
|
||||
|
|
@ -183,9 +270,15 @@ function countedTrackItems(
|
|||
export {
|
||||
countedTrackItems,
|
||||
parseOnboardingTrack,
|
||||
REWARD_TRACK_KEYS,
|
||||
SECOND_AGENT_KEY,
|
||||
SELF_ATTESTED_KEYS,
|
||||
TRACK_KEYS,
|
||||
useOnboardingTrack,
|
||||
};
|
||||
export type { OnboardingTrackItemV1, OnboardingTrackV1, TrackKey };
|
||||
export type {
|
||||
OnboardingTrackItemV1,
|
||||
OnboardingTrackV1,
|
||||
TrackKey,
|
||||
TrackRewardState,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -90,59 +90,81 @@ function StreamModeBadge({
|
|||
function StreamStatusPanel({
|
||||
diagnostic,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
diagnostic: StreamDiagnostic;
|
||||
children?: ReactNode;
|
||||
/** Interactive content shown under the status, outside its live region. */
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const isSuccess = diagnostic.tone === "success";
|
||||
const status = (
|
||||
<div
|
||||
role={footer ? "status" : undefined}
|
||||
aria-live={footer ? "polite" : undefined}
|
||||
className="flex max-w-md flex-col gap-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium text-neutral-900 dark:text-slate-100">
|
||||
{isSuccess ? (
|
||||
<CheckCircledIcon className="h-4 w-4 flex-shrink-0 text-success" />
|
||||
) : (
|
||||
<InfoCircledIcon className="h-4 w-4 flex-shrink-0 text-neutral-500 dark:text-slate-400" />
|
||||
)}
|
||||
<span>{diagnostic.title}</span>
|
||||
</div>
|
||||
{diagnostic.detail && (
|
||||
<div className="text-neutral-600 dark:text-slate-400">
|
||||
{diagnostic.detail}
|
||||
</div>
|
||||
)}
|
||||
{diagnostic.hint && (
|
||||
<div className="text-xs text-neutral-500 dark:text-slate-500">
|
||||
{diagnostic.hint}
|
||||
</div>
|
||||
)}
|
||||
{diagnostic.pending && (
|
||||
// Decorative and on a 7s rotation: inside the live region it would
|
||||
// interrupt a screen reader again every time it cycles.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mt-1 flex flex-col gap-1 text-neutral-600 dark:text-slate-400"
|
||||
>
|
||||
<RotateThrough interval={7 * 1000}>
|
||||
{WHIMSICAL_LOADING_MESSAGES.map((message) => (
|
||||
<span key={message}>{message}</span>
|
||||
))}
|
||||
</RotateThrough>
|
||||
<AnimatedWave text={WHIMSICAL_SPARKLE} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const panelClassName = cn(
|
||||
"flex h-full w-full rounded-md bg-white p-6 text-neutral-600 dark:bg-slate-900 dark:text-slate-300",
|
||||
isSuccess && "border border-success/30",
|
||||
className,
|
||||
);
|
||||
if (footer) {
|
||||
// m-auto centers the column while still letting a tall footer scroll from the top.
|
||||
return (
|
||||
<div className={cn(panelClassName, "overflow-y-auto")}>
|
||||
<div className="m-auto flex w-full max-w-md flex-col">
|
||||
{status}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-md bg-white p-6 text-neutral-600 dark:bg-slate-900 dark:text-slate-300",
|
||||
isSuccess && "border border-success/30",
|
||||
className,
|
||||
)}
|
||||
className={cn(panelClassName, "items-center justify-center")}
|
||||
>
|
||||
<div className="flex max-w-md flex-col gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium text-neutral-900 dark:text-slate-100">
|
||||
{isSuccess ? (
|
||||
<CheckCircledIcon className="h-4 w-4 flex-shrink-0 text-success" />
|
||||
) : (
|
||||
<InfoCircledIcon className="h-4 w-4 flex-shrink-0 text-neutral-500 dark:text-slate-400" />
|
||||
)}
|
||||
<span>{diagnostic.title}</span>
|
||||
</div>
|
||||
{diagnostic.detail && (
|
||||
<div className="text-neutral-600 dark:text-slate-400">
|
||||
{diagnostic.detail}
|
||||
</div>
|
||||
)}
|
||||
{diagnostic.hint && (
|
||||
<div className="text-xs text-neutral-500 dark:text-slate-500">
|
||||
{diagnostic.hint}
|
||||
</div>
|
||||
)}
|
||||
{diagnostic.pending && (
|
||||
// Decorative and on a 7s rotation: inside the live region it would
|
||||
// interrupt a screen reader again every time it cycles.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mt-1 flex flex-col gap-1 text-neutral-600 dark:text-slate-400"
|
||||
>
|
||||
<RotateThrough interval={7 * 1000}>
|
||||
{WHIMSICAL_LOADING_MESSAGES.map((message) => (
|
||||
<span key={message}>{message}</span>
|
||||
))}
|
||||
</RotateThrough>
|
||||
<AnimatedWave text={WHIMSICAL_SPARKLE} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -402,7 +402,9 @@ describe("PromptBox", () => {
|
|||
const textarea = screen.getByPlaceholderText("Enter your prompt...");
|
||||
textarea.scrollIntoView = vi.fn();
|
||||
|
||||
act(() => ref.current?.focusAndPrefillExample("AAPLStockPrice"));
|
||||
act(() =>
|
||||
ref.current?.focusAndPrefillExample("AAPLStockPrice", "finditparts"),
|
||||
);
|
||||
expect((textarea as HTMLTextAreaElement).value).toContain(
|
||||
'find the "AAPL" stock price',
|
||||
);
|
||||
|
|
@ -416,7 +418,9 @@ describe("PromptBox", () => {
|
|||
);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Keep my agent prompt" } });
|
||||
act(() => ref.current?.focusAndPrefillExample("contact_us_forms"));
|
||||
act(() =>
|
||||
ref.current?.focusAndPrefillExample("contact_us_forms", "finditparts"),
|
||||
);
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe(
|
||||
"Keep my agent prompt",
|
||||
);
|
||||
|
|
@ -699,7 +703,7 @@ describe("PromptBox", () => {
|
|||
|
||||
for (const [key, expected] of cases) {
|
||||
fireEvent.change(textarea, { target: { value: "" } });
|
||||
act(() => ref.current?.focusAndPrefillExample(key));
|
||||
act(() => ref.current?.focusAndPrefillExample(key, "finditparts"));
|
||||
expect(textarea.value).toContain(expected);
|
||||
}
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -159,7 +159,13 @@ type PromptBoxProps = {
|
|||
};
|
||||
|
||||
type PromptBoxHandle = {
|
||||
focusAndPrefillExample: (key: ExamplePromptKey) => void;
|
||||
/** Prefills `key`, or `fallback` when `key` is not a known example (e.g. from a URL). */
|
||||
focusAndPrefillExample: (
|
||||
key: string | null,
|
||||
fallback: ExamplePromptKey,
|
||||
) => void;
|
||||
/** Prefills the user's own words; never overwrites a prompt already typed. */
|
||||
focusAndPrefillPrompt: (text: string) => void;
|
||||
};
|
||||
|
||||
const HANDOFF_TITLE_MAX_LEN = 80;
|
||||
|
|
@ -288,9 +294,11 @@ function PromptBoxImpl(
|
|||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusAndPrefillExample: (key) => {
|
||||
focusAndPrefillExample: (key, fallback) => {
|
||||
const selectedExample =
|
||||
exampleCases.find((example) => example.key === key) ?? exampleCases[0];
|
||||
exampleCases.find((example) => example.key === key) ??
|
||||
exampleCases.find((example) => example.key === fallback) ??
|
||||
exampleCases[0];
|
||||
if (!prompt.trim()) {
|
||||
cancelSpeech();
|
||||
setPrompt(selectedExample.prompt);
|
||||
|
|
@ -299,6 +307,14 @@ function PromptBoxImpl(
|
|||
textareaRef.current?.scrollIntoView?.({ block: "center" });
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
},
|
||||
focusAndPrefillPrompt: (text) => {
|
||||
if (!prompt.trim()) {
|
||||
setPrompt(text);
|
||||
setExampleAttribution(undefined);
|
||||
}
|
||||
textareaRef.current?.scrollIntoView?.({ block: "center" });
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
},
|
||||
}));
|
||||
|
||||
const uploadDocumentMutation = useMutation({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { usePostHog } from "posthog-js/react";
|
|||
|
||||
import { StreamStatusPanel } from "@/routes/streaming/StreamDiagnostics";
|
||||
import type { StreamState } from "@/routes/streaming/streamState";
|
||||
import { usePageSlots } from "@/store/PageSlots";
|
||||
import { useStudioBrowserStore } from "@/store/useStudioBrowserStore";
|
||||
|
||||
import { HeroRecording } from "./runview/HeroRecording";
|
||||
|
|
@ -31,6 +32,7 @@ export function BrowserTab() {
|
|||
liveSurface,
|
||||
} = useBrowserPaneView();
|
||||
const postHog = usePostHog();
|
||||
const { firstRunWaitCard: FirstRunWaitCard } = usePageSlots();
|
||||
const setRunStreamState = useStudioBrowserStore((s) => s.setRunStreamState);
|
||||
|
||||
const {
|
||||
|
|
@ -93,6 +95,11 @@ export function BrowserTab() {
|
|||
detail: "Getting your run's browser ready…",
|
||||
pending: true,
|
||||
}}
|
||||
footer={
|
||||
FirstRunWaitCard ? (
|
||||
<FirstRunWaitCard phase="run_provisioning" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RunLiveStream
|
||||
|
|
@ -128,6 +135,11 @@ export function BrowserTab() {
|
|||
"Spinning up the debug browser — this only takes a moment.",
|
||||
pending: true,
|
||||
}}
|
||||
footer={
|
||||
FirstRunWaitCard ? (
|
||||
<FirstRunWaitCard phase="debug_browser_warming" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : view === "recording" ? (
|
||||
|
|
|
|||
|
|
@ -8,10 +8,15 @@ export type WorkflowRunMilestoneCardProps = Readonly<{
|
|||
rerun?: Readonly<{ to: string; state?: unknown }>;
|
||||
}>;
|
||||
|
||||
export type FirstRunWaitCardProps = Readonly<{
|
||||
phase: "run_provisioning" | "debug_browser_warming";
|
||||
}>;
|
||||
|
||||
export type PageSlots = {
|
||||
workflowAnalyticsPanel?: React.ComponentType<WorkflowAnalyticsPanelProps>;
|
||||
workflowRunsFilterControls?: React.ComponentType;
|
||||
workflowRunMilestoneCard?: React.ComponentType<WorkflowRunMilestoneCardProps>;
|
||||
firstRunWaitCard?: React.ComponentType<FirstRunWaitCardProps>;
|
||||
workflowCreatorDirectory?: React.ComponentType<{
|
||||
children: React.ReactNode;
|
||||
}>;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ function isAuthoritativeConfirmedResponse(
|
|||
response: OnboardingStateResponse,
|
||||
): boolean {
|
||||
const status = response.questionnaire_prompt_result?.status;
|
||||
return status !== "flag_disabled" && status !== "ineligible";
|
||||
return (
|
||||
status !== "flag_disabled" &&
|
||||
status !== "flag_unavailable" &&
|
||||
status !== "ineligible"
|
||||
);
|
||||
}
|
||||
|
||||
function mergeConfirmedResponse(
|
||||
|
|
@ -174,25 +178,26 @@ function OnboardingProvider({ children }: Readonly<Props>) {
|
|||
},
|
||||
[credentialGetter, isCurrent],
|
||||
);
|
||||
const { data, isLoading } = useQuery<OnboardingStateResponse>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const { client, headers } = await requestClient(generation);
|
||||
const response = await client.get<OnboardingStateResponse>(
|
||||
"/users/me/onboarding",
|
||||
{ headers },
|
||||
);
|
||||
if (!isCurrent(generation)) throw new CancelledError();
|
||||
const legacyFields = legacyFieldsToReplay(
|
||||
legacyWritesRef.current,
|
||||
legacyWriteVersionRef.current + 1,
|
||||
);
|
||||
return Object.keys(legacyFields).length === 0
|
||||
? response.data
|
||||
: mergeNewerLegacyFields(response.data, legacyFields);
|
||||
},
|
||||
enabled: !!credentialGetter && !!userId,
|
||||
});
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useQuery<OnboardingStateResponse>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const { client, headers } = await requestClient(generation);
|
||||
const response = await client.get<OnboardingStateResponse>(
|
||||
"/users/me/onboarding",
|
||||
{ headers },
|
||||
);
|
||||
if (!isCurrent(generation)) throw new CancelledError();
|
||||
const legacyFields = legacyFieldsToReplay(
|
||||
legacyWritesRef.current,
|
||||
legacyWriteVersionRef.current + 1,
|
||||
);
|
||||
return Object.keys(legacyFields).length === 0
|
||||
? response.data
|
||||
: mergeNewerLegacyFields(response.data, legacyFields);
|
||||
},
|
||||
enabled: !!credentialGetter && !!userId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof BroadcastChannel === "undefined") return;
|
||||
|
|
@ -454,11 +459,14 @@ function OnboardingProvider({ children }: Readonly<Props>) {
|
|||
value={{
|
||||
state: data?.onboarding_state ?? null,
|
||||
isLoading,
|
||||
loadFailed: isError && !isFetching,
|
||||
retryLoad: () => void refetch(),
|
||||
updateState,
|
||||
updateStateConfirmed,
|
||||
isNewUser,
|
||||
abVariant,
|
||||
recoveryGuidanceAssignment: data?.recovery_guidance_assignment ?? null,
|
||||
organizationId: data?.organization_id ?? null,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ export const QUESTIONNAIRE_USER_INTENTS_V1 = [
|
|||
"extract_data",
|
||||
"monitor_website",
|
||||
"something_else",
|
||||
"invoices",
|
||||
"job_applications",
|
||||
"healthcare",
|
||||
"government",
|
||||
"employee_onboarding",
|
||||
] as const;
|
||||
|
||||
export type QuestionnaireUserIntentV1 =
|
||||
|
|
@ -24,7 +29,10 @@ export type QuestionnaireRoleV1 =
|
|||
| "product_manager"
|
||||
| "founder_or_executive"
|
||||
| "other"
|
||||
| "prefer_not_to_say";
|
||||
| "prefer_not_to_say"
|
||||
| "founder"
|
||||
| "executive"
|
||||
| "sales_or_marketing";
|
||||
|
||||
export type QuestionnaireCompanyContextV1 =
|
||||
| "personal_or_individual"
|
||||
|
|
@ -53,7 +61,8 @@ export type QuestionnaireReferralSourceV1 =
|
|||
| "event_or_community"
|
||||
| "podcast"
|
||||
| "other"
|
||||
| "prefer_not_to_say";
|
||||
| "prefer_not_to_say"
|
||||
| "github";
|
||||
|
||||
export type QuestionnaireReferralSourceDetailV1 =
|
||||
| "chatgpt"
|
||||
|
|
@ -67,12 +76,33 @@ export type QuestionnaireReferralSourceDetailV1 =
|
|||
| "youtube"
|
||||
| "other_social";
|
||||
|
||||
export type QuestionnaireAudienceV1 =
|
||||
| "just_me"
|
||||
| "my_team"
|
||||
| "my_company"
|
||||
| "my_customers"
|
||||
| "school"
|
||||
| "research";
|
||||
|
||||
export type QuestionnaireWeeklyRunVolumeV1 =
|
||||
| "under_10"
|
||||
| "10_to_100"
|
||||
| "100_to_1000"
|
||||
| "over_1000"
|
||||
| "not_sure";
|
||||
|
||||
// The welcome page answers audience and weekly_run_volume in place of company_context and scale_intent.
|
||||
export type QuestionnaireAnswersV1 = {
|
||||
role: QuestionnaireRoleV1;
|
||||
company_context: QuestionnaireCompanyContextV1;
|
||||
scale_intent: QuestionnaireScaleIntentV1;
|
||||
company_context?: QuestionnaireCompanyContextV1;
|
||||
scale_intent?: QuestionnaireScaleIntentV1;
|
||||
referral_source: QuestionnaireReferralSourceV1;
|
||||
referral_source_detail?: QuestionnaireReferralSourceDetailV1 | null;
|
||||
role_other?: string | null;
|
||||
referral_source_other?: string | null;
|
||||
audience?: QuestionnaireAudienceV1 | null;
|
||||
weekly_run_volume?: QuestionnaireWeeklyRunVolumeV1 | null;
|
||||
organization_name?: string | null;
|
||||
};
|
||||
|
||||
export type QuestionnairePatchV1 =
|
||||
|
|
@ -94,7 +124,13 @@ export type QuestionnairePromptPatchV1 = { version: 1; action: "reserve" };
|
|||
|
||||
export type QuestionnairePromptResultV1 =
|
||||
| { status: "reserved"; prompted_at: string }
|
||||
| { status: "flag_disabled" | "ineligible" | "already_prompted" };
|
||||
| {
|
||||
status:
|
||||
| "flag_disabled"
|
||||
| "flag_unavailable"
|
||||
| "ineligible"
|
||||
| "already_prompted";
|
||||
};
|
||||
|
||||
export type QuestionnaireStatusV1 = "completed" | "skipped" | "deferred";
|
||||
|
||||
|
|
@ -130,6 +166,11 @@ export type QuestionnaireStateV1 = {
|
|||
scale_intent: QuestionnaireScaleIntentV1 | null;
|
||||
referral_source: QuestionnaireReferralSourceV1 | null;
|
||||
referral_source_detail?: QuestionnaireReferralSourceDetailV1 | null;
|
||||
role_other?: string | null;
|
||||
referral_source_other?: string | null;
|
||||
audience?: QuestionnaireAudienceV1 | null;
|
||||
weekly_run_volume?: QuestionnaireWeeklyRunVolumeV1 | null;
|
||||
organization_name?: string | null;
|
||||
completed_at: string | null;
|
||||
skipped_at: string | null;
|
||||
deferred_at: string | null;
|
||||
|
|
@ -145,6 +186,9 @@ export type OnboardingState = {
|
|||
first_run_at: string | null;
|
||||
ab_variant: string | null;
|
||||
user_intent: string | null;
|
||||
user_intents?: string[] | null;
|
||||
user_intent_detail?: string | null;
|
||||
email_domain_type?: "company" | "webmail" | null;
|
||||
questionnaire_prompted_at?: string | null;
|
||||
questionnaire?: QuestionnaireStateV1 | null;
|
||||
seen_canvas: boolean | null;
|
||||
|
|
|
|||
|
|
@ -14,11 +14,16 @@ type ConfirmedOnboardingStateWriter = (
|
|||
type OnboardingContextValue = {
|
||||
state: OnboardingState | null;
|
||||
isLoading: boolean;
|
||||
// The state read failed and is not being retried; isLoading is false and state stays null.
|
||||
loadFailed?: boolean;
|
||||
retryLoad?: () => void;
|
||||
updateState: (patch: LegacyOnboardingStatePatch) => void;
|
||||
updateStateConfirmed: ConfirmedOnboardingStateWriter;
|
||||
isNewUser: boolean;
|
||||
abVariant: string | null;
|
||||
recoveryGuidanceAssignment: RecoveryGuidanceAssignment | null;
|
||||
// The Skyvern organization the onboarding state was read for.
|
||||
organizationId?: string | null;
|
||||
};
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextValue | null>(null);
|
||||
|
|
|
|||
|
|
@ -25,9 +25,6 @@ export const WORKFLOW_TAGGING_FLAG = "WORKFLOW_TAGGING";
|
|||
// Not enrolled reads as disabled, so the default stays the flat folders/list.
|
||||
export const WORKFLOWS_DIRECTORY_TREE_FLAG = "WORKFLOWS_DIRECTORY_TREE";
|
||||
|
||||
// Gates the optional, server-confirmed onboarding details step.
|
||||
export const ONBOARDING_QUESTIONNAIRE_FLAG = "onboarding_questionnaire_v1";
|
||||
|
||||
// Gates the login-block fallback-credential editor. Off ⇒ the fallback config is hidden, because
|
||||
// automatic retries only run for orgs in the CREDENTIAL_FALLBACK_RETRY rollout (backend gate), so
|
||||
// showing the editor to other orgs would promise a retry that never fires. Server-evaluated via
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ describe("OnboardingTelemetry", () => {
|
|||
{
|
||||
questionnaire_version: 1,
|
||||
"$feature/onboarding_questionnaire_v1": true,
|
||||
surface: "get_started_modal",
|
||||
surface: "welcome_page",
|
||||
primary_intent: null,
|
||||
prompt_reason: "initial",
|
||||
organization_id: "org_123",
|
||||
|
|
@ -149,12 +149,15 @@ describe("OnboardingTelemetry", () => {
|
|||
{
|
||||
questionnaire_version: 1,
|
||||
"$feature/onboarding_questionnaire_v1": true,
|
||||
surface: "get_started_modal",
|
||||
surface: "welcome_page",
|
||||
response_id: "response",
|
||||
revision: 1,
|
||||
primary_intent: "fill_forms",
|
||||
...answers,
|
||||
referral_source_detail: null,
|
||||
audience: null,
|
||||
weekly_run_volume: null,
|
||||
organization_name_present: false,
|
||||
organization_id: "org_123",
|
||||
step_id: "onboarding_questionnaire_completed:v1",
|
||||
project_owner_reported: false,
|
||||
|
|
@ -169,7 +172,7 @@ describe("OnboardingTelemetry", () => {
|
|||
{
|
||||
questionnaire_version: 1,
|
||||
"$feature/onboarding_questionnaire_v1": true,
|
||||
surface: "get_started_modal",
|
||||
surface: "welcome_page",
|
||||
response_id: "response",
|
||||
revision: 2,
|
||||
disposition: "skip",
|
||||
|
|
@ -184,13 +187,16 @@ describe("OnboardingTelemetry", () => {
|
|||
{
|
||||
questionnaire_version: 1,
|
||||
"$feature/onboarding_questionnaire_v1": true,
|
||||
surface: "get_started_modal",
|
||||
surface: "welcome_page",
|
||||
response_id: "response",
|
||||
revision: 3,
|
||||
primary_intent: "fill_forms",
|
||||
previous_status: "deferred",
|
||||
...answers,
|
||||
referral_source_detail: null,
|
||||
audience: null,
|
||||
weekly_run_volume: null,
|
||||
organization_name_present: false,
|
||||
organization_id: "org_123",
|
||||
step_id: "onboarding_questionnaire_updated:v1",
|
||||
project_owner_reported: false,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ type Surface =
|
|||
|
||||
type TourLayer = 1 | 2;
|
||||
|
||||
const QUESTIONNAIRE_SURFACE = "welcome_page";
|
||||
|
||||
type QuestionnaireShownInput = {
|
||||
primaryIntent: QuestionnaireUserIntentV1 | null;
|
||||
promptReason: "initial" | "resume";
|
||||
|
|
@ -78,10 +80,6 @@ function capture(event: string, properties: Properties): boolean {
|
|||
|
||||
// -- Flow events --
|
||||
|
||||
function flowStarted(surface: Surface): void {
|
||||
capture("onboarding.flow_started", { surface });
|
||||
}
|
||||
|
||||
function flowCompleted(surface: Surface): void {
|
||||
capture("onboarding.flow_completed", { surface });
|
||||
}
|
||||
|
|
@ -126,46 +124,11 @@ function tourDismissed(surface: Surface, lastStep: string): void {
|
|||
capture("onboarding.tour_dismissed", { surface, last_step: lastStep });
|
||||
}
|
||||
|
||||
// -- Modal events --
|
||||
|
||||
function modalOpened(surface: Surface): void {
|
||||
capture("onboarding.modal_opened", { surface });
|
||||
}
|
||||
|
||||
function modalTemplateSelected(
|
||||
surface: Surface,
|
||||
templateId: string,
|
||||
intent: string,
|
||||
): void {
|
||||
capture("onboarding.modal_template_selected", {
|
||||
surface,
|
||||
template_id: templateId,
|
||||
intent,
|
||||
});
|
||||
}
|
||||
|
||||
function modalCopilotClicked(
|
||||
surface: Surface,
|
||||
intent: string,
|
||||
promptText: string,
|
||||
): void {
|
||||
capture("onboarding.modal_copilot_clicked", {
|
||||
surface,
|
||||
intent,
|
||||
// capture only length, never the raw prompt - it can contain customer URLs / PII
|
||||
prompt_length: promptText.length,
|
||||
});
|
||||
}
|
||||
|
||||
function modalSkipped(surface: Surface): void {
|
||||
capture("onboarding.modal_skipped", { surface });
|
||||
}
|
||||
|
||||
function questionnaireResponseProperties(input: QuestionnaireResponseInput) {
|
||||
return {
|
||||
questionnaire_version: 1,
|
||||
"$feature/onboarding_questionnaire_v1": true,
|
||||
surface: "get_started_modal",
|
||||
surface: QUESTIONNAIRE_SURFACE,
|
||||
response_id: input.responseId,
|
||||
revision: input.revision,
|
||||
...(input.organizationId ? { organization_id: input.organizationId } : {}),
|
||||
|
|
@ -175,10 +138,14 @@ function questionnaireResponseProperties(input: QuestionnaireResponseInput) {
|
|||
function questionnaireAnswerProperties(answers: QuestionnaireAnswersV1) {
|
||||
return {
|
||||
role: answers.role,
|
||||
company_context: answers.company_context,
|
||||
scale_intent: answers.scale_intent,
|
||||
company_context: answers.company_context ?? null,
|
||||
scale_intent: answers.scale_intent ?? null,
|
||||
referral_source: answers.referral_source,
|
||||
referral_source_detail: answers.referral_source_detail ?? null,
|
||||
audience: answers.audience ?? null,
|
||||
weekly_run_volume: answers.weekly_run_volume ?? null,
|
||||
// Free text never leaves the app; only whether it was given.
|
||||
organization_name_present: Boolean(answers.organization_name),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +153,7 @@ function questionnaireShown(input: QuestionnaireShownInput): boolean {
|
|||
return capture("onboarding_questionnaire_shown", {
|
||||
questionnaire_version: 1,
|
||||
"$feature/onboarding_questionnaire_v1": true,
|
||||
surface: "get_started_modal",
|
||||
surface: QUESTIONNAIRE_SURFACE,
|
||||
primary_intent: input.primaryIntent,
|
||||
prompt_reason: input.promptReason,
|
||||
...(input.organizationId ? { organization_id: input.organizationId } : {}),
|
||||
|
|
@ -283,10 +250,6 @@ function error(surface: Surface): void {
|
|||
capture("onboarding.error", { surface });
|
||||
}
|
||||
|
||||
function modalRenderError(surface: Surface): void {
|
||||
capture("onboarding.modal_render_error", { surface });
|
||||
}
|
||||
|
||||
function tourError(surface: Surface): void {
|
||||
capture("onboarding.tour_error", { surface });
|
||||
}
|
||||
|
|
@ -302,7 +265,6 @@ function hintDismissed(surface: Surface, hintId: string): void {
|
|||
}
|
||||
|
||||
export const OnboardingTelemetry = {
|
||||
flowStarted,
|
||||
flowCompleted,
|
||||
dropOff,
|
||||
tourStarted,
|
||||
|
|
@ -311,10 +273,6 @@ export const OnboardingTelemetry = {
|
|||
tourSkipped,
|
||||
stepCompleted,
|
||||
tourDismissed,
|
||||
modalOpened,
|
||||
modalTemplateSelected,
|
||||
modalCopilotClicked,
|
||||
modalSkipped,
|
||||
questionnaireShown,
|
||||
questionnaireCompleted,
|
||||
questionnaireSkipped,
|
||||
|
|
@ -328,7 +286,6 @@ export const OnboardingTelemetry = {
|
|||
abVariantAssigned,
|
||||
registerVariant,
|
||||
error,
|
||||
modalRenderError,
|
||||
tourError,
|
||||
hintShown,
|
||||
hintDismissed,
|
||||
|
|
|
|||
|
|
@ -2979,6 +2979,15 @@ class AgentFunction:
|
|||
"""Fired after a workflow is saved. Overrides must be best-effort and never raise."""
|
||||
return
|
||||
|
||||
async def on_workflow_updated_by_user(
|
||||
self,
|
||||
organization_id: str,
|
||||
user_id: str | None,
|
||||
workflow: Workflow,
|
||||
) -> None:
|
||||
"""Fired after the update-agent routes save a new version. Overrides must be best-effort and never raise."""
|
||||
return
|
||||
|
||||
async def on_workflow_run_completed(
|
||||
self,
|
||||
organization_id: str,
|
||||
|
|
|
|||
|
|
@ -1382,7 +1382,7 @@ async def update_workflow_legacy(
|
|||
|
||||
try:
|
||||
workflow_create_request = WorkflowCreateYAMLRequest.model_validate(workflow_yaml)
|
||||
return await app.WORKFLOW_SERVICE.create_workflow_from_request(
|
||||
workflow = await app.WORKFLOW_SERVICE.create_workflow_from_request(
|
||||
organization=current_org,
|
||||
request=workflow_create_request,
|
||||
workflow_permanent_id=workflow_id,
|
||||
|
|
@ -1407,6 +1407,8 @@ async def update_workflow_legacy(
|
|||
organization_id=current_org.organization_id,
|
||||
)
|
||||
raise FailedToUpdateWorkflow(workflow_id, f"<{type(e).__name__}: {str(e)}>")
|
||||
await app.AGENT_FUNCTION.on_workflow_updated_by_user(current_org.organization_id, user_id, workflow)
|
||||
return workflow
|
||||
|
||||
|
||||
@base_router.post(
|
||||
|
|
@ -1468,7 +1470,7 @@ async def update_workflow(
|
|||
status_code=422,
|
||||
detail="Invalid workflow definition. Workflow should be provided in either yaml or json format.",
|
||||
)
|
||||
return await app.WORKFLOW_SERVICE.create_workflow_from_request(
|
||||
workflow = await app.WORKFLOW_SERVICE.create_workflow_from_request(
|
||||
organization=current_org,
|
||||
request=workflow_definition,
|
||||
workflow_permanent_id=workflow_id,
|
||||
|
|
@ -1490,6 +1492,8 @@ async def update_workflow(
|
|||
workflow_permanent_id=workflow_id,
|
||||
)
|
||||
raise FailedToUpdateWorkflow(workflow_id, f"<{type(e).__name__}: {str(e)}>")
|
||||
await app.AGENT_FUNCTION.on_workflow_updated_by_user(current_org.organization_id, user_id, workflow)
|
||||
return workflow
|
||||
|
||||
|
||||
@legacy_base_router.delete(
|
||||
|
|
|
|||
|
|
@ -1426,6 +1426,10 @@ class ReusedSessionBelowLifetimeFloor(Exception):
|
|||
self.shortfall = shortfall
|
||||
|
||||
|
||||
def workflow_definitions_differ(previous: WorkflowDefinition, current: WorkflowDefinition) -> bool:
|
||||
return _get_workflow_definition_core_data(previous) != _get_workflow_definition_core_data(current)
|
||||
|
||||
|
||||
def _get_workflow_definition_core_data(
|
||||
workflow_definition: WorkflowDefinition, *, unset_engine_is_v1: bool = True
|
||||
) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -240,7 +240,9 @@ class TestWorkflowRoutesThreadUser:
|
|||
data = WorkflowRequest(json_definition=_yaml_request())
|
||||
|
||||
with patch("skyvern.forge.sdk.routes.agent_protocol.app") as mock_app:
|
||||
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=MagicMock())
|
||||
saved = MagicMock()
|
||||
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=saved)
|
||||
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user = AsyncMock()
|
||||
await update_workflow(
|
||||
data=data,
|
||||
workflow_id="wpid_1",
|
||||
|
|
@ -248,6 +250,7 @@ class TestWorkflowRoutesThreadUser:
|
|||
user_id="u_456",
|
||||
)
|
||||
kwargs = mock_app.WORKFLOW_SERVICE.create_workflow_from_request.await_args.kwargs
|
||||
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user.assert_awaited_once_with("o_123", "u_456", saved)
|
||||
|
||||
assert kwargs.get("created_by") == "u_456"
|
||||
assert kwargs.get("edited_by") == "u_456"
|
||||
|
|
@ -288,7 +291,9 @@ class TestWorkflowRoutesThreadUser:
|
|||
)
|
||||
|
||||
with patch("skyvern.forge.sdk.routes.agent_protocol.app") as mock_app:
|
||||
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=MagicMock())
|
||||
saved = MagicMock()
|
||||
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=saved)
|
||||
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user = AsyncMock()
|
||||
await update_workflow_legacy(
|
||||
request=raw_request,
|
||||
workflow_id="wpid_1",
|
||||
|
|
@ -296,6 +301,7 @@ class TestWorkflowRoutesThreadUser:
|
|||
user_id="u_456",
|
||||
)
|
||||
kwargs = mock_app.WORKFLOW_SERVICE.create_workflow_from_request.await_args.kwargs
|
||||
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user.assert_awaited_once_with("o_123", "u_456", saved)
|
||||
|
||||
assert kwargs.get("created_by") == "u_456"
|
||||
assert kwargs.get("edited_by") == "u_456"
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ def test_only_the_detail_get_carries_the_effective_default_engine(
|
|||
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=_stored_workflow())
|
||||
mock_app.WORKFLOW_SERVICE.get_workflows_by_organization_id = AsyncMock(return_value=[_stored_workflow()])
|
||||
mock_app.DATABASE.workflows.is_workflow_copilot_authored = AsyncMock(return_value=False)
|
||||
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user = AsyncMock()
|
||||
with (
|
||||
patch("skyvern.forge.sdk.routes.agent_protocol.app", mock_app),
|
||||
patch("skyvern.forge.sdk.routes.agent_protocol.effective_default_engine", AsyncMock(return_value=computed)),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue