-
Notifications
You must be signed in to change notification settings - Fork 2.5k
feat: Add DismissibleUpsell component for dismissible messages #7842
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2998,5 +2998,33 @@ export const webviewMessageHandler = async ( | |
|
|
||
| break | ||
| } | ||
| case "dismissUpsell": { | ||
| if (message.upsellId) { | ||
| // Get current list of dismissed upsells | ||
| const dismissedUpsells = getGlobalState("dismissedUpsells") || [] | ||
|
|
||
| // Add the new upsell ID if not already present | ||
| if (!dismissedUpsells.includes(message.upsellId)) { | ||
| const updatedList = [...dismissedUpsells, message.upsellId] | ||
| await updateGlobalState("dismissedUpsells", updatedList) | ||
| } | ||
|
|
||
| // Send updated list back to webview | ||
| await provider.postMessageToWebview({ | ||
| type: "dismissedUpsells", | ||
| list: [...dismissedUpsells, message.upsellId], | ||
|
||
| }) | ||
| } | ||
| break | ||
| } | ||
| case "getDismissedUpsells": { | ||
| // Send the current list of dismissed upsells to the webview | ||
| const dismissedUpsells = getGlobalState("dismissedUpsells") || [] | ||
| await provider.postMessageToWebview({ | ||
| type: "dismissedUpsells", | ||
| list: dismissedUpsells, | ||
| }) | ||
| break | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,144 @@ | ||||||
| import { memo, ReactNode, useEffect, useState } from "react" | ||||||
| import styled from "styled-components" | ||||||
|
|
||||||
| import { vscode } from "@src/utils/vscode" | ||||||
|
|
||||||
| interface DismissibleUpsellProps { | ||||||
| /** Required unique identifier for this upsell */ | ||||||
| className: string | ||||||
|
||||||
| /** Content to display inside the upsell */ | ||||||
| children: ReactNode | ||||||
| /** Visual variant of the upsell */ | ||||||
| variant?: "banner" | "default" | ||||||
| /** Optional callback when upsell is dismissed */ | ||||||
| onDismiss?: () => void | ||||||
| } | ||||||
|
|
||||||
| const UpsellContainer = styled.div<{ $variant: "banner" | "default" }>` | ||||||
| position: relative; | ||||||
| padding: 12px 40px 12px 16px; | ||||||
| border-radius: 6px; | ||||||
| margin-bottom: 8px; | ||||||
| display: flex; | ||||||
| align-items: center; | ||||||
| ${(props) => | ||||||
| props.$variant === "banner" | ||||||
| ? ` | ||||||
| background-color: var(--vscode-button-background); | ||||||
| color: var(--vscode-button-foreground); | ||||||
| ` | ||||||
| : ` | ||||||
| background-color: var(--vscode-notifications-background); | ||||||
| color: var(--vscode-notifications-foreground); | ||||||
| border: 1px solid var(--vscode-notifications-border); | ||||||
| `} | ||||||
| ` | ||||||
|
|
||||||
| const DismissButton = styled.button<{ $variant: "banner" | "default" }>` | ||||||
| position: absolute; | ||||||
| top: 50%; | ||||||
| right: 12px; | ||||||
| transform: translateY(-50%); | ||||||
| background: none; | ||||||
| border: none; | ||||||
| cursor: pointer; | ||||||
| padding: 4px; | ||||||
| display: flex; | ||||||
| align-items: center; | ||||||
| justify-content: center; | ||||||
| border-radius: 4px; | ||||||
| transition: background-color 0.2s; | ||||||
| ${(props) => | ||||||
| props.$variant === "banner" | ||||||
| ? ` | ||||||
| color: var(--vscode-button-foreground); | ||||||
| &:hover { | ||||||
| background-color: rgba(255, 255, 255, 0.1); | ||||||
| } | ||||||
| ` | ||||||
| : ` | ||||||
| color: var(--vscode-notifications-foreground); | ||||||
| &:hover { | ||||||
| background-color: var(--vscode-toolbar-hoverBackground); | ||||||
| } | ||||||
| `} | ||||||
| &:focus { | ||||||
| outline: 1px solid var(--vscode-focusBorder); | ||||||
| outline-offset: 1px; | ||||||
| } | ||||||
| ` | ||||||
|
|
||||||
| const DismissIcon = () => ( | ||||||
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> | ||||||
| <path | ||||||
| fillRule="evenodd" | ||||||
| clipRule="evenodd" | ||||||
| d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.647 3.646.708.707L8 8.707z" | ||||||
| fill="currentColor" | ||||||
| /> | ||||||
| </svg> | ||||||
| ) | ||||||
|
|
||||||
| const DismissibleUpsell = memo(({ className, children, variant = "banner", onDismiss }: DismissibleUpsellProps) => { | ||||||
| const [isVisible, setIsVisible] = useState(true) | ||||||
|
|
||||||
| useEffect(() => { | ||||||
| // Request the current list of dismissed upsells from the extension | ||||||
| vscode.postMessage({ type: "getDismissedUpsells" }) | ||||||
|
|
||||||
| // Listen for the response | ||||||
| const handleMessage = (event: MessageEvent) => { | ||||||
| const message = event.data | ||||||
| if (message.type === "dismissedUpsells" && Array.isArray(message.list)) { | ||||||
| // Check if this upsell has been dismissed | ||||||
| if (message.list.includes(className)) { | ||||||
| setIsVisible(false) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| window.addEventListener("message", handleMessage) | ||||||
| return () => window.removeEventListener("message", handleMessage) | ||||||
| }, [className]) | ||||||
|
||||||
|
|
||||||
| const handleDismiss = () => { | ||||||
| // Hide the upsell immediately | ||||||
| setIsVisible(false) | ||||||
|
|
||||||
| // Notify the extension to persist the dismissal | ||||||
| vscode.postMessage({ | ||||||
| type: "dismissUpsell", | ||||||
| upsellId: className, | ||||||
| }) | ||||||
|
|
||||||
| // Call the optional callback | ||||||
| onDismiss?.() | ||||||
| } | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Race condition: If the component unmounts immediately after clicking dismiss but before the message is sent, the dismissal won't be persisted. Consider ensuring the message is sent before hiding the component. |
||||||
|
|
||||||
| // Don't render if not visible | ||||||
| if (!isVisible) { | ||||||
| return null | ||||||
| } | ||||||
|
|
||||||
| return ( | ||||||
| <UpsellContainer $variant={variant} className={className}> | ||||||
| {children} | ||||||
| <DismissButton | ||||||
| $variant={variant} | ||||||
| onClick={handleDismiss} | ||||||
| aria-label="Dismiss" | ||||||
|
||||||
| aria-label="Dismiss" | |
| aria-label={i18n('dismiss')} |
This comment was generated because it violated a code review rule: irule_C0ez7Rji6ANcGkkX.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import { render, screen, fireEvent, waitFor } from "@testing-library/react" | ||
| import { describe, it, expect, vi, beforeEach } from "vitest" | ||
| import DismissibleUpsell from "../DismissibleUpsell" | ||
|
|
||
| // Mock the vscode API | ||
| const mockPostMessage = vi.fn() | ||
| vi.mock("@src/utils/vscode", () => ({ | ||
| vscode: { | ||
| postMessage: (message: any) => mockPostMessage(message), | ||
| }, | ||
| })) | ||
|
|
||
| describe("DismissibleUpsell", () => { | ||
| beforeEach(() => { | ||
| mockPostMessage.mockClear() | ||
| }) | ||
|
|
||
| it("renders children content", () => { | ||
| render( | ||
| <DismissibleUpsell className="test-upsell"> | ||
| <div>Test content</div> | ||
| </DismissibleUpsell>, | ||
| ) | ||
|
|
||
| expect(screen.getByText("Test content")).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it("applies the correct variant styles", () => { | ||
| const { container, rerender } = render( | ||
| <DismissibleUpsell className="test-upsell" variant="banner"> | ||
| <div>Banner content</div> | ||
| </DismissibleUpsell>, | ||
| ) | ||
|
|
||
| // Check banner variant has correct background color style | ||
| const bannerContainer = container.firstChild | ||
| expect(bannerContainer).toHaveStyle({ | ||
| backgroundColor: "var(--vscode-button-background)", | ||
| color: "var(--vscode-button-foreground)", | ||
| }) | ||
|
|
||
| // Re-render with default variant | ||
| rerender( | ||
| <DismissibleUpsell className="test-upsell" variant="default"> | ||
| <div>Default content</div> | ||
| </DismissibleUpsell>, | ||
| ) | ||
|
|
||
| const defaultContainer = container.firstChild | ||
| expect(defaultContainer).toHaveStyle({ | ||
| backgroundColor: "var(--vscode-notifications-background)", | ||
| color: "var(--vscode-notifications-foreground)", | ||
| }) | ||
| }) | ||
|
|
||
| it("requests dismissed upsells list on mount", () => { | ||
| render( | ||
| <DismissibleUpsell className="test-upsell"> | ||
| <div>Test content</div> | ||
| </DismissibleUpsell>, | ||
| ) | ||
|
|
||
| expect(mockPostMessage).toHaveBeenCalledWith({ | ||
| type: "getDismissedUpsells", | ||
| }) | ||
| }) | ||
|
|
||
| it("hides the upsell when dismiss button is clicked", async () => { | ||
| const onDismiss = vi.fn() | ||
| const { container } = render( | ||
| <DismissibleUpsell className="test-upsell" onDismiss={onDismiss}> | ||
| <div>Test content</div> | ||
| </DismissibleUpsell>, | ||
| ) | ||
|
|
||
| // Find and click the dismiss button | ||
| const dismissButton = screen.getByRole("button", { name: /dismiss/i }) | ||
| fireEvent.click(dismissButton) | ||
|
|
||
| // Check that the component is no longer visible | ||
| await waitFor(() => { | ||
| expect(container.firstChild).toBeNull() | ||
| }) | ||
|
|
||
| // Check that the dismiss message was sent | ||
| expect(mockPostMessage).toHaveBeenCalledWith({ | ||
| type: "dismissUpsell", | ||
| upsellId: "test-upsell", | ||
| }) | ||
|
|
||
| // Check that the callback was called | ||
| expect(onDismiss).toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("hides the upsell if it's in the dismissed list", async () => { | ||
| const { container } = render( | ||
| <DismissibleUpsell className="test-upsell"> | ||
| <div>Test content</div> | ||
| </DismissibleUpsell>, | ||
| ) | ||
|
|
||
| // 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) | ||
|
|
||
| // Check that the component is no longer visible | ||
| await waitFor(() => { | ||
| expect(container.firstChild).toBeNull() | ||
| }) | ||
| }) | ||
|
|
||
| it("remains visible if not in the dismissed list", async () => { | ||
| render( | ||
| <DismissibleUpsell className="test-upsell"> | ||
| <div>Test content</div> | ||
| </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) | ||
|
|
||
| // Check that the component is still visible | ||
| await waitFor(() => { | ||
| expect(screen.getByText("Test content")).toBeInTheDocument() | ||
| }) | ||
| }) | ||
|
|
||
| it("applies the className prop to the container", () => { | ||
| const { container } = render( | ||
| <DismissibleUpsell className="custom-class"> | ||
| <div>Test content</div> | ||
| </DismissibleUpsell>, | ||
| ) | ||
|
|
||
| expect(container.firstChild).toHaveClass("custom-class") | ||
| }) | ||
|
|
||
| it("dismiss button has proper accessibility attributes", () => { | ||
| render( | ||
| <DismissibleUpsell className="test-upsell"> | ||
| <div>Test content</div> | ||
| </DismissibleUpsell>, | ||
| ) | ||
|
|
||
| const dismissButton = screen.getByRole("button", { name: /dismiss/i }) | ||
| expect(dismissButton).toHaveAttribute("aria-label", "Dismiss") | ||
| expect(dismissButton).toHaveAttribute("title", "Dismiss and don't show again") | ||
| }) | ||
| }) | ||
brunobergher marked this conversation as resolved.
Show resolved
Hide resolved
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing error handling. What happens if updateGlobalState fails? Consider wrapping in try-catch and logging any errors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's OK to fail silently in this case