test: harden onboarding route coverage
This commit is contained in:
@@ -22,14 +22,11 @@ const TASK_TITLE = "E2E test task";
|
|||||||
|
|
||||||
test.describe("Onboarding wizard", () => {
|
test.describe("Onboarding wizard", () => {
|
||||||
test("completes full wizard flow", async ({ page }) => {
|
test("completes full wizard flow", async ({ page }) => {
|
||||||
// Navigate to root — should auto-open onboarding when no companies exist
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
// If the wizard didn't auto-open (company already exists), click the button
|
|
||||||
const wizardHeading = page.locator("h3", { hasText: "Name your company" });
|
const wizardHeading = page.locator("h3", { hasText: "Name your company" });
|
||||||
const newCompanyBtn = page.getByRole("button", { name: "New Company" });
|
const newCompanyBtn = page.getByRole("button", { name: "New Company" });
|
||||||
|
|
||||||
// Wait for either the wizard or the start page
|
|
||||||
await expect(
|
await expect(
|
||||||
wizardHeading.or(newCompanyBtn)
|
wizardHeading.or(newCompanyBtn)
|
||||||
).toBeVisible({ timeout: 15_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
@@ -38,40 +35,28 @@ test.describe("Onboarding wizard", () => {
|
|||||||
await newCompanyBtn.click();
|
await newCompanyBtn.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Step 1: Name your company
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
await expect(wizardHeading).toBeVisible({ timeout: 5_000 });
|
await expect(wizardHeading).toBeVisible({ timeout: 5_000 });
|
||||||
await expect(page.locator("text=Step 1 of 4")).toBeVisible();
|
|
||||||
|
|
||||||
const companyNameInput = page.locator('input[placeholder="Acme Corp"]');
|
const companyNameInput = page.locator('input[placeholder="Acme Corp"]');
|
||||||
await companyNameInput.fill(COMPANY_NAME);
|
await companyNameInput.fill(COMPANY_NAME);
|
||||||
|
|
||||||
// Click Next
|
|
||||||
const nextButton = page.getByRole("button", { name: "Next" });
|
const nextButton = page.getByRole("button", { name: "Next" });
|
||||||
await nextButton.click();
|
await nextButton.click();
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Step 2: Create your first agent
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("h3", { hasText: "Create your first agent" })
|
page.locator("h3", { hasText: "Create your first agent" })
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.locator("text=Step 2 of 4")).toBeVisible();
|
|
||||||
|
|
||||||
// Agent name should default to "CEO"
|
|
||||||
const agentNameInput = page.locator('input[placeholder="CEO"]');
|
const agentNameInput = page.locator('input[placeholder="CEO"]');
|
||||||
await expect(agentNameInput).toHaveValue(AGENT_NAME);
|
await expect(agentNameInput).toHaveValue(AGENT_NAME);
|
||||||
|
|
||||||
// Claude Code adapter should be selected by default
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("button", { hasText: "Claude Code" }).locator("..")
|
page.locator("button", { hasText: "Claude Code" }).locator("..")
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// Select the "Process" adapter to avoid needing a real CLI tool installed
|
await page.getByRole("button", { name: "More Agent Adapter Types" }).click();
|
||||||
await page.locator("button", { hasText: "Process" }).click();
|
await page.getByRole("button", { name: "Process" }).click();
|
||||||
|
|
||||||
// Fill in process adapter fields
|
|
||||||
const commandInput = page.locator('input[placeholder="e.g. node, python"]');
|
const commandInput = page.locator('input[placeholder="e.g. node, python"]');
|
||||||
await commandInput.fill("echo");
|
await commandInput.fill("echo");
|
||||||
const argsInput = page.locator(
|
const argsInput = page.locator(
|
||||||
@@ -79,52 +64,34 @@ test.describe("Onboarding wizard", () => {
|
|||||||
);
|
);
|
||||||
await argsInput.fill("hello");
|
await argsInput.fill("hello");
|
||||||
|
|
||||||
// Click Next (process adapter skips environment test)
|
|
||||||
await page.getByRole("button", { name: "Next" }).click();
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Step 3: Give it something to do
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("h3", { hasText: "Give it something to do" })
|
page.locator("h3", { hasText: "Give it something to do" })
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.locator("text=Step 3 of 4")).toBeVisible();
|
|
||||||
|
|
||||||
// Clear default title and set our test title
|
|
||||||
const taskTitleInput = page.locator(
|
const taskTitleInput = page.locator(
|
||||||
'input[placeholder="e.g. Research competitor pricing"]'
|
'input[placeholder="e.g. Research competitor pricing"]'
|
||||||
);
|
);
|
||||||
await taskTitleInput.clear();
|
await taskTitleInput.clear();
|
||||||
await taskTitleInput.fill(TASK_TITLE);
|
await taskTitleInput.fill(TASK_TITLE);
|
||||||
|
|
||||||
// Click Next
|
|
||||||
await page.getByRole("button", { name: "Next" }).click();
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Step 4: Ready to launch
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("h3", { hasText: "Ready to launch" })
|
page.locator("h3", { hasText: "Ready to launch" })
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.locator("text=Step 4 of 4")).toBeVisible();
|
|
||||||
|
|
||||||
// Verify summary displays our created entities
|
|
||||||
await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible();
|
await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible();
|
||||||
await expect(page.locator("text=" + AGENT_NAME)).toBeVisible();
|
await expect(page.locator("text=" + AGENT_NAME)).toBeVisible();
|
||||||
await expect(page.locator("text=" + TASK_TITLE)).toBeVisible();
|
await expect(page.locator("text=" + TASK_TITLE)).toBeVisible();
|
||||||
|
|
||||||
// Click "Open Issue"
|
await page.getByRole("button", { name: "Create & Open Issue" }).click();
|
||||||
await page.getByRole("button", { name: "Open Issue" }).click();
|
|
||||||
|
|
||||||
// Should navigate to the issue page
|
|
||||||
await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
|
await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
|
||||||
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
// Verify via API that entities were created
|
|
||||||
// -----------------------------------------------------------
|
|
||||||
const baseUrl = page.url().split("/").slice(0, 3).join("/");
|
const baseUrl = page.url().split("/").slice(0, 3).join("/");
|
||||||
|
|
||||||
// List companies and find ours
|
|
||||||
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
|
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
|
||||||
expect(companiesRes.ok()).toBe(true);
|
expect(companiesRes.ok()).toBe(true);
|
||||||
const companies = await companiesRes.json();
|
const companies = await companiesRes.json();
|
||||||
@@ -133,7 +100,6 @@ test.describe("Onboarding wizard", () => {
|
|||||||
);
|
);
|
||||||
expect(company).toBeTruthy();
|
expect(company).toBeTruthy();
|
||||||
|
|
||||||
// List agents for our company
|
|
||||||
const agentsRes = await page.request.get(
|
const agentsRes = await page.request.get(
|
||||||
`${baseUrl}/api/companies/${company.id}/agents`
|
`${baseUrl}/api/companies/${company.id}/agents`
|
||||||
);
|
);
|
||||||
@@ -146,7 +112,6 @@ test.describe("Onboarding wizard", () => {
|
|||||||
expect(ceoAgent.role).toBe("ceo");
|
expect(ceoAgent.role).toBe("ceo");
|
||||||
expect(ceoAgent.adapterType).toBe("process");
|
expect(ceoAgent.adapterType).toBe("process");
|
||||||
|
|
||||||
// List issues for our company
|
|
||||||
const issuesRes = await page.request.get(
|
const issuesRes = await page.request.get(
|
||||||
`${baseUrl}/api/companies/${company.id}/issues`
|
`${baseUrl}/api/companies/${company.id}/issues`
|
||||||
);
|
);
|
||||||
@@ -159,7 +124,6 @@ test.describe("Onboarding wizard", () => {
|
|||||||
expect(task.assigneeAgentId).toBe(ceoAgent.id);
|
expect(task.assigneeAgentId).toBe(ceoAgent.id);
|
||||||
|
|
||||||
if (!SKIP_LLM) {
|
if (!SKIP_LLM) {
|
||||||
// LLM-dependent: wait for the heartbeat to transition the issue
|
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const res = await page.request.get(
|
const res = await page.request.get(
|
||||||
`${baseUrl}/api/issues/${task.id}`
|
`${baseUrl}/api/issues/${task.id}`
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
|||||||
// The webServer directive starts `paperclipai run` before tests.
|
// The webServer directive starts `paperclipai run` before tests.
|
||||||
// Expects `pnpm paperclipai` to be runnable from repo root.
|
// Expects `pnpm paperclipai` to be runnable from repo root.
|
||||||
webServer: {
|
webServer: {
|
||||||
command: `pnpm paperclipai run --yes`,
|
command: `pnpm paperclipai run`,
|
||||||
url: `${BASE_URL}/api/health`,
|
url: `${BASE_URL}/api/health`,
|
||||||
reuseExistingServer: !!process.env.CI,
|
reuseExistingServer: !!process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -40,6 +39,7 @@ import { queryKeys } from "./lib/queryKeys";
|
|||||||
import { useCompany } from "./context/CompanyContext";
|
import { useCompany } from "./context/CompanyContext";
|
||||||
import { useDialog } from "./context/DialogContext";
|
import { useDialog } from "./context/DialogContext";
|
||||||
import { loadLastInboxTab } from "./lib/inbox";
|
import { loadLastInboxTab } from "./lib/inbox";
|
||||||
|
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
|
||||||
|
|
||||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -175,24 +175,13 @@ function LegacySettingsRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OnboardingRoutePage() {
|
function OnboardingRoutePage() {
|
||||||
const { companies, loading } = useCompany();
|
const { companies } = useCompany();
|
||||||
const { onboardingOpen, openOnboarding } = useDialog();
|
const { openOnboarding } = useDialog();
|
||||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||||
const opened = useRef(false);
|
|
||||||
const matchedCompany = companyPrefix
|
const matchedCompany = companyPrefix
|
||||||
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
|
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading || opened.current || onboardingOpen) return;
|
|
||||||
opened.current = true;
|
|
||||||
if (matchedCompany) {
|
|
||||||
openOnboarding({ initialStep: 2, companyId: matchedCompany.id });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openOnboarding();
|
|
||||||
}, [companyPrefix, loading, matchedCompany, onboardingOpen, openOnboarding]);
|
|
||||||
|
|
||||||
const title = matchedCompany
|
const title = matchedCompany
|
||||||
? `Add another agent to ${matchedCompany.name}`
|
? `Add another agent to ${matchedCompany.name}`
|
||||||
: companies.length > 0
|
: companies.length > 0
|
||||||
@@ -227,19 +216,22 @@ function OnboardingRoutePage() {
|
|||||||
|
|
||||||
function CompanyRootRedirect() {
|
function CompanyRootRedirect() {
|
||||||
const { companies, selectedCompany, loading } = useCompany();
|
const { companies, selectedCompany, loading } = useCompany();
|
||||||
const { onboardingOpen } = useDialog();
|
const location = useLocation();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the first-run onboarding mounted until it completes.
|
|
||||||
if (onboardingOpen) {
|
|
||||||
return <NoCompaniesStartPage autoOpen={false} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||||
if (!targetCompany) {
|
if (!targetCompany) {
|
||||||
|
if (
|
||||||
|
shouldRedirectCompanylessRouteToOnboarding({
|
||||||
|
pathname: location.pathname,
|
||||||
|
hasCompanies: false,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return <Navigate to="/onboarding" replace />;
|
||||||
|
}
|
||||||
return <NoCompaniesStartPage />;
|
return <NoCompaniesStartPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +248,14 @@ function UnprefixedBoardRedirect() {
|
|||||||
|
|
||||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||||
if (!targetCompany) {
|
if (!targetCompany) {
|
||||||
|
if (
|
||||||
|
shouldRedirectCompanylessRouteToOnboarding({
|
||||||
|
pathname: location.pathname,
|
||||||
|
hasCompanies: false,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return <Navigate to="/onboarding" replace />;
|
||||||
|
}
|
||||||
return <NoCompaniesStartPage />;
|
return <NoCompaniesStartPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,16 +267,8 @@ function UnprefixedBoardRedirect() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) {
|
function NoCompaniesStartPage() {
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialog();
|
||||||
const opened = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoOpen) return;
|
|
||||||
if (opened.current) return;
|
|
||||||
opened.current = true;
|
|
||||||
openOnboarding();
|
|
||||||
}, [autoOpen, openOnboarding]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<div className="mx-auto max-w-xl py-10">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||||
|
import { useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||||
|
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
import { HintIcon } from "./agent-config-primitives";
|
import { HintIcon } from "./agent-config-primitives";
|
||||||
@@ -75,12 +76,29 @@ After that, hire yourself a Founding Engineer agent and then plan the roadmap an
|
|||||||
|
|
||||||
export function OnboardingWizard() {
|
export function OnboardingWizard() {
|
||||||
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
|
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
|
||||||
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
|
const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||||
|
const [routeDismissed, setRouteDismissed] = useState(false);
|
||||||
|
|
||||||
const initialStep = onboardingOptions.initialStep ?? 1;
|
const routeOnboardingOptions =
|
||||||
const existingCompanyId = onboardingOptions.companyId;
|
companyPrefix && companiesLoading
|
||||||
|
? null
|
||||||
|
: resolveRouteOnboardingOptions({
|
||||||
|
pathname: location.pathname,
|
||||||
|
companyPrefix,
|
||||||
|
companies,
|
||||||
|
});
|
||||||
|
const effectiveOnboardingOpen =
|
||||||
|
onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed);
|
||||||
|
const effectiveOnboardingOptions = onboardingOpen
|
||||||
|
? onboardingOptions
|
||||||
|
: routeOnboardingOptions ?? {};
|
||||||
|
|
||||||
|
const initialStep = effectiveOnboardingOptions.initialStep ?? 1;
|
||||||
|
const existingCompanyId = effectiveOnboardingOptions.companyId;
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>(initialStep);
|
const [step, setStep] = useState<Step>(initialStep);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -134,27 +152,31 @@ export function OnboardingWizard() {
|
|||||||
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
||||||
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
|
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRouteDismissed(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
// Sync step and company when onboarding opens with options.
|
// Sync step and company when onboarding opens with options.
|
||||||
// Keep this independent from company-list refreshes so Step 1 completion
|
// Keep this independent from company-list refreshes so Step 1 completion
|
||||||
// doesn't get reset after creating a company.
|
// doesn't get reset after creating a company.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onboardingOpen) return;
|
if (!effectiveOnboardingOpen) return;
|
||||||
const cId = onboardingOptions.companyId ?? null;
|
const cId = effectiveOnboardingOptions.companyId ?? null;
|
||||||
setStep(onboardingOptions.initialStep ?? 1);
|
setStep(effectiveOnboardingOptions.initialStep ?? 1);
|
||||||
setCreatedCompanyId(cId);
|
setCreatedCompanyId(cId);
|
||||||
setCreatedCompanyPrefix(null);
|
setCreatedCompanyPrefix(null);
|
||||||
}, [
|
}, [
|
||||||
onboardingOpen,
|
effectiveOnboardingOpen,
|
||||||
onboardingOptions.companyId,
|
effectiveOnboardingOptions.companyId,
|
||||||
onboardingOptions.initialStep
|
effectiveOnboardingOptions.initialStep
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Backfill issue prefix for an existing company once companies are loaded.
|
// Backfill issue prefix for an existing company once companies are loaded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
|
if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
|
||||||
const company = companies.find((c) => c.id === createdCompanyId);
|
const company = companies.find((c) => c.id === createdCompanyId);
|
||||||
if (company) setCreatedCompanyPrefix(company.issuePrefix);
|
if (company) setCreatedCompanyPrefix(company.issuePrefix);
|
||||||
}, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
|
}, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
|
||||||
|
|
||||||
// Resize textarea when step 3 is shown or description changes
|
// Resize textarea when step 3 is shown or description changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,7 +193,7 @@ export function OnboardingWizard() {
|
|||||||
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
||||||
: ["agents", "none", "adapter-models", adapterType],
|
: ["agents", "none", "adapter-models", adapterType],
|
||||||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
||||||
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
|
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
|
||||||
});
|
});
|
||||||
const isLocalAdapter =
|
const isLocalAdapter =
|
||||||
adapterType === "claude_local" ||
|
adapterType === "claude_local" ||
|
||||||
@@ -546,13 +568,16 @@ export function OnboardingWizard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!onboardingOpen) return null;
|
if (!effectiveOnboardingOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={onboardingOpen}
|
open={effectiveOnboardingOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) handleClose();
|
if (!open) {
|
||||||
|
setRouteDismissed(true);
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
@@ -762,6 +787,12 @@ export function OnboardingWizard() {
|
|||||||
icon: Gem,
|
icon: Gem,
|
||||||
desc: "Local Gemini agent"
|
desc: "Local Gemini agent"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "process" as const,
|
||||||
|
label: "Process",
|
||||||
|
icon: Terminal,
|
||||||
|
desc: "Run a local command"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "opencode_local" as const,
|
value: "opencode_local" as const,
|
||||||
label: "OpenCode",
|
label: "OpenCode",
|
||||||
|
|||||||
80
ui/src/lib/onboarding-route.test.ts
Normal file
80
ui/src/lib/onboarding-route.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isOnboardingPath,
|
||||||
|
resolveRouteOnboardingOptions,
|
||||||
|
shouldRedirectCompanylessRouteToOnboarding,
|
||||||
|
} from "./onboarding-route";
|
||||||
|
|
||||||
|
describe("isOnboardingPath", () => {
|
||||||
|
it("matches the global onboarding route", () => {
|
||||||
|
expect(isOnboardingPath("/onboarding")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a company-prefixed onboarding route", () => {
|
||||||
|
expect(isOnboardingPath("/pap/onboarding")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-onboarding routes", () => {
|
||||||
|
expect(isOnboardingPath("/pap/dashboard")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRouteOnboardingOptions", () => {
|
||||||
|
it("opens company creation for the global onboarding route", () => {
|
||||||
|
expect(
|
||||||
|
resolveRouteOnboardingOptions({
|
||||||
|
pathname: "/onboarding",
|
||||||
|
companies: [],
|
||||||
|
}),
|
||||||
|
).toEqual({ initialStep: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens agent creation when the prefixed company exists", () => {
|
||||||
|
expect(
|
||||||
|
resolveRouteOnboardingOptions({
|
||||||
|
pathname: "/pap/onboarding",
|
||||||
|
companyPrefix: "pap",
|
||||||
|
companies: [{ id: "company-1", issuePrefix: "PAP" }],
|
||||||
|
}),
|
||||||
|
).toEqual({ initialStep: 2, companyId: "company-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to company creation when the prefixed company is missing", () => {
|
||||||
|
expect(
|
||||||
|
resolveRouteOnboardingOptions({
|
||||||
|
pathname: "/pap/onboarding",
|
||||||
|
companyPrefix: "pap",
|
||||||
|
companies: [],
|
||||||
|
}),
|
||||||
|
).toEqual({ initialStep: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldRedirectCompanylessRouteToOnboarding", () => {
|
||||||
|
it("redirects companyless entry routes into onboarding", () => {
|
||||||
|
expect(
|
||||||
|
shouldRedirectCompanylessRouteToOnboarding({
|
||||||
|
pathname: "/",
|
||||||
|
hasCompanies: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not redirect when already on onboarding", () => {
|
||||||
|
expect(
|
||||||
|
shouldRedirectCompanylessRouteToOnboarding({
|
||||||
|
pathname: "/onboarding",
|
||||||
|
hasCompanies: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not redirect when companies exist", () => {
|
||||||
|
expect(
|
||||||
|
shouldRedirectCompanylessRouteToOnboarding({
|
||||||
|
pathname: "/issues",
|
||||||
|
hasCompanies: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
ui/src/lib/onboarding-route.ts
Normal file
51
ui/src/lib/onboarding-route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
type OnboardingRouteCompany = {
|
||||||
|
id: string;
|
||||||
|
issuePrefix: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isOnboardingPath(pathname: string): boolean {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
if (segments.length === 1) {
|
||||||
|
return segments[0]?.toLowerCase() === "onboarding";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length === 2) {
|
||||||
|
return segments[1]?.toLowerCase() === "onboarding";
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRouteOnboardingOptions(params: {
|
||||||
|
pathname: string;
|
||||||
|
companyPrefix?: string;
|
||||||
|
companies: OnboardingRouteCompany[];
|
||||||
|
}): { initialStep: 1 | 2; companyId?: string } | null {
|
||||||
|
const { pathname, companyPrefix, companies } = params;
|
||||||
|
|
||||||
|
if (!isOnboardingPath(pathname)) return null;
|
||||||
|
|
||||||
|
if (!companyPrefix) {
|
||||||
|
return { initialStep: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedCompany =
|
||||||
|
companies.find(
|
||||||
|
(company) =>
|
||||||
|
company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase(),
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
if (!matchedCompany) {
|
||||||
|
return { initialStep: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { initialStep: 2, companyId: matchedCompany.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRedirectCompanylessRouteToOnboarding(params: {
|
||||||
|
pathname: string;
|
||||||
|
hasCompanies: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return !params.hasCompanies && !isOnboardingPath(params.pathname);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user