Makes upsell dialog visibility available outside the component to fix an issue where <RooTips /> would be incorrectly hidden.

This commit is contained in:
Bruno Bergher 2025-09-15 13:00:16 +01:00
parent d54ff8a604
commit 6af93241b6
9 changed files with 1072 additions and 211 deletions

View file

@ -40,7 +40,6 @@ import RooTips from "@src/components/welcome/RooTips"
import { StandardTooltip } from "@src/components/ui"
import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
import TelemetryBanner from "../common/TelemetryBanner"
import VersionIndicator from "../common/VersionIndicator"
@ -55,9 +54,12 @@ import SystemPromptWarning from "./SystemPromptWarning"
import ProfileViolationWarning from "./ProfileViolationWarning"
import { CheckpointWarning } from "./CheckpointWarning"
import { QueuedMessages } from "./QueuedMessages"
import { Cloud } from "lucide-react"
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
import DismissibleUpsell from "../common/DismissibleUpsell"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { Cloud } from "lucide-react"
import { useUpsellVisibility, UPSELL_IDS } from "@/hooks/useUpsellVisibility"
export interface ChatViewProps {
isHidden: boolean
@ -1772,6 +1774,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText || isStreaming
const isCloudUpsellVisible = useUpsellVisibility(UPSELL_IDS.TASK_LIST)
return (
<div
data-testid="chat-view"
@ -1842,12 +1846,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
<RooHero />
<div className="mb-2.5">
{cloudIsAuthenticated || taskHistory.length < 4 ? (
{cloudIsAuthenticated || (tasks.length === 0 && !isCloudUpsellVisible) ? (
<RooTips />
) : (
<>
<DismissibleUpsell
upsellId="taskList"
upsellId={UPSELL_IDS.TASK_LIST}
icon={<Cloud className="size-4 mt-0.5 shrink-0" />}
onClick={() => openUpsell()}
dismissOnClick={false}

View file

@ -2,7 +2,6 @@ import { memo, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { CloudUpsellDialog } from "@src/components/cloud/CloudUpsellDialog"
import DismissibleUpsell from "@src/components/common/DismissibleUpsell"
import { FoldVertical, ChevronUp, ChevronDown } from "lucide-react"
import prettyBytes from "pretty-bytes"
@ -24,6 +23,9 @@ import { ContextWindowProgress } from "./ContextWindowProgress"
import { Mention } from "./Mention"
import { TodoListDisplay } from "./TodoListDisplay"
import DismissibleUpsell from "@src/components/common/DismissibleUpsell"
import { UPSELL_IDS } from "@/constants/upsellIds"
export interface TaskHeaderProps {
task: ClineMessage
tokensIn: number
@ -103,7 +105,7 @@ const TaskHeader = ({
<div className="pt-2 pb-0 px-3">
{showLongRunningTaskMessage && !isTaskComplete && (
<DismissibleUpsell
upsellId="longRunningTask"
upsellId={UPSELL_IDS.LONG_RUNNING_TASK}
onClick={() => openUpsell()}
dismissOnClick={false}
variant="banner">

View file

@ -1,6 +1,6 @@
import { memo, ReactNode, useEffect, useState, useRef } from "react"
import { vscode } from "@src/utils/vscode"
import { memo, ReactNode } from "react"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { DismissedUpsellsProvider, useDismissedUpsells } from "@src/context/DismissedUpsellsContext"
interface DismissibleUpsellProps {
/** Required unique identifier for this upsell */
@ -32,7 +32,8 @@ const DismissIcon = () => (
</svg>
)
const DismissibleUpsell = memo(
// Internal component that uses the context
const DismissibleUpsellInternal = memo(
({
upsellId,
className,
@ -44,55 +45,21 @@ const DismissibleUpsell = memo(
dismissOnClick = false,
}: DismissibleUpsellProps) => {
const { t } = useAppTranslation()
const [isVisible, setIsVisible] = useState(false)
const isMountedRef = useRef(true)
const { isUpsellVisible, dismissUpsell, isLoading } = useDismissedUpsells()
useEffect(() => {
// Track mounted state
isMountedRef.current = true
// Check if this upsell is visible
const isVisible = isUpsellVisible(upsellId)
// Request the current list of dismissed upsells from the extension
vscode.postMessage({ type: "getDismissedUpsells" })
// Listen for the response
const handleMessage = (event: MessageEvent) => {
// Only update state if component is still mounted
if (!isMountedRef.current) return
const message = event.data
// Add null/undefined check for message
if (message && message.type === "dismissedUpsells" && Array.isArray(message.list)) {
// Check if this upsell has been dismissed
if (!message.list.includes(upsellId)) {
setIsVisible(true)
}
}
}
window.addEventListener("message", handleMessage)
return () => {
isMountedRef.current = false
window.removeEventListener("message", handleMessage)
}
}, [upsellId])
const handleDismiss = async () => {
// First notify the extension to persist the dismissal
// This ensures the message is sent even if the component unmounts quickly
vscode.postMessage({
type: "dismissUpsell",
upsellId: upsellId,
})
// Then hide the upsell
setIsVisible(false)
const handleDismiss = () => {
// Dismiss the upsell through the context
dismissUpsell(upsellId)
// Call the optional callback
onDismiss?.()
}
// Don't render if not visible
if (!isVisible) {
// Don't render if not visible or still loading
if (!isVisible || isLoading) {
return null
}
@ -107,6 +74,7 @@ const DismissibleUpsell = memo(
button: "text-vscode-notifications-foreground",
},
}
// Build container classes based on variant and presence of click handler
const containerClasses = [
"relative flex items-start justify-between gap-2",
@ -158,6 +126,17 @@ const DismissibleUpsell = memo(
},
)
DismissibleUpsellInternal.displayName = "DismissibleUpsellInternal"
// Wrapper component that provides the context
const DismissibleUpsell = memo((props: DismissibleUpsellProps) => {
return (
<DismissedUpsellsProvider>
<DismissibleUpsellInternal {...props} />
</DismissedUpsellsProvider>
)
})
DismissibleUpsell.displayName = "DismissibleUpsell"
export default DismissibleUpsell

View file

@ -1,6 +1,7 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import DismissibleUpsell from "../DismissibleUpsell"
import React from "react"
// Mock the vscode API
const mockPostMessage = vi.fn()
@ -24,34 +25,59 @@ vi.mock("@src/i18n/TranslationContext", () => ({
}))
describe("DismissibleUpsell", () => {
let messageHandler: ((event: MessageEvent) => void) | null = null
beforeEach(() => {
mockPostMessage.mockClear()
vi.clearAllTimers()
// Capture the message event handler
window.addEventListener = vi.fn((event, handler) => {
if (event === "message") {
messageHandler = handler as (event: MessageEvent) => void
}
})
window.removeEventListener = vi.fn()
})
afterEach(() => {
vi.clearAllTimers()
messageHandler = null
})
// Helper function to make the component visible
const makeUpsellVisible = () => {
const messageEvent = new MessageEvent("message", {
data: {
type: "dismissedUpsells",
list: [], // Empty list means no upsells are dismissed
},
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: [], // Empty list means no upsells are dismissed
},
} as MessageEvent)
})
window.dispatchEvent(messageEvent)
}
it("renders children content", async () => {
// Helper function to mark upsell as dismissed
const makeUpsellDismissed = (upsellIds: string[]) => {
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: upsellIds,
},
} as MessageEvent)
})
}
it("renders children content when visible", async () => {
render(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// Component starts hidden, make it visible
// Component starts hidden (loading), make it visible
makeUpsellVisible()
// Wait for component to become visible
@ -60,7 +86,7 @@ describe("DismissibleUpsell", () => {
})
})
it("requests dismissed upsells list on mount", () => {
it("requests dismissed upsells list on mount via context", () => {
render(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
@ -92,7 +118,7 @@ describe("DismissibleUpsell", () => {
const dismissButton = screen.getByRole("button", { name: /dismiss/i })
fireEvent.click(dismissButton)
// Check that the dismiss message was sent BEFORE hiding
// Check that the dismiss message was sent
expect(mockPostMessage).toHaveBeenCalledWith({
type: "dismissUpsell",
upsellId: "test-upsell",
@ -114,17 +140,8 @@ describe("DismissibleUpsell", () => {
</DismissibleUpsell>,
)
// Component starts hidden by default
expect(container.firstChild).toBeNull()
// Simulate receiving a message that this upsell is dismissed
const messageEvent = new MessageEvent("message", {
data: {
type: "dismissedUpsells",
list: ["test-upsell", "other-upsell"],
},
})
window.dispatchEvent(messageEvent)
makeUpsellDismissed(["test-upsell", "other-upsell"])
// Check that the component remains hidden
await waitFor(() => {
@ -140,15 +157,9 @@ describe("DismissibleUpsell", () => {
)
// Simulate receiving a message that doesn't include this upsell
const messageEvent = new MessageEvent("message", {
data: {
type: "dismissedUpsells",
list: ["other-upsell"],
},
})
window.dispatchEvent(messageEvent)
makeUpsellDismissed(["other-upsell"])
// Check that the component is still visible
// Check that the component is visible
await waitFor(() => {
expect(screen.getByText("Test content")).toBeInTheDocument()
})
@ -192,7 +203,6 @@ describe("DismissibleUpsell", () => {
expect(dismissButton).toHaveAttribute("title", "Dismiss and don't show again")
})
// New edge case tests
it("handles multiple rapid dismissals of the same component", async () => {
const onDismiss = vi.fn()
render(
@ -216,141 +226,16 @@ describe("DismissibleUpsell", () => {
fireEvent.click(dismissButton)
fireEvent.click(dismissButton)
// Should only send one message
expect(mockPostMessage).toHaveBeenCalledTimes(2) // 1 for getDismissedUpsells, 1 for dismissUpsell
expect(mockPostMessage).toHaveBeenCalledWith({
type: "dismissUpsell",
upsellId: "test-upsell",
})
// Should only send one dismiss message (plus initial getDismissedUpsells)
const dismissCalls = mockPostMessage.mock.calls.filter(
(call) => call[0].type === "dismissUpsell" && call[0].upsellId === "test-upsell",
)
expect(dismissCalls.length).toBe(1)
// Callback should only be called once
expect(onDismiss).toHaveBeenCalledTimes(1)
})
it("does not update state after component unmounts", async () => {
const { unmount } = render(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// Unmount the component
unmount()
// Simulate receiving a message after unmount
const messageEvent = new MessageEvent("message", {
data: {
type: "dismissedUpsells",
list: ["test-upsell"],
},
})
// This should not cause any errors
act(() => {
window.dispatchEvent(messageEvent)
})
// No errors should be thrown
expect(true).toBe(true)
})
it("handles invalid/malformed messages gracefully", async () => {
render(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// First make it visible
makeUpsellVisible()
// Wait for component to be visible
await waitFor(() => {
expect(screen.getByText("Test content")).toBeInTheDocument()
})
// Send various malformed messages
const malformedMessages = [
{ type: "dismissedUpsells", list: null },
{ type: "dismissedUpsells", list: "not-an-array" },
{ type: "dismissedUpsells" }, // missing list
{ type: "wrongType", list: ["test-upsell"] },
null,
undefined,
"string-message",
]
malformedMessages.forEach((data) => {
const messageEvent = new MessageEvent("message", { data })
window.dispatchEvent(messageEvent)
})
// Component should still be visible
expect(screen.getByText("Test content")).toBeInTheDocument()
})
it("ensures message is sent before component unmounts on dismiss", async () => {
const { unmount } = render(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// Make component visible
makeUpsellVisible()
// Wait for component to be visible
await waitFor(() => {
expect(screen.getByText("Test content")).toBeInTheDocument()
})
const dismissButton = screen.getByRole("button", { name: /dismiss/i })
fireEvent.click(dismissButton)
// Message should be sent immediately
expect(mockPostMessage).toHaveBeenCalledWith({
type: "dismissUpsell",
upsellId: "test-upsell",
})
// Unmount immediately after clicking
unmount()
// Message was already sent before unmount
expect(mockPostMessage).toHaveBeenCalledWith({
type: "dismissUpsell",
upsellId: "test-upsell",
})
})
it("uses separate id and className props correctly", async () => {
const { container } = render(
<DismissibleUpsell upsellId="unique-id" className="styling-class">
<div>Test content</div>
</DismissibleUpsell>,
)
// Make component visible
makeUpsellVisible()
// Wait for component to be visible
await waitFor(() => {
expect(container.firstChild).not.toBeNull()
})
// className should be applied to the container
expect(container.firstChild).toHaveClass("styling-class")
// When dismissed, should use the id, not className
const dismissButton = screen.getByRole("button", { name: /dismiss/i })
fireEvent.click(dismissButton)
expect(mockPostMessage).toHaveBeenCalledWith({
type: "dismissUpsell",
upsellId: "unique-id",
})
})
it("calls onClick when the container is clicked", async () => {
const onClick = vi.fn()
render(
@ -425,6 +310,14 @@ describe("DismissibleUpsell", () => {
</DismissibleUpsell>,
)
// Make sure it's still visible after re-render
makeUpsellVisible()
// Wait for component to be visible again
await waitFor(() => {
expect(container.firstChild).not.toBeNull()
})
// Should not have cursor-pointer when onClick is not provided
expect(container.firstChild).not.toHaveClass("cursor-pointer")
})
@ -526,7 +419,10 @@ describe("DismissibleUpsell", () => {
expect(onClick).toHaveBeenCalledTimes(1)
expect(onDismiss).not.toHaveBeenCalled()
expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "dismissUpsell" }))
// Should not dismiss the upsell
const dismissCalls = mockPostMessage.mock.calls.filter((call) => call[0].type === "dismissUpsell")
expect(dismissCalls.length).toBe(0)
expect(screen.getByText("Test content")).toBeInTheDocument()
})
@ -554,4 +450,77 @@ describe("DismissibleUpsell", () => {
expect(onDismiss).not.toHaveBeenCalled()
expect(screen.getByText("Test content")).toBeInTheDocument()
})
it("renders icon when provided", async () => {
const TestIcon = () => <span data-testid="test-icon">Icon</span>
render(
<DismissibleUpsell upsellId="test-upsell" icon={<TestIcon />}>
<div>Test content</div>
</DismissibleUpsell>,
)
// Make component visible
makeUpsellVisible()
// Wait for component to be visible
await waitFor(() => {
expect(screen.getByText("Test content")).toBeInTheDocument()
expect(screen.getByTestId("test-icon")).toBeInTheDocument()
})
})
it("applies correct variant classes", async () => {
const { container, rerender } = render(
<DismissibleUpsell upsellId="test-upsell" variant="banner">
<div>Test content</div>
</DismissibleUpsell>,
)
// Make component visible
makeUpsellVisible()
// Wait for component to be visible
await waitFor(() => {
expect(container.firstChild).not.toBeNull()
})
// Should have banner variant classes
expect(container.firstChild).toHaveClass("bg-vscode-badge-background/80")
expect(container.firstChild).toHaveClass("text-vscode-badge-foreground")
// Re-render with default variant
rerender(
<DismissibleUpsell upsellId="test-upsell-2" variant="default">
<div>Test content</div>
</DismissibleUpsell>,
)
// Make the new upsell visible
makeUpsellVisible()
// Should have default variant classes
await waitFor(() => {
expect(container.firstChild).toHaveClass("bg-vscode-notifications-background")
expect(container.firstChild).toHaveClass("text-vscode-notifications-foreground")
})
})
it("does not render while context is loading", async () => {
const { container } = render(
<DismissibleUpsell upsellId="test-upsell">
<div>Test content</div>
</DismissibleUpsell>,
)
// Component should not render while loading
expect(container.firstChild).toBeNull()
// Send dismissed list to complete loading
makeUpsellVisible()
// Now component should be visible
await waitFor(() => {
expect(screen.getByText("Test content")).toBeInTheDocument()
})
})
})

View file

@ -0,0 +1,17 @@
/**
* Constants for all upsell IDs used in the application.
* Using constants ensures type safety and prevents typos.
*/
export const UPSELL_IDS = {
TASK_LIST: "taskList", // Cloud upsell in the home page task list
LONG_RUNNING_TASK: "longRunningTask", // Cloud upsell when a task takes a while
} as const
// Type for all valid upsell IDs
export type UpsellId = (typeof UPSELL_IDS)[keyof typeof UPSELL_IDS]
// Helper to validate if a string is a valid upsell ID
export function isValidUpsellId(id: string): id is UpsellId {
return Object.values(UPSELL_IDS).includes(id as UpsellId)
}

View file

@ -0,0 +1,111 @@
import React, { createContext, useContext, useEffect, useState, useCallback, ReactNode } from "react"
import { vscode } from "@src/utils/vscode"
import { UpsellId } from "@src/constants/upsellIds"
interface DismissedUpsellsContextType {
/** List of dismissed upsell IDs */
dismissedUpsells: string[]
/** Check if a specific upsell is visible (not dismissed) */
isUpsellVisible: (upsellId: UpsellId | string) => boolean
/** Dismiss an upsell */
dismissUpsell: (upsellId: UpsellId | string) => void
/** Check if the context is loading initial data */
isLoading: boolean
/** Force refresh dismissed upsells from extension */
refreshDismissedUpsells: () => void
}
const DismissedUpsellsContext = createContext<DismissedUpsellsContextType | undefined>(undefined)
interface DismissedUpsellsProviderProps {
children: ReactNode
}
export const DismissedUpsellsProvider: React.FC<DismissedUpsellsProviderProps> = ({ children }) => {
const [dismissedUpsells, setDismissedUpsells] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(true)
// Request dismissed upsells from extension
const refreshDismissedUpsells = useCallback(() => {
vscode.postMessage({ type: "getDismissedUpsells" })
}, [])
// Check if an upsell is visible
const isUpsellVisible = useCallback(
(upsellId: UpsellId | string): boolean => {
return !dismissedUpsells.includes(upsellId)
},
[dismissedUpsells],
)
// Dismiss an upsell
const dismissUpsell = useCallback(
(upsellId: UpsellId | string) => {
if (!dismissedUpsells.includes(upsellId)) {
// Optimistically update local state
setDismissedUpsells((prev) => [...prev, upsellId])
// Send dismiss message to extension
vscode.postMessage({
type: "dismissUpsell",
upsellId: upsellId,
})
}
},
[dismissedUpsells],
)
// Listen for messages from extension
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message && message.type === "dismissedUpsells" && Array.isArray(message.list)) {
setDismissedUpsells(message.list)
setIsLoading(false)
}
}
window.addEventListener("message", handleMessage)
// Request initial dismissed upsells list
refreshDismissedUpsells()
return () => {
window.removeEventListener("message", handleMessage)
}
}, [refreshDismissedUpsells])
const value: DismissedUpsellsContextType = {
dismissedUpsells,
isUpsellVisible,
dismissUpsell,
isLoading,
refreshDismissedUpsells,
}
return <DismissedUpsellsContext.Provider value={value}>{children}</DismissedUpsellsContext.Provider>
}
/**
* Hook to use the dismissed upsells context
* @throws Error if used outside of DismissedUpsellsProvider
*/
export const useDismissedUpsells = (): DismissedUpsellsContextType => {
const context = useContext(DismissedUpsellsContext)
if (!context) {
throw new Error("useDismissedUpsells must be used within a DismissedUpsellsProvider")
}
return context
}
/**
* Helper hook to check if a specific upsell is visible
* This is a convenience wrapper around useDismissedUpsells
*/
export const useIsUpsellVisible = (upsellId: UpsellId | string): boolean => {
const { isUpsellVisible } = useDismissedUpsells()
return isUpsellVisible(upsellId)
}

View file

@ -0,0 +1,385 @@
import { render, screen, act, waitFor, renderHook } from "@testing-library/react"
import { DismissedUpsellsProvider, useDismissedUpsells, useIsUpsellVisible } from "../DismissedUpsellsContext"
import { vscode } from "@src/utils/vscode"
import { UPSELL_IDS } from "@src/constants/upsellIds"
import React from "react"
// Mock vscode
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
describe("DismissedUpsellsContext", () => {
let messageHandler: ((event: MessageEvent) => void) | null = null
beforeEach(() => {
vi.clearAllMocks()
// Capture the message event handler
window.addEventListener = vi.fn((event, handler) => {
if (event === "message") {
messageHandler = handler as (event: MessageEvent) => void
}
})
window.removeEventListener = vi.fn()
})
afterEach(() => {
messageHandler = null
})
describe("DismissedUpsellsProvider", () => {
it("should request dismissed upsells on mount", () => {
render(
<DismissedUpsellsProvider>
<div>Test</div>
</DismissedUpsellsProvider>,
)
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "getDismissedUpsells" })
})
it("should handle dismissed upsells message from extension", async () => {
const TestComponent = () => {
const { dismissedUpsells, isLoading } = useDismissedUpsells()
return (
<div>
<div data-testid="loading">{isLoading.toString()}</div>
<div data-testid="count">{dismissedUpsells.length}</div>
{dismissedUpsells.map((id) => (
<div key={id} data-testid={`dismissed-${id}`}>
{id}
</div>
))}
</div>
)
}
render(
<DismissedUpsellsProvider>
<TestComponent />
</DismissedUpsellsProvider>,
)
// Initially loading
expect(screen.getByTestId("loading")).toHaveTextContent("true")
expect(screen.getByTestId("count")).toHaveTextContent("0")
// Simulate message from extension
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: ["upsell-1", "upsell-2"],
},
} as MessageEvent)
})
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("false")
expect(screen.getByTestId("count")).toHaveTextContent("2")
expect(screen.getByTestId("dismissed-upsell-1")).toHaveTextContent("upsell-1")
expect(screen.getByTestId("dismissed-upsell-2")).toHaveTextContent("upsell-2")
})
})
it("should handle empty dismissed upsells list", async () => {
const TestComponent = () => {
const { isLoading } = useDismissedUpsells()
return <div data-testid="loading">{isLoading.toString()}</div>
}
render(
<DismissedUpsellsProvider>
<TestComponent />
</DismissedUpsellsProvider>,
)
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: [],
},
} as MessageEvent)
})
await waitFor(() => {
expect(screen.getByTestId("loading")).toHaveTextContent("false")
})
})
it("should ignore invalid messages", () => {
const TestComponent = () => {
const { isLoading } = useDismissedUpsells()
return <div data-testid="loading">{isLoading.toString()}</div>
}
render(
<DismissedUpsellsProvider>
<TestComponent />
</DismissedUpsellsProvider>,
)
// Invalid message type
act(() => {
messageHandler?.({
data: {
type: "otherType",
list: ["upsell-1"],
},
} as MessageEvent)
})
// Still loading
expect(screen.getByTestId("loading")).toHaveTextContent("true")
// Invalid list format
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: "not-an-array",
},
} as MessageEvent)
})
// Still loading
expect(screen.getByTestId("loading")).toHaveTextContent("true")
})
})
describe("useDismissedUpsells", () => {
it("should throw error when used outside provider", () => {
// Suppress console.error for this test
const spy = vi.spyOn(console, "error").mockImplementation(() => {})
const { result } = renderHook(() => {
try {
return useDismissedUpsells()
} catch (error: any) {
return { error }
}
})
expect((result.current as any).error).toBeDefined()
expect((result.current as any).error.message).toBe(
"useDismissedUpsells must be used within a DismissedUpsellsProvider",
)
spy.mockRestore()
})
it("should check if upsell is visible", async () => {
const TestComponent = () => {
const { isUpsellVisible } = useDismissedUpsells()
return (
<div>
<div data-testid="visible-1">{isUpsellVisible("upsell-1").toString()}</div>
<div data-testid="visible-2">{isUpsellVisible("upsell-2").toString()}</div>
<div data-testid="visible-3">{isUpsellVisible("upsell-3").toString()}</div>
</div>
)
}
render(
<DismissedUpsellsProvider>
<TestComponent />
</DismissedUpsellsProvider>,
)
// Send dismissed list
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: ["upsell-1", "upsell-3"],
},
} as MessageEvent)
})
await waitFor(() => {
expect(screen.getByTestId("visible-1")).toHaveTextContent("false")
expect(screen.getByTestId("visible-2")).toHaveTextContent("true")
expect(screen.getByTestId("visible-3")).toHaveTextContent("false")
})
})
it("should dismiss an upsell", async () => {
const TestComponent = () => {
const { dismissUpsell, isUpsellVisible } = useDismissedUpsells()
return (
<div>
<div data-testid="visible">{isUpsellVisible("test-upsell").toString()}</div>
<button onClick={() => dismissUpsell("test-upsell")}>Dismiss</button>
</div>
)
}
const { getByText } = render(
<DismissedUpsellsProvider>
<TestComponent />
</DismissedUpsellsProvider>,
)
// Initially visible
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: [],
},
} as MessageEvent)
})
await waitFor(() => {
expect(screen.getByTestId("visible")).toHaveTextContent("true")
})
// Dismiss the upsell
act(() => {
getByText("Dismiss").click()
})
// Should send message to extension
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "dismissUpsell",
upsellId: "test-upsell",
})
// Should update local state optimistically
await waitFor(() => {
expect(screen.getByTestId("visible")).toHaveTextContent("false")
})
})
it("should not dismiss already dismissed upsell", async () => {
const TestComponent = () => {
const { dismissUpsell } = useDismissedUpsells()
return <button onClick={() => dismissUpsell("already-dismissed")}>Dismiss</button>
}
const { getByText } = render(
<DismissedUpsellsProvider>
<TestComponent />
</DismissedUpsellsProvider>,
)
// Set already dismissed
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: ["already-dismissed"],
},
} as MessageEvent)
})
// Clear previous calls
vi.clearAllMocks()
// Try to dismiss again
act(() => {
getByText("Dismiss").click()
})
// Should not send message
expect(vscode.postMessage).not.toHaveBeenCalled()
})
it("should refresh dismissed upsells", () => {
const TestComponent = () => {
const { refreshDismissedUpsells } = useDismissedUpsells()
return <button onClick={refreshDismissedUpsells}>Refresh</button>
}
const { getByText } = render(
<DismissedUpsellsProvider>
<TestComponent />
</DismissedUpsellsProvider>,
)
// Clear initial call
vi.clearAllMocks()
act(() => {
getByText("Refresh").click()
})
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "getDismissedUpsells" })
})
})
describe("useIsUpsellVisible", () => {
it("should return visibility status for specific upsell", async () => {
const TestComponent = ({ upsellId }: { upsellId: string }) => {
const isVisible = useIsUpsellVisible(upsellId)
return <div data-testid="visible">{isVisible.toString()}</div>
}
render(
<DismissedUpsellsProvider>
<TestComponent upsellId="test-upsell" />
</DismissedUpsellsProvider>,
)
// Send dismissed list without the test upsell
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: ["other-upsell"],
},
} as MessageEvent)
})
await waitFor(() => {
expect(screen.getByTestId("visible")).toHaveTextContent("true")
})
// Send dismissed list with the test upsell
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: ["test-upsell", "other-upsell"],
},
} as MessageEvent)
})
await waitFor(() => {
expect(screen.getByTestId("visible")).toHaveTextContent("false")
})
})
it("should work with UPSELL_IDS constants", async () => {
const TestComponent = () => {
const isVisible = useIsUpsellVisible(UPSELL_IDS.TASK_LIST)
return <div data-testid="visible">{isVisible.toString()}</div>
}
render(
<DismissedUpsellsProvider>
<TestComponent />
</DismissedUpsellsProvider>,
)
// Send dismissed list with the constant
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list: [UPSELL_IDS.TASK_LIST],
},
} as MessageEvent)
})
await waitFor(() => {
expect(screen.getByTestId("visible")).toHaveTextContent("false")
})
})
})
})

View file

@ -0,0 +1,299 @@
import { renderHook } from "@testing-library/react"
import React, { ReactNode } from "react"
import { DismissedUpsellsProvider } from "@src/context/DismissedUpsellsContext"
import {
useUpsellVisibility,
useMultipleUpsellVisibility,
useAnyUpsellVisible,
useAllUpsellsVisible,
useUpsellManager,
} from "../useUpsellVisibility"
import { UPSELL_IDS } from "@src/constants/upsellIds"
import { act } from "@testing-library/react"
// Mock vscode
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
describe("useUpsellVisibility hooks", () => {
let messageHandler: ((event: MessageEvent) => void) | null = null
const wrapper = ({ children }: { children: ReactNode }) => (
<DismissedUpsellsProvider>{children}</DismissedUpsellsProvider>
)
beforeEach(() => {
vi.clearAllMocks()
// Capture the message event handler
window.addEventListener = vi.fn((event, handler) => {
if (event === "message") {
messageHandler = handler as (event: MessageEvent) => void
}
})
window.removeEventListener = vi.fn()
})
afterEach(() => {
messageHandler = null
})
const sendDismissedList = (list: string[]) => {
act(() => {
messageHandler?.({
data: {
type: "dismissedUpsells",
list,
},
} as MessageEvent)
})
}
describe("useUpsellVisibility", () => {
it("should return true when upsell is not dismissed", () => {
const { result } = renderHook(() => useUpsellVisibility("test-upsell"), { wrapper })
sendDismissedList([])
expect(result.current).toBe(true)
})
it("should return false when upsell is dismissed", () => {
const { result } = renderHook(() => useUpsellVisibility("test-upsell"), { wrapper })
sendDismissedList(["test-upsell"])
expect(result.current).toBe(false)
})
it("should work with UPSELL_IDS constants", () => {
const { result } = renderHook(() => useUpsellVisibility(UPSELL_IDS.TASK_LIST), { wrapper })
sendDismissedList([UPSELL_IDS.TASK_LIST])
expect(result.current).toBe(false)
})
it("should update when dismissed list changes", () => {
const { result } = renderHook(() => useUpsellVisibility("dynamic-upsell"), { wrapper })
sendDismissedList([])
expect(result.current).toBe(true)
sendDismissedList(["dynamic-upsell"])
expect(result.current).toBe(false)
sendDismissedList([])
expect(result.current).toBe(true)
})
})
describe("useMultipleUpsellVisibility", () => {
it("should return visibility status for multiple upsells", () => {
const upsellIds = ["upsell-1", "upsell-2", "upsell-3"]
const { result } = renderHook(() => useMultipleUpsellVisibility(upsellIds), { wrapper })
sendDismissedList(["upsell-1", "upsell-3"])
expect(result.current).toEqual({
"upsell-1": false,
"upsell-2": true,
"upsell-3": false,
})
})
it("should handle empty array", () => {
const { result } = renderHook(() => useMultipleUpsellVisibility([]), { wrapper })
sendDismissedList(["some-upsell"])
expect(result.current).toEqual({})
})
it("should update when dismissed list changes", () => {
const upsellIds = ["upsell-a", "upsell-b"]
const { result } = renderHook(() => useMultipleUpsellVisibility(upsellIds), { wrapper })
sendDismissedList([])
expect(result.current).toEqual({
"upsell-a": true,
"upsell-b": true,
})
sendDismissedList(["upsell-a"])
expect(result.current).toEqual({
"upsell-a": false,
"upsell-b": true,
})
})
})
describe("useAnyUpsellVisible", () => {
it("should return true if at least one upsell is visible", () => {
const upsellIds = ["upsell-1", "upsell-2", "upsell-3"]
const { result } = renderHook(() => useAnyUpsellVisible(upsellIds), { wrapper })
sendDismissedList(["upsell-1", "upsell-3"])
expect(result.current).toBe(true)
})
it("should return false if all upsells are dismissed", () => {
const upsellIds = ["upsell-1", "upsell-2", "upsell-3"]
const { result } = renderHook(() => useAnyUpsellVisible(upsellIds), { wrapper })
sendDismissedList(["upsell-1", "upsell-2", "upsell-3"])
expect(result.current).toBe(false)
})
it("should return true if no upsells are dismissed", () => {
const upsellIds = ["upsell-1", "upsell-2"]
const { result } = renderHook(() => useAnyUpsellVisible(upsellIds), { wrapper })
sendDismissedList([])
expect(result.current).toBe(true)
})
it("should handle empty array", () => {
const { result } = renderHook(() => useAnyUpsellVisible([]), { wrapper })
sendDismissedList(["some-upsell"])
expect(result.current).toBe(false)
})
})
describe("useAllUpsellsVisible", () => {
it("should return true if all upsells are visible", () => {
const upsellIds = ["upsell-1", "upsell-2", "upsell-3"]
const { result } = renderHook(() => useAllUpsellsVisible(upsellIds), { wrapper })
sendDismissedList(["other-upsell"])
expect(result.current).toBe(true)
})
it("should return false if at least one upsell is dismissed", () => {
const upsellIds = ["upsell-1", "upsell-2", "upsell-3"]
const { result } = renderHook(() => useAllUpsellsVisible(upsellIds), { wrapper })
sendDismissedList(["upsell-2"])
expect(result.current).toBe(false)
})
it("should return false if all upsells are dismissed", () => {
const upsellIds = ["upsell-1", "upsell-2"]
const { result } = renderHook(() => useAllUpsellsVisible(upsellIds), { wrapper })
sendDismissedList(["upsell-1", "upsell-2"])
expect(result.current).toBe(false)
})
it("should handle empty array", () => {
const { result } = renderHook(() => useAllUpsellsVisible([]), { wrapper })
sendDismissedList(["some-upsell"])
expect(result.current).toBe(true)
})
})
describe("useUpsellManager", () => {
it("should provide dismissUpsell function", () => {
const { result } = renderHook(() => useUpsellManager(), { wrapper })
sendDismissedList([])
act(() => {
result.current.dismissUpsell("test-upsell")
})
expect(result.current.dismissedUpsells).toContain("test-upsell")
})
it("should provide dismissMultiple function", () => {
const { result } = renderHook(() => useUpsellManager(), { wrapper })
sendDismissedList([])
act(() => {
result.current.dismissMultiple(["upsell-1", "upsell-2", "upsell-3"])
})
expect(result.current.dismissedUpsells).toContain("upsell-1")
expect(result.current.dismissedUpsells).toContain("upsell-2")
expect(result.current.dismissedUpsells).toContain("upsell-3")
})
it("should provide getDismissedCount function", () => {
const { result } = renderHook(() => useUpsellManager(), { wrapper })
sendDismissedList(["upsell-1", "upsell-2"])
expect(result.current.getDismissedCount()).toBe(2)
})
it("should provide dismissedUpsells array", () => {
const { result } = renderHook(() => useUpsellManager(), { wrapper })
sendDismissedList(["upsell-a", "upsell-b", "upsell-c"])
expect(result.current.dismissedUpsells).toEqual(["upsell-a", "upsell-b", "upsell-c"])
})
it("should provide isLoading state", () => {
const { result } = renderHook(() => useUpsellManager(), { wrapper })
expect(result.current.isLoading).toBe(true)
sendDismissedList([])
expect(result.current.isLoading).toBe(false)
})
it("should log warning for clearAllDismissed", () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
const { result } = renderHook(() => useUpsellManager(), { wrapper })
act(() => {
result.current.clearAllDismissed()
})
expect(consoleSpy).toHaveBeenCalledWith(
"clearAllDismissed is not yet implemented. It requires extension-side support.",
)
consoleSpy.mockRestore()
})
it("should not dismiss same upsell multiple times", () => {
const { result } = renderHook(() => useUpsellManager(), { wrapper })
sendDismissedList([])
// Dismiss once
act(() => {
result.current.dismissUpsell("test-upsell")
})
// Wait for the state to update
expect(result.current.dismissedUpsells).toContain("test-upsell")
// Try to dismiss again (should not add duplicate)
act(() => {
result.current.dismissUpsell("test-upsell")
})
const count = result.current.dismissedUpsells.filter((id) => id === "test-upsell").length
expect(count).toBe(1)
})
})
})

View file

@ -0,0 +1,95 @@
import { useCallback, useMemo } from "react"
import { useDismissedUpsells, useIsUpsellVisible } from "@src/context/DismissedUpsellsContext"
import { UpsellId, UPSELL_IDS, isValidUpsellId } from "@src/constants/upsellIds"
// Re-export upsell constants and types for convenience
export { UPSELL_IDS, isValidUpsellId }
export type { UpsellId }
/**
* Hook to check visibility of a single upsell
* @param upsellId - The ID of the upsell to check
* @returns Whether the upsell is visible (not dismissed)
*/
export function useUpsellVisibility(upsellId: UpsellId | string): boolean {
return useIsUpsellVisible(upsellId)
}
/**
* Hook to check visibility of multiple upsells
* @param upsellIds - Array of upsell IDs to check
* @returns Object with visibility status for each upsell ID
*/
export function useMultipleUpsellVisibility(upsellIds: (UpsellId | string)[]): Record<string, boolean> {
const { isUpsellVisible } = useDismissedUpsells()
return useMemo(() => {
const result: Record<string, boolean> = {}
for (const id of upsellIds) {
result[id] = isUpsellVisible(id)
}
return result
}, [upsellIds, isUpsellVisible])
}
/**
* Hook to check if any of the provided upsells are visible
* @param upsellIds - Array of upsell IDs to check
* @returns True if at least one upsell is visible
*/
export function useAnyUpsellVisible(upsellIds: (UpsellId | string)[]): boolean {
const { isUpsellVisible } = useDismissedUpsells()
return useMemo(() => {
return upsellIds.some((id) => isUpsellVisible(id))
}, [upsellIds, isUpsellVisible])
}
/**
* Hook to check if all of the provided upsells are visible
* @param upsellIds - Array of upsell IDs to check
* @returns True if all upsells are visible
*/
export function useAllUpsellsVisible(upsellIds: (UpsellId | string)[]): boolean {
const { isUpsellVisible } = useDismissedUpsells()
return useMemo(() => {
return upsellIds.every((id) => isUpsellVisible(id))
}, [upsellIds, isUpsellVisible])
}
/**
* Hook that provides upsell management functions
* @returns Object with upsell management functions
*/
export function useUpsellManager() {
const { dismissUpsell, refreshDismissedUpsells, dismissedUpsells, isLoading } = useDismissedUpsells()
const dismissMultiple = useCallback(
(upsellIds: (UpsellId | string)[]) => {
upsellIds.forEach((id) => dismissUpsell(id))
},
[dismissUpsell],
)
const getDismissedCount = useCallback(() => {
return dismissedUpsells.length
}, [dismissedUpsells])
const clearAllDismissed = useCallback(() => {
// This would require a new message type to the extension
console.warn("clearAllDismissed is not yet implemented. It requires extension-side support.")
}, [])
return {
dismissUpsell,
dismissMultiple,
refreshDismissedUpsells,
getDismissedCount,
clearAllDismissed,
dismissedUpsells,
isLoading,
}
}