diff --git a/static/app/components/sidebar/broadcastPanelItem.tsx b/static/app/components/sidebar/broadcastPanelItem.tsx new file mode 100644 index 0000000000000..1568fde2f7497 --- /dev/null +++ b/static/app/components/sidebar/broadcastPanelItem.tsx @@ -0,0 +1,100 @@ +import {useCallback} from 'react'; +import styled from '@emotion/styled'; + +import Badge from 'sentry/components/badge/badge'; +import Tag from 'sentry/components/badge/tag'; +import ExternalLink from 'sentry/components/links/externalLink'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {Broadcast} from 'sentry/types/system'; +import {trackAnalytics} from 'sentry/utils/analytics'; +import useOrganization from 'sentry/utils/useOrganization'; + +export const BROADCAST_CATEGORIES: Record, string> = { + announcement: t('Announcement'), + feature: t('New Feature'), + blog: t('Blog Post'), + event: t('Event'), + video: t('Video'), +}; + +interface BroadcastPanelItemProps + extends Pick< + Broadcast, + 'hasSeen' | 'category' | 'title' | 'message' | 'link' | 'mediaUrl' + > {} + +export function BroadcastPanelItem({ + hasSeen, + title, + message, + link, + mediaUrl, + category, +}: BroadcastPanelItemProps) { + const organization = useOrganization(); + + const handlePanelClicked = useCallback(() => { + trackAnalytics('whats_new.link_clicked', {organization, title, category}); + }, [organization, title, category]); + + return ( + + + {category && + (hasSeen ? ( + {BROADCAST_CATEGORIES[category]} + ) : ( + {BROADCAST_CATEGORIES[category]} + ))} + + {title} + + {message} + + {mediaUrl && } + + ); +} + +const SidebarPanelItemRoot = styled('div')` + line-height: 1.5; + background: ${p => p.theme.background}; + margin: 0 ${space(3)}; + padding: ${space(2)} 0; + + :not(:first-child) { + border-top: 1px solid ${p => p.theme.border}; + } +`; + +const Title = styled(ExternalLink)>` + font-size: ${p => p.theme.fontSizeLarge}; + color: ${p => p.theme.blue400}; + ${p => !p.hasSeen && `font-weight: ${p.theme.fontWeightBold}`}; +`; + +const Message = styled('div')` + color: ${p => p.theme.subText}; +`; + +const TextBlock = styled('div')` + margin-bottom: ${space(1.5)}; + display: flex; + flex-direction: column; +`; + +const Media = styled('img')` + border-radius: ${p => p.theme.borderRadius}; + border: 1px solid ${p => p.theme.translucentGray200}; + max-width: 100%; +`; + +const CategoryTag = styled(Tag)` + margin-bottom: ${space(1)}; +`; + +const CategoryBadge = styled(Badge)` + margin-left: 0; + margin-bottom: ${space(1)}; +`; diff --git a/static/app/components/sidebar/broadcasts.spec.tsx b/static/app/components/sidebar/broadcasts.spec.tsx new file mode 100644 index 0000000000000..de3710dd04c52 --- /dev/null +++ b/static/app/components/sidebar/broadcasts.spec.tsx @@ -0,0 +1,110 @@ +import {BroadcastFixture} from 'sentry-fixture/broadcast'; +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {BROADCAST_CATEGORIES} from 'sentry/components/sidebar/broadcastPanelItem'; +import Broadcasts from 'sentry/components/sidebar/broadcasts'; +import {SidebarPanelKey} from 'sentry/components/sidebar/types'; +import type {Broadcast} from 'sentry/types/system'; +import {trackAnalytics} from 'sentry/utils/analytics'; + +jest.mock('sentry/utils/analytics'); + +function renderMockRequests({ + orgSlug, + broadcastsResponse, +}: { + orgSlug: string; + broadcastsResponse?: Broadcast[]; +}) { + MockApiClient.addMockResponse({ + url: '/broadcasts/', + method: 'PUT', + }); + MockApiClient.addMockResponse({ + url: `/organizations/${orgSlug}/broadcasts/`, + body: broadcastsResponse ?? [], + }); +} + +describe('Broadcasts', function () { + const category = 'blog'; + + it('renders empty state', async function () { + const organization = OrganizationFixture(); + + renderMockRequests({orgSlug: organization.slug}); + + render( + jest.fn()} + hidePanel={jest.fn()} + organization={organization} + /> + ); + + expect(await screen.findByText(/No recent updates/)).toBeInTheDocument(); + }); + + it('renders a broadcast item with media content correctly', async function () { + const organization = OrganizationFixture({features: ['what-is-new-revamp']}); + const broadcast = BroadcastFixture({ + mediaUrl: + 'https://images.ctfassets.net/em6l9zw4tzag/2vWdw7ZaApWxygugalbyOC/285525e5b7c9fbfa8fb814a69ab214cd/PerformancePageSketches_hero.jpg?w=2520&h=945&q=50&fm=webp', + category, + }); + + renderMockRequests({orgSlug: organization.slug, broadcastsResponse: [broadcast]}); + + render( + jest.fn()} + hidePanel={jest.fn()} + organization={organization} + /> + ); + + // Verify that the broadcast content is rendered correctly + expect(await screen.findByText(BROADCAST_CATEGORIES[category])).toBeInTheDocument(); + const titleLink = screen.getByRole('link', {name: broadcast.title}); + expect(titleLink).toHaveAttribute('href', broadcast.link); + expect(screen.getByText(/Source maps are JSON/)).toBeInTheDocument(); + + // Simulate click and check if analytics tracking is called + await userEvent.click(titleLink); + expect(trackAnalytics).toHaveBeenCalledWith( + 'whats_new.link_clicked', + expect.objectContaining({ + title: broadcast.title, + category, + }) + ); + }); + + it('renders deprecated broadcast experience', async function () { + const organization = OrganizationFixture(); + const broadcast = BroadcastFixture(); + + renderMockRequests({orgSlug: organization.slug, broadcastsResponse: [broadcast]}); + + render( + jest.fn()} + hidePanel={jest.fn()} + organization={organization} + /> + ); + + expect(await screen.findByRole('link', {name: broadcast.cta})).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/sidebar/broadcasts.tsx b/static/app/components/sidebar/broadcasts.tsx index 539e25284918d..bae0605da3570 100644 --- a/static/app/components/sidebar/broadcasts.tsx +++ b/static/app/components/sidebar/broadcasts.tsx @@ -4,6 +4,7 @@ import {getAllBroadcasts, markBroadcastsAsSeen} from 'sentry/actionCreators/broa import type {Client} from 'sentry/api'; import DemoModeGate from 'sentry/components/acl/demoModeGate'; import LoadingIndicator from 'sentry/components/loadingIndicator'; +import {BroadcastPanelItem} from 'sentry/components/sidebar/broadcastPanelItem'; import SidebarItem from 'sentry/components/sidebar/sidebarItem'; import SidebarPanel from 'sentry/components/sidebar/sidebarPanel'; import SidebarPanelEmpty from 'sentry/components/sidebar/sidebarPanelEmpty'; @@ -115,10 +116,11 @@ class Broadcasts extends Component { } render() { - const {orientation, collapsed, currentPanel, hidePanel} = this.props; + const {orientation, collapsed, currentPanel, hidePanel, organization} = this.props; const {broadcasts, loading} = this.state; const unseenPosts = this.unseenIds; + const whatIsNewRevampFeature = organization.features.includes('what-is-new-revamp'); return ( @@ -149,6 +151,18 @@ class Broadcasts extends Component { {t('No recent updates from the Sentry team.')} + ) : whatIsNewRevampFeature ? ( + broadcasts.map(item => ( + + )) ) : ( broadcasts.map(item => ( - trackAnalytics('whats_new.link_clicked', {organization, title}) - } + onClick={() => { + if (!title) { + return; + } + trackAnalytics('whats_new.link_clicked', {organization, title}); + }} > {cta || t('Read More')} diff --git a/static/app/types/system.tsx b/static/app/types/system.tsx index a02cb9dea2e06..3bd7c356d582e 100644 --- a/static/app/types/system.tsx +++ b/static/app/types/system.tsx @@ -228,17 +228,38 @@ export type PipelineInitialData = { props: Record; }; -export type Broadcast = { - cta: string; +export interface Broadcast { dateCreated: string; dateExpires: string; + /** + * Has the item been seen? affects the styling of the panel item + */ hasSeen: boolean; id: string; isActive: boolean; + /** + * The URL to use for the CTA + */ link: string; + /** + * A message with muted styling which appears above the children content + */ message: string; title: string; -}; + /** + * Category of the broadcast. + * Synced with https://github.com/getsentry/sentry/blob/master/src/sentry/models/broadcast.py#L14 + */ + category?: 'announcement' | 'feature' | 'blog' | 'event' | 'video'; + /** + * The text for the CTA link at the bottom of the panel item + */ + cta?: string; + /** + * Image url + */ + mediaUrl?: string; +} // XXX(epurkhiser): The components list can be generated using jq // diff --git a/static/app/utils/analytics/issueAnalyticsEvents.tsx b/static/app/utils/analytics/issueAnalyticsEvents.tsx index eb951b0da5330..51fd97cd33afa 100644 --- a/static/app/utils/analytics/issueAnalyticsEvents.tsx +++ b/static/app/utils/analytics/issueAnalyticsEvents.tsx @@ -2,6 +2,7 @@ import type {SourceMapProcessingIssueType} from 'sentry/components/events/interf import type {FieldValue} from 'sentry/components/forms/model'; import type {PriorityLevel} from 'sentry/types/group'; import type {IntegrationType} from 'sentry/types/integrations'; +import type {Broadcast} from 'sentry/types/system'; import type {BaseEventAnalyticsParams} from 'sentry/utils/analytics/workflowAnalyticsEvents'; import type {CommonGroupAnalyticsData} from 'sentry/utils/events'; @@ -296,7 +297,8 @@ export type IssueEventParameters = { 'tag.clicked': { is_clickable: boolean; }; - 'whats_new.link_clicked': {title?: string}; + 'whats_new.link_clicked': Pick & + Partial>; }; export type IssueEventKey = keyof IssueEventParameters;