feat(FN-1941): surface setup readiness warnings in task entry flows
- Add useSetupReadiness hook to evaluate setup state and expose actionable warning metadata - Add reusable SetupWarningBanner component with compact and full warning presentation modes - Render setup warnings in NewTaskModal and QuickEntryBox so task creation surfaces missing configuration early - Add targeted tests for the hook and banner plus integration coverage updates for modal and quick-entry behavior
This commit is contained in:
@@ -4,6 +4,8 @@ import type { ToastType } from "../hooks/useToast";
|
|||||||
import { uploadAttachment, fetchAgents } from "../api";
|
import { uploadAttachment, fetchAgents } from "../api";
|
||||||
import type { Agent } from "../api";
|
import type { Agent } from "../api";
|
||||||
import { Bot } from "lucide-react";
|
import { Bot } from "lucide-react";
|
||||||
|
import { useSetupReadiness } from "../hooks/useSetupReadiness";
|
||||||
|
import { SetupWarningBanner } from "./SetupWarningBanner";
|
||||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||||
|
|
||||||
interface NewTaskModalProps {
|
interface NewTaskModalProps {
|
||||||
@@ -38,6 +40,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
|||||||
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
||||||
const [agentsLoading, setAgentsLoading] = useState(false);
|
const [agentsLoading, setAgentsLoading] = useState(false);
|
||||||
const agentPickerRef = useRef<HTMLDivElement>(null);
|
const agentPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
|
||||||
|
|
||||||
// Handler for workflow step changes that detects explicit user interaction
|
// Handler for workflow step changes that detects explicit user interaction
|
||||||
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
|
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
|
||||||
@@ -221,6 +224,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
|
{!setupReadinessLoading && (
|
||||||
|
<SetupWarningBanner
|
||||||
|
hasAiProvider={hasAiProvider}
|
||||||
|
hasGithub={hasGithub}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<TaskForm
|
<TaskForm
|
||||||
mode="create"
|
mode="create"
|
||||||
description={description}
|
description={description}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { ModelInfo, RefinementType, Agent } from "../api";
|
|||||||
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
|
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
|
||||||
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
|
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
|
import { SetupWarningBanner } from "./SetupWarningBanner";
|
||||||
|
import { useSetupReadiness } from "../hooks/useSetupReadiness";
|
||||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
|
|
||||||
const STORAGE_KEY = "kb-quick-entry-text";
|
const STORAGE_KEY = "kb-quick-entry-text";
|
||||||
@@ -150,6 +152,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
|
|
||||||
// If onCreate is not provided, the component is disabled
|
// If onCreate is not provided, the component is disabled
|
||||||
const isDisabled = !onCreate;
|
const isDisabled = !onCreate;
|
||||||
|
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
|
||||||
|
|
||||||
// Fetch models and settings if not provided by parent
|
// Fetch models and settings if not provided by parent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1134,6 +1137,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
hidden={!showExpandedControls}
|
hidden={!showExpandedControls}
|
||||||
aria-hidden={!showExpandedControls}
|
aria-hidden={!showExpandedControls}
|
||||||
>
|
>
|
||||||
|
{showExpandedControls && !setupReadinessLoading && (
|
||||||
|
<SetupWarningBanner
|
||||||
|
hasAiProvider={hasAiProvider}
|
||||||
|
hasGithub={hasGithub}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* All quick-create actions behind single disclosure toggle */}
|
{/* All quick-create actions behind single disclosure toggle */}
|
||||||
{showExpandedControls && !isSubmitting && (
|
{showExpandedControls && !isSubmitting && (
|
||||||
<div className="quick-entry-actions" data-testid="quick-entry-actions">
|
<div className="quick-entry-actions" data-testid="quick-entry-actions">
|
||||||
|
|||||||
69
packages/dashboard/app/components/SetupWarningBanner.tsx
Normal file
69
packages/dashboard/app/components/SetupWarningBanner.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
interface SetupWarningBannerProps {
|
||||||
|
/** Whether an AI provider is connected */
|
||||||
|
hasAiProvider: boolean;
|
||||||
|
/** Whether GitHub is connected */
|
||||||
|
hasGithub: boolean;
|
||||||
|
/** Optional: compact mode for inline use (QuickEntryBox) */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarningItem {
|
||||||
|
key: "ai" | "github";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupWarningBanner({
|
||||||
|
hasAiProvider,
|
||||||
|
hasGithub,
|
||||||
|
compact = false,
|
||||||
|
}: SetupWarningBannerProps) {
|
||||||
|
if (hasAiProvider && hasGithub) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="setup-warning-banner setup-warning-banner--compact"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<p className="setup-warning-banner__compact-text">
|
||||||
|
⚠ Setup incomplete — AI and/or GitHub features will be limited.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const warningItems: WarningItem[] = [];
|
||||||
|
|
||||||
|
if (!hasAiProvider) {
|
||||||
|
warningItems.push({
|
||||||
|
key: "ai",
|
||||||
|
title: "No AI provider connected",
|
||||||
|
description:
|
||||||
|
"AI agents won't be able to work on tasks until you connect a provider. Set one up in Settings → AI Setup.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasGithub) {
|
||||||
|
warningItems.push({
|
||||||
|
key: "github",
|
||||||
|
title: "GitHub not connected",
|
||||||
|
description:
|
||||||
|
"You won't be able to import issues from GitHub, but you can still create tasks manually.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="setup-warning-banner" role="status" aria-live="polite">
|
||||||
|
{warningItems.map((warning) => (
|
||||||
|
<div key={warning.key} className="setup-warning-banner__item">
|
||||||
|
<strong className="setup-warning-banner__title">{warning.title}</strong>
|
||||||
|
<p className="setup-warning-banner__description">{warning.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ vi.mock("../../api", () => ({
|
|||||||
}),
|
}),
|
||||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchAuthStatus: vi.fn().mockResolvedValue({ providers: [] }),
|
||||||
refineText: vi.fn(),
|
refineText: vi.fn(),
|
||||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||||
@@ -139,6 +140,32 @@ describe("NewTaskModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("still submits when setup warnings are shown", async () => {
|
||||||
|
const { fetchAuthStatus } = await import("../../api");
|
||||||
|
vi.mocked(fetchAuthStatus).mockResolvedValueOnce({
|
||||||
|
providers: [{ id: "github", name: "GitHub", authenticated: false, type: "oauth" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { props } = renderNewTaskModal();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("No AI provider connected")).toBeTruthy();
|
||||||
|
expect(screen.getByText("GitHub not connected")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const descTextarea = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(descTextarea, { target: { value: "Submit despite warning" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: "Submit despite warning",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("closes modal after successful creation", async () => {
|
it("closes modal after successful creation", async () => {
|
||||||
const { props } = renderNewTaskModal();
|
const { props } = renderNewTaskModal();
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ vi.mock("../../api", () => ({
|
|||||||
groupOverlappingFiles: true,
|
groupOverlappingFiles: true,
|
||||||
autoMerge: true,
|
autoMerge: true,
|
||||||
}),
|
}),
|
||||||
|
fetchAuthStatus: vi.fn().mockResolvedValue({ providers: [] }),
|
||||||
refineText: vi.fn(),
|
refineText: vi.fn(),
|
||||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { SetupWarningBanner } from "../SetupWarningBanner";
|
||||||
|
|
||||||
|
describe("SetupWarningBanner", () => {
|
||||||
|
it("returns null when both hasAiProvider and hasGithub are true", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SetupWarningBanner hasAiProvider hasGithub />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows AI provider warning when hasAiProvider is false and hasGithub is true", () => {
|
||||||
|
render(<SetupWarningBanner hasAiProvider={false} hasGithub />);
|
||||||
|
|
||||||
|
expect(screen.getByText("No AI provider connected")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"AI agents won't be able to work on tasks until you connect a provider. Set one up in Settings → AI Setup.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("GitHub not connected")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows GitHub warning when hasGithub is false and hasAiProvider is true", () => {
|
||||||
|
render(<SetupWarningBanner hasAiProvider hasGithub={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("GitHub not connected")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"You won't be able to import issues from GitHub, but you can still create tasks manually.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No AI provider connected")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows both warnings when both providers are missing", () => {
|
||||||
|
render(<SetupWarningBanner hasAiProvider={false} hasGithub={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("No AI provider connected")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("GitHub not connected")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compact mode renders a single-line summary", () => {
|
||||||
|
render(
|
||||||
|
<SetupWarningBanner hasAiProvider={false} hasGithub compact />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("⚠ Setup incomplete — AI and/or GitHub features will be limited."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No AI provider connected")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("full mode renders setup-warning-banner class with expected structure", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SetupWarningBanner hasAiProvider={false} hasGithub={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const banner = container.querySelector(".setup-warning-banner");
|
||||||
|
const items = container.querySelectorAll(".setup-warning-banner__item");
|
||||||
|
|
||||||
|
expect(banner).toBeTruthy();
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has role=status and aria-live=polite for accessibility", () => {
|
||||||
|
render(<SetupWarningBanner hasAiProvider={false} hasGithub />);
|
||||||
|
|
||||||
|
const banner = screen.getByRole("status");
|
||||||
|
expect(banner).toHaveAttribute("aria-live", "polite");
|
||||||
|
});
|
||||||
|
});
|
||||||
164
packages/dashboard/app/hooks/__tests__/useSetupReadiness.test.ts
Normal file
164
packages/dashboard/app/hooks/__tests__/useSetupReadiness.test.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||||
|
import { useSetupReadiness, __test_clearCache } from "../useSetupReadiness";
|
||||||
|
import * as api from "../../api";
|
||||||
|
import type { AuthProvider } from "../../api";
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchAuthStatus: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
|
||||||
|
|
||||||
|
function makeProvider(
|
||||||
|
id: string,
|
||||||
|
authenticated: boolean,
|
||||||
|
name = id,
|
||||||
|
type: AuthProvider["type"] = "oauth",
|
||||||
|
): AuthProvider {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
authenticated,
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSetupReadiness", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
__test_clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasAiProvider=true when at least one non-GitHub provider is authenticated", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [makeProvider("anthropic", true), makeProvider("github", false)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSetupReadiness());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.hasAiProvider).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasAiProvider=false when no non-GitHub providers are authenticated", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [makeProvider("anthropic", false), makeProvider("github", true)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSetupReadiness());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.hasAiProvider).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasGithub=true when GitHub provider is authenticated", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSetupReadiness());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.hasGithub).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasGithub=false when GitHub is missing or not authenticated", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [makeProvider("anthropic", true)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: missingGithub } = renderHook(() => useSetupReadiness("project-a"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(missingGithub.current.loading).toBe(false));
|
||||||
|
expect(missingGithub.current.hasGithub).toBe(false);
|
||||||
|
|
||||||
|
__test_clearCache();
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [makeProvider("github", false)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: unauthenticatedGithub } = renderHook(() => useSetupReadiness("project-b"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(unauthenticatedGithub.current.loading).toBe(false));
|
||||||
|
expect(unauthenticatedGithub.current.hasGithub).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasWarnings=true when AI provider is missing", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [makeProvider("github", true)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSetupReadiness());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.hasWarnings).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasWarnings=true when GitHub is missing", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [makeProvider("anthropic", true), makeProvider("github", false)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSetupReadiness());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.hasWarnings).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasWarnings=false when both AI provider and GitHub are connected", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSetupReadiness());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.hasWarnings).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns loading=true during initial fetch and false after completion", async () => {
|
||||||
|
const pending = deferred<{ providers: AuthProvider[] }>();
|
||||||
|
mockFetchAuthStatus.mockReturnValueOnce(pending.promise);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSetupReadiness());
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pending.resolve({
|
||||||
|
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cache prevents duplicate fetches across multiple consumers", async () => {
|
||||||
|
mockFetchAuthStatus.mockResolvedValue({
|
||||||
|
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: first } = renderHook(() => useSetupReadiness());
|
||||||
|
await waitFor(() => expect(first.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const { result: second } = renderHook(() => useSetupReadiness());
|
||||||
|
await waitFor(() => expect(second.current.loading).toBe(false));
|
||||||
|
|
||||||
|
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
153
packages/dashboard/app/hooks/useSetupReadiness.ts
Normal file
153
packages/dashboard/app/hooks/useSetupReadiness.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { fetchAuthStatus } from "../api";
|
||||||
|
import type { AuthProvider } from "../api";
|
||||||
|
|
||||||
|
export interface SetupReadiness {
|
||||||
|
/** True if at least one AI provider is authenticated */
|
||||||
|
hasAiProvider: boolean;
|
||||||
|
/** True if GitHub is connected */
|
||||||
|
hasGithub: boolean;
|
||||||
|
/** True if still loading auth status */
|
||||||
|
loading: boolean;
|
||||||
|
/** Whether any warnings should be shown (at least one setup item incomplete) */
|
||||||
|
hasWarnings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupReadinessSnapshot {
|
||||||
|
hasAiProvider: boolean;
|
||||||
|
hasGithub: boolean;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
const setupReadinessCache = new Map<string, SetupReadinessSnapshot>();
|
||||||
|
const setupReadinessInFlight = new Map<string, Promise<SetupReadinessSnapshot>>();
|
||||||
|
|
||||||
|
function getCacheKey(projectId?: string): string {
|
||||||
|
return projectId ?? "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreshSnapshot(cacheKey: string): SetupReadinessSnapshot | null {
|
||||||
|
const cached = setupReadinessCache.get(cacheKey);
|
||||||
|
if (!cached) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() >= cached.expiresAt) {
|
||||||
|
setupReadinessCache.delete(cacheKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateProviders(providers: AuthProvider[]): Pick<SetupReadinessSnapshot, "hasAiProvider" | "hasGithub"> {
|
||||||
|
const hasAiProvider = providers.some((provider) => provider.id !== "github" && provider.authenticated);
|
||||||
|
const hasGithub = providers.some((provider) => provider.id === "github" && provider.authenticated);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasAiProvider,
|
||||||
|
hasGithub,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndCacheSetupReadiness(cacheKey: string): Promise<SetupReadinessSnapshot> {
|
||||||
|
const existingRequest = setupReadinessInFlight.get(cacheKey);
|
||||||
|
if (existingRequest) {
|
||||||
|
return existingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = fetchAuthStatus()
|
||||||
|
.then(({ providers }) => {
|
||||||
|
const computed = evaluateProviders(providers);
|
||||||
|
const snapshot: SetupReadinessSnapshot = {
|
||||||
|
...computed,
|
||||||
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||||
|
};
|
||||||
|
setupReadinessCache.set(cacheKey, snapshot);
|
||||||
|
return snapshot;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setupReadinessInFlight.delete(cacheKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
setupReadinessInFlight.set(cacheKey, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears setup-readiness cache and in-flight requests.
|
||||||
|
* Exported for tests.
|
||||||
|
*/
|
||||||
|
export function __test_clearCache(): void {
|
||||||
|
setupReadinessCache.clear();
|
||||||
|
setupReadinessInFlight.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetupReadiness(projectId?: string): SetupReadiness {
|
||||||
|
const cacheKey = getCacheKey(projectId);
|
||||||
|
const initialSnapshot = getFreshSnapshot(cacheKey);
|
||||||
|
|
||||||
|
const [hasAiProvider, setHasAiProvider] = useState(initialSnapshot?.hasAiProvider ?? false);
|
||||||
|
const [hasGithub, setHasGithub] = useState(initialSnapshot?.hasGithub ?? false);
|
||||||
|
const [loading, setLoading] = useState(initialSnapshot == null);
|
||||||
|
|
||||||
|
const initialLoadCompleteRef = useRef(Boolean(initialSnapshot));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const nextCacheKey = getCacheKey(projectId);
|
||||||
|
const cached = getFreshSnapshot(nextCacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
setHasAiProvider(cached.hasAiProvider);
|
||||||
|
setHasGithub(cached.hasGithub);
|
||||||
|
setLoading(false);
|
||||||
|
initialLoadCompleteRef.current = true;
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
initialLoadCompleteRef.current = false;
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
const isInitialLoad = !initialLoadCompleteRef.current;
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchAndCacheSetupReadiness(nextCacheKey);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHasAiProvider(snapshot.hasAiProvider);
|
||||||
|
setHasGithub(snapshot.hasGithub);
|
||||||
|
} catch {
|
||||||
|
// Best effort only: keep warnings visible when status cannot be fetched.
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
initialLoadCompleteRef.current = true;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
hasAiProvider,
|
||||||
|
hasGithub,
|
||||||
|
loading,
|
||||||
|
hasWarnings: !hasAiProvider || !hasGithub,
|
||||||
|
}),
|
||||||
|
[hasAiProvider, hasGithub, loading],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23826,6 +23826,48 @@ html .column.drag-over * {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === SetupWarningBanner === */
|
||||||
|
.setup-warning-banner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-inline-start: var(--space-xs) solid var(--color-warning);
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-warning-banner__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-warning-banner__title {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-warning-banner__description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-warning-banner--compact {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-warning-banner__compact-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.setup-warning-banner--compact {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* === OnboardingDisclosure === */
|
/* === OnboardingDisclosure === */
|
||||||
.onboarding-disclosure {
|
.onboarding-disclosure {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user